mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-13 04:45:19 +00:00
add invite system and google oauth provider (#185)
* add settings page with members list * add invite to schema and basic create form * add invite table * add basic invite link copy button * add auth invite accept case * add non auth logic * add google oauth provider
This commit is contained in:
parent
846d73b0e6
commit
90550181af
18 changed files with 575 additions and 4 deletions
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Invite" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"recipientEmail" TEXT NOT NULL,
|
||||||
|
"hostUserId" TEXT NOT NULL,
|
||||||
|
"orgId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Invite_recipientEmail_orgId_key" ON "Invite"("recipientEmail", "orgId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_hostUserId_fkey" FOREIGN KEY ("hostUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -83,6 +83,27 @@ model RepoToConnection {
|
||||||
@@id([connectionId, repoId])
|
@@id([connectionId, repoId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Invite {
|
||||||
|
/// The globally unique invite id
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
/// Time of invite creation
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
/// The email of the recipient of the invite
|
||||||
|
recipientEmail String
|
||||||
|
|
||||||
|
/// The user that created the invite
|
||||||
|
host User @relation(fields: [hostUserId], references: [id], onDelete: Cascade)
|
||||||
|
hostUserId String
|
||||||
|
|
||||||
|
/// The organization the invite is for
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
orgId Int
|
||||||
|
|
||||||
|
@@unique([recipientEmail, orgId])
|
||||||
|
}
|
||||||
|
|
||||||
model Org {
|
model Org {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
|
|
@ -92,6 +113,9 @@ model Org {
|
||||||
connections Connection[]
|
connections Connection[]
|
||||||
repos Repo[]
|
repos Repo[]
|
||||||
secrets Secret[]
|
secrets Secret[]
|
||||||
|
|
||||||
|
/// List of pending invites to this organization
|
||||||
|
invites Invite[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OrgRole {
|
enum OrgRole {
|
||||||
|
|
@ -139,6 +163,9 @@ model User {
|
||||||
orgs UserToOrg[]
|
orgs UserToOrg[]
|
||||||
activeOrgId Int?
|
activeOrgId Int?
|
||||||
|
|
||||||
|
/// List of pending invites that the user has created
|
||||||
|
invites Invite[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
||||||
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
import { encrypt } from "@sourcebot/crypto"
|
import { encrypt } from "@sourcebot/crypto"
|
||||||
import { getConnection } from "./data/connection";
|
import { getConnection } from "./data/connection";
|
||||||
import { Prisma } from "@sourcebot/db";
|
import { Prisma, Invite } from "@sourcebot/db";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
|
|
@ -301,3 +301,58 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
|
||||||
|
|
||||||
return parsedConfig;
|
return parsedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createInvite = async (email: string, userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => {
|
||||||
|
console.log("Creating invite for", email, userId, orgId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.invite.create({
|
||||||
|
data: {
|
||||||
|
recipientEmail: email,
|
||||||
|
hostUserId: userId,
|
||||||
|
orgId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create invite:", error);
|
||||||
|
return unexpectedError("Failed to create invite");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const redeemInvite = async (invite: Invite, userId: string): Promise<{ orgId: number } | ServiceError> => {
|
||||||
|
try {
|
||||||
|
await prisma.userToOrg.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
orgId: invite.orgId,
|
||||||
|
role: "MEMBER",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
activeOrgId: invite.orgId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.invite.delete({
|
||||||
|
where: {
|
||||||
|
id: invite.id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
orgId: invite.orgId,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to redeem invite:", error);
|
||||||
|
return unexpectedError("Failed to redeem invite");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,13 @@ export const NavigationMenu = async () => {
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<Link href="/settings" legacyBehavior passHref>
|
||||||
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
Settings
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
</NavigationMenuBase>
|
</NavigationMenuBase>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { auth } from "@/auth";
|
||||||
import { getUser } from "@/data/user";
|
import { getUser } from "@/data/user";
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import { ConnectionList } from "./components/connectionList";
|
import { ConnectionList } from "./components/connectionList";
|
||||||
import { Header } from "./components/header";
|
import { Header } from "../components/header";
|
||||||
import { NewConnectionCard } from "./components/newConnectionCard";
|
import { NewConnectionCard } from "./components/newConnectionCard";
|
||||||
|
|
||||||
export default async function ConnectionsPage() {
|
export default async function ConnectionsPage() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { redeemInvite } from "../../../actions";
|
||||||
|
import { isServiceError } from "@/lib/utils"
|
||||||
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Invite } from "@sourcebot/db"
|
||||||
|
|
||||||
|
interface AcceptInviteButtonProps {
|
||||||
|
invite: Invite
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcceptInviteButton({ invite, userId }: AcceptInviteButtonProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const handleAcceptInvite = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await redeemInvite(invite, userId)
|
||||||
|
if (isServiceError(res)) {
|
||||||
|
console.log("Failed to redeem invite: ", res)
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to redeem invite. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.push("/")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error redeeming invite:", error)
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "An unexpected error occurred. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={handleAcceptInvite} disabled={isLoading}>
|
||||||
|
{isLoading ? "Accepting..." : "Accept Invite"}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
84
packages/web/src/app/redeem/page.tsx
Normal file
84
packages/web/src/app/redeem/page.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { prisma } from "@/prisma";
|
||||||
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
import { NavigationMenu } from "../components/navigationMenu";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getUser } from "@/data/user";
|
||||||
|
import { AcceptInviteButton } from "./components/acceptInviteButton"
|
||||||
|
|
||||||
|
interface RedeemPageProps {
|
||||||
|
searchParams?: {
|
||||||
|
invite_id?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
|
||||||
|
const invite_id = searchParams?.invite_id;
|
||||||
|
|
||||||
|
if (!invite_id) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const invite = await prisma.invite.findUnique({
|
||||||
|
where: { id: invite_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<NavigationMenu />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<h1>This invite either expired or was revoked. Contact your organization owner.</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
let user = undefined;
|
||||||
|
if (session) {
|
||||||
|
user = await getUser(session.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Auth case
|
||||||
|
if (user) {
|
||||||
|
if (user.email !== invite.recipientEmail) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<NavigationMenu />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<h1>Sorry this invite does not belong to you.</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const orgName = await prisma.org.findUnique({
|
||||||
|
where: { id: invite.orgId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!orgName) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<NavigationMenu />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<h1>Organization not found. Please contact the invite sender.</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<NavigationMenu />
|
||||||
|
<div className="flex justify-between items-center h-screen px-6">
|
||||||
|
<h1 className="text-2xl font-bold">You've been invited to org {orgName.name}</h1>
|
||||||
|
<AcceptInviteButton invite={invite} userId={user.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
redirect(`/login?callbackUrl=${encodeURIComponent(`/redeem?invite_id=${invite_id}`)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
packages/web/src/app/settings/components/inviteTable.tsx
Normal file
40
packages/web/src/app/settings/components/inviteTable.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
'use client';
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { User } from "@sourcebot/db";
|
||||||
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import { InviteColumnInfo, inviteTableColumns } from "./inviteTableColumns"
|
||||||
|
|
||||||
|
export interface InviteInfo {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InviteTableProps {
|
||||||
|
initialInvites: InviteInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InviteTable = ({ initialInvites }: InviteTableProps) => {
|
||||||
|
const [invites, setInvites] = useState<InviteInfo[]>(initialInvites);
|
||||||
|
|
||||||
|
const inviteRows: InviteColumnInfo[] = useMemo(() => {
|
||||||
|
return invites.map(invite => {
|
||||||
|
return {
|
||||||
|
id: invite.id!,
|
||||||
|
email: invite.email!,
|
||||||
|
createdAt: invite.createdAt!,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [invites]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DataTable
|
||||||
|
columns={inviteTableColumns()}
|
||||||
|
data={inviteRows}
|
||||||
|
searchKey="email"
|
||||||
|
searchPlaceholder="Search invites..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { resolveServerPath } from "../../api/(client)/client";
|
||||||
|
import { createPathWithQueryParams } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type InviteColumnInfo = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inviteTableColumns = (): ColumnDef<InviteColumnInfo>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: "email",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const invite = row.original;
|
||||||
|
return <div>{invite.email}</div>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const invite = row.original;
|
||||||
|
return invite.createdAt.toISOString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "copy",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const invite = row.original;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => {
|
||||||
|
const basePath = `${window.location.origin}${resolveServerPath('/')}`;
|
||||||
|
const url = createPathWithQueryParams(`${basePath}redeem?invite_id=${invite.id}`);
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
'use client'
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { createInvite } from "../../../actions"
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
email: z.string().min(2).max(40),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MemberInviteForm = ({ orgId, userId }: { orgId: number, userId: string }) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateInvite = async (values: { email: string }) => {
|
||||||
|
const res = await createInvite(values.email, userId, orgId);
|
||||||
|
if (isServiceError(res)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Failed to create invite`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
description: `✅ Invite created successfully!`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleCreateInvite)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button className="mt-5" type="submit">Submit</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
packages/web/src/app/settings/components/memberTable.tsx
Normal file
38
packages/web/src/app/settings/components/memberTable.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
'use client';
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { User } from "@sourcebot/db";
|
||||||
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import { MemberColumnInfo, memberTableColumns } from "./memberTableColumns";
|
||||||
|
|
||||||
|
export interface MemberInfo {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberTableProps {
|
||||||
|
initialMembers: MemberInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemberTable = ({ initialMembers }: MemberTableProps) => {
|
||||||
|
const [members, setMembers] = useState<MemberInfo[]>(initialMembers);
|
||||||
|
|
||||||
|
const memberRows: MemberColumnInfo[] = useMemo(() => {
|
||||||
|
return members.map(member => {
|
||||||
|
return {
|
||||||
|
name: member.name!,
|
||||||
|
role: member.role!,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<DataTable
|
||||||
|
columns={memberTableColumns()}
|
||||||
|
data={memberRows}
|
||||||
|
searchKey="name"
|
||||||
|
searchPlaceholder="Search members..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Column, ColumnDef } from "@tanstack/react-table"
|
||||||
|
|
||||||
|
export type MemberColumnInfo = {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memberTableColumns = (): ColumnDef<MemberColumnInfo>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const member = row.original;
|
||||||
|
return <div>{member.name}</div>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "role",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const member = row.original;
|
||||||
|
return <div>{member.role}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
17
packages/web/src/app/settings/layout.tsx
Normal file
17
packages/web/src/app/settings/layout.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { NavigationMenu } from "../components/navigationMenu";
|
||||||
|
|
||||||
|
export default function Layout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<NavigationMenu />
|
||||||
|
<main className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative">
|
||||||
|
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
packages/web/src/app/settings/page.tsx
Normal file
80
packages/web/src/app/settings/page.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { Header } from "../components/header";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getUser } from "@/data/user";
|
||||||
|
import { prisma } from "@/prisma";
|
||||||
|
import { MemberTable } from "./components/memberTable";
|
||||||
|
import { MemberInviteForm } from "./components/memberInviteForm";
|
||||||
|
import { InviteTable } from "./components/inviteTable";
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const fetchData = async () => {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
if (!user || !user.activeOrgId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
orgs: {
|
||||||
|
some: {
|
||||||
|
orgId: user.activeOrgId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
orgs: {
|
||||||
|
where: {
|
||||||
|
orgId: user.activeOrgId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const invites = await prisma.invite.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: user.activeOrgId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberInfo = members.map((member) => ({
|
||||||
|
name: member.name!,
|
||||||
|
role: member.orgs[0].role,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const inviteInfo = invites.map((invite) => ({
|
||||||
|
id: invite.id,
|
||||||
|
email: invite.recipientEmail,
|
||||||
|
createdAt: invite.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { user, memberInfo, inviteInfo };
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await fetchData();
|
||||||
|
if (!data) {
|
||||||
|
return <div>Error: Unable to fetch data</div>;
|
||||||
|
}
|
||||||
|
const { user, memberInfo, inviteInfo } = data;
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header>
|
||||||
|
<h1 className="text-3xl">Settings</h1>
|
||||||
|
</Header>
|
||||||
|
<div>
|
||||||
|
<MemberInviteForm orgId={user.activeOrgId!} userId={user.id} />
|
||||||
|
<InviteTable initialInvites={inviteInfo} />
|
||||||
|
<MemberTable initialMembers={memberInfo} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import 'next-auth/jwt';
|
import 'next-auth/jwt';
|
||||||
import NextAuth, { User as AuthJsUser, DefaultSession } from "next-auth"
|
import NextAuth, { User as AuthJsUser, DefaultSession } from "next-auth"
|
||||||
import GitHub from "next-auth/providers/github"
|
import GitHub from "next-auth/providers/github"
|
||||||
|
import Google from "next-auth/providers/google"
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter"
|
import { PrismaAdapter } from "@auth/prisma-adapter"
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import type { Provider } from "next-auth/providers"
|
import type { Provider } from "next-auth/providers"
|
||||||
import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_SECRET } from "./lib/environment";
|
import { AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET, AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET, AUTH_SECRET } from "./lib/environment";
|
||||||
import { User } from '@sourcebot/db';
|
import { User } from '@sourcebot/db';
|
||||||
import { notAuthenticated, notFound, unexpectedError } from "@/lib/serviceError";
|
import { notAuthenticated, notFound, unexpectedError } from "@/lib/serviceError";
|
||||||
import { getUser } from "./data/user";
|
import { getUser } from "./data/user";
|
||||||
|
|
@ -28,6 +29,10 @@ const providers: Provider[] = [
|
||||||
clientId: AUTH_GITHUB_CLIENT_ID,
|
clientId: AUTH_GITHUB_CLIENT_ID,
|
||||||
clientSecret: AUTH_GITHUB_CLIENT_SECRET,
|
clientSecret: AUTH_GITHUB_CLIENT_SECRET,
|
||||||
}),
|
}),
|
||||||
|
Google({
|
||||||
|
clientId: AUTH_GOOGLE_CLIENT_ID!,
|
||||||
|
clientSecret: AUTH_GOOGLE_CLIENT_SECRET!,
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
// @see: https://authjs.dev/guides/pages/signin
|
// @see: https://authjs.dev/guides/pages/signin
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,5 @@ export const NODE_ENV = process.env.NODE_ENV;
|
||||||
export const AUTH_SECRET = getEnv(process.env.AUTH_SECRET); // Generate using `npx auth secret`
|
export const AUTH_SECRET = getEnv(process.env.AUTH_SECRET); // Generate using `npx auth secret`
|
||||||
export const AUTH_GITHUB_CLIENT_ID = getEnv(process.env.AUTH_GITHUB_CLIENT_ID);
|
export const AUTH_GITHUB_CLIENT_ID = getEnv(process.env.AUTH_GITHUB_CLIENT_ID);
|
||||||
export const AUTH_GITHUB_CLIENT_SECRET = getEnv(process.env.AUTH_GITHUB_CLIENT_SECRET);
|
export const AUTH_GITHUB_CLIENT_SECRET = getEnv(process.env.AUTH_GITHUB_CLIENT_SECRET);
|
||||||
|
export const AUTH_GOOGLE_CLIENT_ID = getEnv(process.env.AUTH_GOOGLE_CLIENT_ID);
|
||||||
|
export const AUTH_GOOGLE_CLIENT_SECRET = getEnv(process.env.AUTH_GOOGLE_CLIENT_SECRET);
|
||||||
|
|
@ -23,6 +23,12 @@ const apiMiddleware = (req: NextAuthRequest) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultMiddleware = (req: NextAuthRequest) => {
|
const defaultMiddleware = (req: NextAuthRequest) => {
|
||||||
|
// if we're trying to redeem an invite while not authed we continue to the redeem page so
|
||||||
|
// that we can pipe the invite_id to the login page
|
||||||
|
if (!req.auth && req.nextUrl.pathname === "/redeem") {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.auth && req.nextUrl.pathname !== "/login") {
|
if (!req.auth && req.nextUrl.pathname !== "/login") {
|
||||||
const newUrl = new URL("/login", req.nextUrl.origin);
|
const newUrl = new URL("/login", req.nextUrl.origin);
|
||||||
return NextResponse.redirect(newUrl);
|
return NextResponse.redirect(newUrl);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue