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:
Michael Sukkarieh 2025-02-10 14:31:38 -08:00 committed by GitHub
parent 846d73b0e6
commit 90550181af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 575 additions and 4 deletions

View file

@ -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;

View file

@ -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
} }

View file

@ -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");
}
}

View file

@ -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>

View file

@ -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() {

View file

@ -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>
)
}

View 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}`)}`);
}
}

View 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>
)
}

View file

@ -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>
)
}
}
]
}

View file

@ -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>
);
}

View 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>
)
}

View file

@ -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>;
}
}
]
}

View 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>
)
}

View 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>
)
}

View file

@ -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

View file

@ -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);

View file

@ -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);