diff --git a/packages/db/prisma/migrations/20250210191917_add_invite_table/migration.sql b/packages/db/prisma/migrations/20250210191917_add_invite_table/migration.sql new file mode 100644 index 00000000..7dd9f329 --- /dev/null +++ b/packages/db/prisma/migrations/20250210191917_add_invite_table/migration.sql @@ -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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 156adccc..c9429e73 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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 @@ -92,6 +113,9 @@ model Org { connections Connection[] repos Repo[] secrets Secret[] + + /// List of pending invites to this organization + invites Invite[] } enum OrgRole { @@ -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 } diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index bea36b00..b8785d60 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -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, @@ -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"); + } +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/header.tsx b/packages/web/src/app/components/header.tsx similarity index 100% rename from packages/web/src/app/connections/components/header.tsx rename to packages/web/src/app/components/header.tsx diff --git a/packages/web/src/app/components/navigationMenu.tsx b/packages/web/src/app/components/navigationMenu.tsx index 2f7ab7b6..17d5cecc 100644 --- a/packages/web/src/app/components/navigationMenu.tsx +++ b/packages/web/src/app/components/navigationMenu.tsx @@ -70,6 +70,13 @@ export const NavigationMenu = async () => { + + + + Settings + + + diff --git a/packages/web/src/app/connections/page.tsx b/packages/web/src/app/connections/page.tsx index f023c216..005c595b 100644 --- a/packages/web/src/app/connections/page.tsx +++ b/packages/web/src/app/connections/page.tsx @@ -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() { diff --git a/packages/web/src/app/redeem/components/acceptInviteButton.tsx b/packages/web/src/app/redeem/components/acceptInviteButton.tsx new file mode 100644 index 00000000..f521e62e --- /dev/null +++ b/packages/web/src/app/redeem/components/acceptInviteButton.tsx @@ -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 ( + + ) +} + diff --git a/packages/web/src/app/redeem/page.tsx b/packages/web/src/app/redeem/page.tsx new file mode 100644 index 00000000..f6faccda --- /dev/null +++ b/packages/web/src/app/redeem/page.tsx @@ -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 ( +
+ +
+

This invite either expired or was revoked. Contact your organization owner.

+
+
+ ); + } + + 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 ( +
+ +
+

Sorry this invite does not belong to you.

+
+
+ ) + } else { + const orgName = await prisma.org.findUnique({ + where: { id: invite.orgId }, + select: { name: true }, + }); + + if (!orgName) { + return ( +
+ +
+

Organization not found. Please contact the invite sender.

+
+
+ ) + } + + return ( +
+ +
+

You've been invited to org {orgName.name}

+ +
+
+ ); + } + } else { + redirect(`/login?callbackUrl=${encodeURIComponent(`/redeem?invite_id=${invite_id}`)}`); + } +} diff --git a/packages/web/src/app/settings/components/inviteTable.tsx b/packages/web/src/app/settings/components/inviteTable.tsx new file mode 100644 index 00000000..4af28d09 --- /dev/null +++ b/packages/web/src/app/settings/components/inviteTable.tsx @@ -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(initialInvites); + + const inviteRows: InviteColumnInfo[] = useMemo(() => { + return invites.map(invite => { + return { + id: invite.id!, + email: invite.email!, + createdAt: invite.createdAt!, + } + }) + }, [invites]); + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/settings/components/inviteTableColumns.tsx b/packages/web/src/app/settings/components/inviteTableColumns.tsx new file mode 100644 index 00000000..fae160f4 --- /dev/null +++ b/packages/web/src/app/settings/components/inviteTableColumns.tsx @@ -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[] => { + return [ + { + accessorKey: "email", + cell: ({ row }) => { + const invite = row.original; + return
{invite.email}
; + } + }, + { + accessorKey: "createdAt", + cell: ({ row }) => { + const invite = row.original; + return invite.createdAt.toISOString(); + } + }, + { + accessorKey: "copy", + cell: ({ row }) => { + const invite = row.original; + return ( + + ) + } + } + ] +} \ No newline at end of file diff --git a/packages/web/src/app/settings/components/memberInviteForm.tsx b/packages/web/src/app/settings/components/memberInviteForm.tsx new file mode 100644 index 00000000..e4cd7807 --- /dev/null +++ b/packages/web/src/app/settings/components/memberInviteForm.tsx @@ -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>({ + 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 ( +
+
+ + ( + + Email + + + + + + )} + /> + + + +
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/settings/components/memberTable.tsx b/packages/web/src/app/settings/components/memberTable.tsx new file mode 100644 index 00000000..83a51458 --- /dev/null +++ b/packages/web/src/app/settings/components/memberTable.tsx @@ -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(initialMembers); + + const memberRows: MemberColumnInfo[] = useMemo(() => { + return members.map(member => { + return { + name: member.name!, + role: member.role!, + } + }) + }, [members]); + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/settings/components/memberTableColumns.tsx b/packages/web/src/app/settings/components/memberTableColumns.tsx new file mode 100644 index 00000000..5bdcda8f --- /dev/null +++ b/packages/web/src/app/settings/components/memberTableColumns.tsx @@ -0,0 +1,27 @@ +'use client' + +import { Column, ColumnDef } from "@tanstack/react-table" + +export type MemberColumnInfo = { + name: string; + role: string; +} + +export const memberTableColumns = (): ColumnDef[] => { + return [ + { + accessorKey: "name", + cell: ({ row }) => { + const member = row.original; + return
{member.name}
; + } + }, + { + accessorKey: "role", + cell: ({ row }) => { + const member = row.original; + return
{member.role}
; + } + } + ] +} \ No newline at end of file diff --git a/packages/web/src/app/settings/layout.tsx b/packages/web/src/app/settings/layout.tsx new file mode 100644 index 00000000..2877c918 --- /dev/null +++ b/packages/web/src/app/settings/layout.tsx @@ -0,0 +1,17 @@ +import { NavigationMenu } from "../components/navigationMenu"; + +export default function Layout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + + return ( +
+ +
+
{children}
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/settings/page.tsx b/packages/web/src/app/settings/page.tsx new file mode 100644 index 00000000..d912f143 --- /dev/null +++ b/packages/web/src/app/settings/page.tsx @@ -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
Error: Unable to fetch data
; + } + const { user, memberInfo, inviteInfo } = data; + + + return ( +
+
+

Settings

+
+
+ + + +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index 2523957c..cd38cf62 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -1,10 +1,11 @@ import 'next-auth/jwt'; import NextAuth, { User as AuthJsUser, DefaultSession } from "next-auth" import GitHub from "next-auth/providers/github" +import Google from "next-auth/providers/google" import { PrismaAdapter } from "@auth/prisma-adapter" import { prisma } from "@/prisma"; 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 { notAuthenticated, notFound, unexpectedError } from "@/lib/serviceError"; import { getUser } from "./data/user"; @@ -28,6 +29,10 @@ const providers: Provider[] = [ clientId: AUTH_GITHUB_CLIENT_ID, clientSecret: AUTH_GITHUB_CLIENT_SECRET, }), + Google({ + clientId: AUTH_GOOGLE_CLIENT_ID!, + clientSecret: AUTH_GOOGLE_CLIENT_SECRET!, + }) ]; // @see: https://authjs.dev/guides/pages/signin diff --git a/packages/web/src/lib/environment.ts b/packages/web/src/lib/environment.ts index 4725eb0d..e188c271 100644 --- a/packages/web/src/lib/environment.ts +++ b/packages/web/src/lib/environment.ts @@ -9,4 +9,6 @@ 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_GITHUB_CLIENT_ID = getEnv(process.env.AUTH_GITHUB_CLIENT_ID); -export const AUTH_GITHUB_CLIENT_SECRET = getEnv(process.env.AUTH_GITHUB_CLIENT_SECRET); \ No newline at end of file +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); \ No newline at end of file diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index d92b192f..97ba638d 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -23,6 +23,12 @@ const apiMiddleware = (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") { const newUrl = new URL("/login", req.nextUrl.origin); return NextResponse.redirect(newUrl);