Skip to content

add invite system and google oauth provider #185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
27 changes: 27 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,27 @@ model RepoToConnection {
@@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 {
id Int @id @default(autoincrement())
name String
Expand All @@ -92,6 +113,9 @@ model Org {
connections Connection[]
repos Repo[]
secrets Secret[]

/// List of pending invites to this organization
invites Invite[]
}

enum OrgRole {
Expand Down Expand Up @@ -139,6 +163,9 @@ model User {
orgs UserToOrg[]
activeOrgId Int?

/// List of pending invites that the user has created
invites Invite[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Expand Down
57 changes: 56 additions & 1 deletion packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { encrypt } from "@sourcebot/crypto"
import { getConnection } from "./data/connection";
import { Prisma } from "@sourcebot/db";
import { Prisma, Invite } from "@sourcebot/db";

const ajv = new Ajv({
validateFormats: false,
Expand Down Expand Up @@ -301,3 +301,58 @@ const parseConnectionConfig = (connectionType: string, config: string) => {

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");
}
}
7 changes: 7 additions & 0 deletions packages/web/src/app/components/navigationMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ export const NavigationMenu = async () => {
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/settings" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Settings
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenuBase>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/app/connections/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { auth } from "@/auth";
import { getUser } from "@/data/user";
import { prisma } from "@/prisma";
import { ConnectionList } from "./components/connectionList";
import { Header } from "./components/header";
import { Header } from "../components/header";
import { NewConnectionCard } from "./components/newConnectionCard";

export default async function ConnectionsPage() {
Expand Down
53 changes: 53 additions & 0 deletions packages/web/src/app/redeem/components/acceptInviteButton.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 84 additions & 0 deletions packages/web/src/app/redeem/page.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 40 additions & 0 deletions packages/web/src/app/settings/components/inviteTable.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
49 changes: 49 additions & 0 deletions packages/web/src/app/settings/components/inviteTableColumns.tsx
Original file line number Diff line number Diff line change
@@ -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>
)
}
}
]
}
Loading