Skip to content

Commit 9055018

Browse files
authored
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
1 parent 846d73b commit 9055018

File tree

18 files changed

+575
-4
lines changed

18 files changed

+575
-4
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- CreateTable
2+
CREATE TABLE "Invite" (
3+
"id" TEXT NOT NULL,
4+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
5+
"recipientEmail" TEXT NOT NULL,
6+
"hostUserId" TEXT NOT NULL,
7+
"orgId" INTEGER NOT NULL,
8+
9+
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
10+
);
11+
12+
-- CreateIndex
13+
CREATE UNIQUE INDEX "Invite_recipientEmail_orgId_key" ON "Invite"("recipientEmail", "orgId");
14+
15+
-- AddForeignKey
16+
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_hostUserId_fkey" FOREIGN KEY ("hostUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
17+
18+
-- AddForeignKey
19+
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,27 @@ model RepoToConnection {
8383
@@id([connectionId, repoId])
8484
}
8585

86+
model Invite {
87+
/// The globally unique invite id
88+
id String @id @default(cuid())
89+
90+
/// Time of invite creation
91+
createdAt DateTime @default(now())
92+
93+
/// The email of the recipient of the invite
94+
recipientEmail String
95+
96+
/// The user that created the invite
97+
host User @relation(fields: [hostUserId], references: [id], onDelete: Cascade)
98+
hostUserId String
99+
100+
/// The organization the invite is for
101+
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
102+
orgId Int
103+
104+
@@unique([recipientEmail, orgId])
105+
}
106+
86107
model Org {
87108
id Int @id @default(autoincrement())
88109
name String
@@ -92,6 +113,9 @@ model Org {
92113
connections Connection[]
93114
repos Repo[]
94115
secrets Secret[]
116+
117+
/// List of pending invites to this organization
118+
invites Invite[]
95119
}
96120

97121
enum OrgRole {
@@ -139,6 +163,9 @@ model User {
139163
orgs UserToOrg[]
140164
activeOrgId Int?
141165
166+
/// List of pending invites that the user has created
167+
invites Invite[]
168+
142169
createdAt DateTime @default(now())
143170
updatedAt DateTime @updatedAt
144171
}

packages/web/src/actions.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
1212
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
1313
import { encrypt } from "@sourcebot/crypto"
1414
import { getConnection } from "./data/connection";
15-
import { Prisma } from "@sourcebot/db";
15+
import { Prisma, Invite } from "@sourcebot/db";
1616

1717
const ajv = new Ajv({
1818
validateFormats: false,
@@ -301,3 +301,58 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
301301

302302
return parsedConfig;
303303
}
304+
305+
export const createInvite = async (email: string, userId: string, orgId: number): Promise<{ success: boolean } | ServiceError> => {
306+
console.log("Creating invite for", email, userId, orgId);
307+
308+
try {
309+
await prisma.invite.create({
310+
data: {
311+
recipientEmail: email,
312+
hostUserId: userId,
313+
orgId,
314+
}
315+
});
316+
} catch (error) {
317+
console.error("Failed to create invite:", error);
318+
return unexpectedError("Failed to create invite");
319+
}
320+
321+
return {
322+
success: true,
323+
}
324+
}
325+
326+
export const redeemInvite = async (invite: Invite, userId: string): Promise<{ orgId: number } | ServiceError> => {
327+
try {
328+
await prisma.userToOrg.create({
329+
data: {
330+
userId,
331+
orgId: invite.orgId,
332+
role: "MEMBER",
333+
}
334+
});
335+
336+
await prisma.user.update({
337+
where: {
338+
id: userId,
339+
},
340+
data: {
341+
activeOrgId: invite.orgId,
342+
}
343+
});
344+
345+
await prisma.invite.delete({
346+
where: {
347+
id: invite.id,
348+
}
349+
});
350+
351+
return {
352+
orgId: invite.orgId,
353+
}
354+
} catch (error) {
355+
console.error("Failed to redeem invite:", error);
356+
return unexpectedError("Failed to redeem invite");
357+
}
358+
}

packages/web/src/app/components/navigationMenu.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ export const NavigationMenu = async () => {
7070
</NavigationMenuLink>
7171
</Link>
7272
</NavigationMenuItem>
73+
<NavigationMenuItem>
74+
<Link href="/settings" legacyBehavior passHref>
75+
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
76+
Settings
77+
</NavigationMenuLink>
78+
</Link>
79+
</NavigationMenuItem>
7380
</NavigationMenuList>
7481
</NavigationMenuBase>
7582
</div>

packages/web/src/app/connections/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { auth } from "@/auth";
22
import { getUser } from "@/data/user";
33
import { prisma } from "@/prisma";
44
import { ConnectionList } from "./components/connectionList";
5-
import { Header } from "./components/header";
5+
import { Header } from "../components/header";
66
import { NewConnectionCard } from "./components/newConnectionCard";
77

88
export default async function ConnectionsPage() {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"use client"
2+
3+
import { useState } from "react"
4+
import { useRouter } from "next/navigation"
5+
import { redeemInvite } from "../../../actions";
6+
import { isServiceError } from "@/lib/utils"
7+
import { useToast } from "@/components/hooks/use-toast"
8+
import { Button } from "@/components/ui/button"
9+
import { Invite } from "@sourcebot/db"
10+
11+
interface AcceptInviteButtonProps {
12+
invite: Invite
13+
userId: string
14+
}
15+
16+
export function AcceptInviteButton({ invite, userId }: AcceptInviteButtonProps) {
17+
const [isLoading, setIsLoading] = useState(false)
18+
const router = useRouter()
19+
const { toast } = useToast()
20+
21+
const handleAcceptInvite = async () => {
22+
setIsLoading(true)
23+
try {
24+
const res = await redeemInvite(invite, userId)
25+
if (isServiceError(res)) {
26+
console.log("Failed to redeem invite: ", res)
27+
toast({
28+
title: "Error",
29+
description: "Failed to redeem invite. Please try again.",
30+
variant: "destructive",
31+
})
32+
} else {
33+
router.push("/")
34+
}
35+
} catch (error) {
36+
console.error("Error redeeming invite:", error)
37+
toast({
38+
title: "Error",
39+
description: "An unexpected error occurred. Please try again.",
40+
variant: "destructive",
41+
})
42+
} finally {
43+
setIsLoading(false)
44+
}
45+
}
46+
47+
return (
48+
<Button onClick={handleAcceptInvite} disabled={isLoading}>
49+
{isLoading ? "Accepting..." : "Accept Invite"}
50+
</Button>
51+
)
52+
}
53+

packages/web/src/app/redeem/page.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { prisma } from "@/prisma";
2+
import { notFound, redirect } from 'next/navigation';
3+
import { NavigationMenu } from "../components/navigationMenu";
4+
import { auth } from "@/auth";
5+
import { getUser } from "@/data/user";
6+
import { AcceptInviteButton } from "./components/acceptInviteButton"
7+
8+
interface RedeemPageProps {
9+
searchParams?: {
10+
invite_id?: string;
11+
};
12+
}
13+
14+
export default async function RedeemPage({ searchParams }: RedeemPageProps) {
15+
const invite_id = searchParams?.invite_id;
16+
17+
if (!invite_id) {
18+
notFound();
19+
}
20+
21+
const invite = await prisma.invite.findUnique({
22+
where: { id: invite_id },
23+
});
24+
25+
if (!invite) {
26+
return (
27+
<div>
28+
<NavigationMenu />
29+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
30+
<h1>This invite either expired or was revoked. Contact your organization owner.</h1>
31+
</div>
32+
</div>
33+
);
34+
}
35+
36+
const session = await auth();
37+
let user = undefined;
38+
if (session) {
39+
user = await getUser(session.user.id);
40+
}
41+
42+
43+
// Auth case
44+
if (user) {
45+
if (user.email !== invite.recipientEmail) {
46+
return (
47+
<div>
48+
<NavigationMenu />
49+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
50+
<h1>Sorry this invite does not belong to you.</h1>
51+
</div>
52+
</div>
53+
)
54+
} else {
55+
const orgName = await prisma.org.findUnique({
56+
where: { id: invite.orgId },
57+
select: { name: true },
58+
});
59+
60+
if (!orgName) {
61+
return (
62+
<div>
63+
<NavigationMenu />
64+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
65+
<h1>Organization not found. Please contact the invite sender.</h1>
66+
</div>
67+
</div>
68+
)
69+
}
70+
71+
return (
72+
<div>
73+
<NavigationMenu />
74+
<div className="flex justify-between items-center h-screen px-6">
75+
<h1 className="text-2xl font-bold">You've been invited to org {orgName.name}</h1>
76+
<AcceptInviteButton invite={invite} userId={user.id} />
77+
</div>
78+
</div>
79+
);
80+
}
81+
} else {
82+
redirect(`/login?callbackUrl=${encodeURIComponent(`/redeem?invite_id=${invite_id}`)}`);
83+
}
84+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client';
2+
import { useEffect, useMemo, useState } from "react";
3+
import { User } from "@sourcebot/db";
4+
import { DataTable } from "@/components/ui/data-table";
5+
import { InviteColumnInfo, inviteTableColumns } from "./inviteTableColumns"
6+
7+
export interface InviteInfo {
8+
id: string;
9+
email: string;
10+
createdAt: Date;
11+
}
12+
13+
interface InviteTableProps {
14+
initialInvites: InviteInfo[];
15+
}
16+
17+
export const InviteTable = ({ initialInvites }: InviteTableProps) => {
18+
const [invites, setInvites] = useState<InviteInfo[]>(initialInvites);
19+
20+
const inviteRows: InviteColumnInfo[] = useMemo(() => {
21+
return invites.map(invite => {
22+
return {
23+
id: invite.id!,
24+
email: invite.email!,
25+
createdAt: invite.createdAt!,
26+
}
27+
})
28+
}, [invites]);
29+
30+
return (
31+
<div>
32+
<DataTable
33+
columns={inviteTableColumns()}
34+
data={inviteRows}
35+
searchKey="email"
36+
searchPlaceholder="Search invites..."
37+
/>
38+
</div>
39+
)
40+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client'
2+
3+
import { Button } from "@/components/ui/button";
4+
import { ColumnDef } from "@tanstack/react-table"
5+
import { resolveServerPath } from "../../api/(client)/client";
6+
import { createPathWithQueryParams } from "@/lib/utils";
7+
8+
export type InviteColumnInfo = {
9+
id: string;
10+
email: string;
11+
createdAt: Date;
12+
}
13+
14+
export const inviteTableColumns = (): ColumnDef<InviteColumnInfo>[] => {
15+
return [
16+
{
17+
accessorKey: "email",
18+
cell: ({ row }) => {
19+
const invite = row.original;
20+
return <div>{invite.email}</div>;
21+
}
22+
},
23+
{
24+
accessorKey: "createdAt",
25+
cell: ({ row }) => {
26+
const invite = row.original;
27+
return invite.createdAt.toISOString();
28+
}
29+
},
30+
{
31+
accessorKey: "copy",
32+
cell: ({ row }) => {
33+
const invite = row.original;
34+
return (
35+
<Button
36+
variant="link"
37+
onClick={() => {
38+
const basePath = `${window.location.origin}${resolveServerPath('/')}`;
39+
const url = createPathWithQueryParams(`${basePath}redeem?invite_id=${invite.id}`);
40+
navigator.clipboard.writeText(url);
41+
}}
42+
>
43+
Copy
44+
</Button>
45+
)
46+
}
47+
}
48+
]
49+
}

0 commit comments

Comments
 (0)