From 504752ce0dba7ea30ae130f04acd99373bad85f3 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Wed, 18 Feb 2026 12:08:29 -0500 Subject: [PATCH] Add user invitation system with token-based signup Server actions for sending and revoking invitations from settings. Invitation emails sent via Resend with unique token links. Accept invitation page at /invite/[token] validates token, creates user account via Supabase admin API, marks invitation accepted. Handles invalid/used tokens and already-registered emails. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 75 +++++++++- package.json | 3 +- src/__tests__/invitations.test.ts | 226 +++++++++++++++++++++++++++++ src/app/invite/[token]/actions.ts | 44 ++++++ src/app/invite/[token]/page.tsx | 108 ++++++++++++++ src/app/settings/invite-actions.ts | 68 +++++++++ 6 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/invitations.test.ts create mode 100644 src/app/invite/[token]/actions.ts create mode 100644 src/app/invite/[token]/page.tsx create mode 100644 src/app/settings/invite-actions.ts diff --git a/package-lock.json b/package-lock.json index 8ca019e..b2a8348 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@supabase/supabase-js": "^2.96.0", "next": "^15.2.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "resend": "^6.9.2" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1827,6 +1828,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4430,6 +4437,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6387,6 +6400,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -6561,6 +6580,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resend": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", + "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6992,6 +7032,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -7218,6 +7268,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -7552,6 +7612,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 5bc333b..7bf2add 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "@supabase/supabase-js": "^2.96.0", "next": "^15.2.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "resend": "^6.9.2" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/__tests__/invitations.test.ts b/src/__tests__/invitations.test.ts new file mode 100644 index 0000000..db7bb4e --- /dev/null +++ b/src/__tests__/invitations.test.ts @@ -0,0 +1,226 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" + +// Mock next/cache +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})) + +// Mock next/navigation +const mockRedirect = vi.fn() +vi.mock("next/navigation", () => ({ + redirect: (url: string) => { + mockRedirect(url) + throw new Error(`NEXT_REDIRECT:${url}`) + }, +})) + +// Mock crypto +vi.mock("crypto", () => ({ + randomBytes: () => ({ + toString: () => "mock-token-abc123", + }), +})) + +// Mock Resend +const mockSend = vi.fn() +vi.mock("resend", () => ({ + Resend: class { + emails = { send: mockSend } + }, +})) + +// Mock Supabase server client +const mockFrom = vi.fn() +const mockGetUser = vi.fn() + +vi.mock("@/lib/supabase/server", () => ({ + createClient: () => + Promise.resolve({ + from: mockFrom, + auth: { getUser: mockGetUser }, + }), +})) + +// Mock Supabase admin client +const mockAdminFrom = vi.fn() +const mockAdminCreateUser = vi.fn() + +vi.mock("@/lib/supabase/admin", () => ({ + createAdminClient: () => ({ + from: mockAdminFrom, + auth: { admin: { createUser: mockAdminCreateUser } }, + }), +})) + +import { sendInvitation, revokeInvitation } from "@/app/settings/invite-actions" +import { acceptInvitation } from "@/app/invite/[token]/actions" + +describe("sendInvitation action", () => { + beforeEach(() => { + vi.clearAllMocks() + mockSend.mockResolvedValue({ id: "email-1" }) + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + const formData = new FormData() + formData.set("email", "new@example.com") + + await expect(sendInvitation(formData)).rejects.toThrow("NEXT_REDIRECT:/login") + }) + + test("creates invitation and sends email", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockInsert = vi.fn().mockReturnValue({ error: null }) + mockFrom.mockReturnValue({ insert: mockInsert }) + + const formData = new FormData() + formData.set("email", "new@example.com") + + await sendInvitation(formData) + + expect(mockFrom).toHaveBeenCalledWith("invitations") + expect(mockInsert).toHaveBeenCalledWith({ + email: "new@example.com", + invited_by: "user-1", + token: "mock-token-abc123", + }) + expect(mockSend).toHaveBeenCalledTimes(1) + const emailCall = mockSend.mock.calls[0][0] + expect(emailCall.to).toEqual(["new@example.com"]) + expect(emailCall.html).toContain("mock-token-abc123") + }) + + test("does nothing if email is empty", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const formData = new FormData() + await sendInvitation(formData) + expect(mockFrom).not.toHaveBeenCalled() + }) +}) + +describe("revokeInvitation action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + await expect(revokeInvitation("inv-1")).rejects.toThrow("NEXT_REDIRECT:/login") + }) + + test("deletes invitation for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockEq = vi.fn().mockReturnValue({ error: null }) + const mockDelete = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ delete: mockDelete }) + + await revokeInvitation("inv-1") + expect(mockFrom).toHaveBeenCalledWith("invitations") + expect(mockEq).toHaveBeenCalledWith("id", "inv-1") + }) +}) + +describe("acceptInvitation action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns error for short password", async () => { + const formData = new FormData() + formData.set("password", "short") + + const result = await acceptInvitation("some-token", formData) + expect(result).toEqual({ error: "Password must be at least 8 characters" }) + }) + + test("returns error for invalid or used token", async () => { + const mockSingle = vi.fn().mockReturnValue({ data: null }) + const mockIs = vi.fn().mockReturnValue({ single: mockSingle }) + const mockEq = vi.fn().mockReturnValue({ is: mockIs }) + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }) + mockAdminFrom.mockReturnValue({ select: mockSelect }) + + const formData = new FormData() + formData.set("password", "password123") + + const result = await acceptInvitation("invalid-token", formData) + expect(result).toEqual({ error: "This invitation is invalid or has already been used" }) + }) + + test("creates user and marks invitation as accepted", async () => { + const invitation = { + id: "inv-1", + email: "new@example.com", + token: "valid-token", + } + + const mockSingle = vi.fn().mockReturnValue({ data: invitation }) + const mockIs = vi.fn().mockReturnValue({ single: mockSingle }) + const mockEq = vi.fn().mockReturnValue({ is: mockIs }) + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }) + + const mockUpdateEq = vi.fn().mockReturnValue({ error: null }) + const mockUpdate = vi.fn().mockReturnValue({ eq: mockUpdateEq }) + + mockAdminFrom.mockImplementation((table: string) => { + if (table === "invitations") { + return { select: mockSelect, update: mockUpdate } + } + return {} + }) + + mockAdminCreateUser.mockResolvedValue({ error: null }) + + const formData = new FormData() + formData.set("password", "password123") + + await expect(acceptInvitation("valid-token", formData)).rejects.toThrow( + "NEXT_REDIRECT:/login" + ) + + expect(mockAdminCreateUser).toHaveBeenCalledWith({ + email: "new@example.com", + password: "password123", + email_confirm: true, + }) + expect(mockUpdate).toHaveBeenCalledWith( + expect.objectContaining({ accepted_at: expect.any(String) }) + ) + }) + + test("returns error when email already registered", async () => { + const invitation = { + id: "inv-1", + email: "existing@example.com", + token: "valid-token", + } + + const mockSingle = vi.fn().mockReturnValue({ data: invitation }) + const mockIs = vi.fn().mockReturnValue({ single: mockSingle }) + const mockEq = vi.fn().mockReturnValue({ is: mockIs }) + const mockSelect = vi.fn().mockReturnValue({ eq: mockEq }) + mockAdminFrom.mockReturnValue({ select: mockSelect }) + + mockAdminCreateUser.mockResolvedValue({ + error: { message: "A user with this email address has already been registered" }, + }) + + const formData = new FormData() + formData.set("password", "password123") + + const result = await acceptInvitation("valid-token", formData) + expect(result).toEqual({ error: "This email is already registered" }) + }) +}) diff --git a/src/app/invite/[token]/actions.ts b/src/app/invite/[token]/actions.ts new file mode 100644 index 0000000..61b59f0 --- /dev/null +++ b/src/app/invite/[token]/actions.ts @@ -0,0 +1,44 @@ +"use server" + +import { redirect } from "next/navigation" +import { createAdminClient } from "@/lib/supabase/admin" + +export async function acceptInvitation(token: string, formData: FormData) { + const password = (formData.get("password") as string)?.trim() + if (!password || password.length < 8) { + return { error: "Password must be at least 8 characters" } + } + + const supabase = createAdminClient() + + const { data: invitation } = await supabase + .from("invitations") + .select("*") + .eq("token", token) + .is("accepted_at", null) + .single() + + if (!invitation) { + return { error: "This invitation is invalid or has already been used" } + } + + const { error: createError } = await supabase.auth.admin.createUser({ + email: invitation.email, + password, + email_confirm: true, + }) + + if (createError) { + if (createError.message.includes("already been registered")) { + return { error: "This email is already registered" } + } + return { error: "Failed to create account" } + } + + await supabase + .from("invitations") + .update({ accepted_at: new Date().toISOString() }) + .eq("id", invitation.id) + + redirect("/login") +} diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..777f9cf --- /dev/null +++ b/src/app/invite/[token]/page.tsx @@ -0,0 +1,108 @@ +"use client" + +import { useState, useActionState } from "react" +import { acceptInvitation } from "./actions" +import Link from "next/link" + +export default function AcceptInvitePage({ + params, +}: { + params: Promise<{ token: string }> +}) { + const [token, setToken] = useState(null) + + // Unwrap params + if (!token) { + params.then((p) => setToken(p.token)) + return null + } + + return +} + +function AcceptInviteForm({ token }: { token: string }) { + const [state, formAction, isPending] = useActionState( + async (_prev: { error: string } | null, formData: FormData) => { + const result = await acceptInvitation(token, formData) + return result ?? null + }, + null + ) + + return ( +
+
+

+ Accept Invitation +

+

+ Set a password to create your account. +

+ + {state?.error && ( +
+ {state.error} +
+ )} + +
+ + +
+ +
+ + Already have an account? Log in + +
+
+
+ ) +} diff --git a/src/app/settings/invite-actions.ts b/src/app/settings/invite-actions.ts new file mode 100644 index 0000000..74ac4d0 --- /dev/null +++ b/src/app/settings/invite-actions.ts @@ -0,0 +1,68 @@ +"use server" + +import { randomBytes } from "crypto" +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" +import { createClient } from "@/lib/supabase/server" +import { Resend } from "resend" + +let _resend: Resend | null = null +function getResend() { + if (!_resend) _resend = new Resend(process.env.RESEND_API_KEY) + return _resend +} + +export async function sendInvitation(formData: FormData) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + const email = (formData.get("email") as string)?.trim() + if (!email) return + + const token = randomBytes(32).toString("hex") + + const { error } = await supabase.from("invitations").insert({ + email, + invited_by: user.id, + token, + }) + + if (error) return + + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://status.rootsofprogress.org" + const inviteLink = `${appUrl}/invite/${token}` + const fromAddress = process.env.RESEND_FROM_EMAIL || "alerts@status.rootsofprogress.org" + + try { + await getResend().emails.send({ + from: fromAddress, + to: [email], + subject: "You've been invited to Site Status", + html: ` +

You've been invited

+

You've been invited to join Site Status as an administrator.

+

Click here to set your password and create your account

+ `.trim(), + }) + } catch (err) { + console.error("Failed to send invitation email:", err) + } + + revalidatePath("/settings") +} + +export async function revokeInvitation(invitationId: string) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + await supabase.from("invitations").delete().eq("id", invitationId) + revalidatePath("/settings") +}