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