From e10145d991231454c8b95e38aea146e3be7f2903 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 09:22:11 +0200 Subject: [PATCH] feat(api): add client invite endpoints with magic link flow Adds createClientUser helper, POST/GET/DELETE /client-invites routes, and mounts the router at /client-invites in authRouter. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/routes/clientInvites.test.ts | 386 ++++++++++++++++++ apps/api/src/helpers/helpers.ts | 65 +++ apps/api/src/routers/authRouter.ts | 2 + apps/api/src/routers/clientInvites.ts | 223 ++++++++++ 4 files changed, 676 insertions(+) create mode 100644 apps/api/src/__tests__/routes/clientInvites.test.ts create mode 100644 apps/api/src/routers/clientInvites.ts diff --git a/apps/api/src/__tests__/routes/clientInvites.test.ts b/apps/api/src/__tests__/routes/clientInvites.test.ts new file mode 100644 index 0000000..6b7cb25 --- /dev/null +++ b/apps/api/src/__tests__/routes/clientInvites.test.ts @@ -0,0 +1,386 @@ +import { createClient } from "@supabase/supabase-js"; +import { testClient } from "hono/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createConfig } from "../../config.js"; +import { MiddlewareManager } from "../../middlewares/middleware.js"; +import { getMainRouter } from "../../routers/index.js"; +import type { TestUserData } from "../helpers/dbSetup.js"; +import { getTestUser } from "../helpers/dbSetup.js"; + +// Mock nodemailer +const mockSendMail = vi.fn(); +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), + }, + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), +})); + +describe("Client Invites Endpoints", () => { + const config = createConfig(); + MiddlewareManager.initialize(config); + const app = getMainRouter(config); + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + + const ownerUser = getTestUser("owner"); + const tempUser = getTestUser("temp"); + + const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, + }); + + // The owner has admin access to this tablo (created via TEST_TABLOS with owner_key: "owner") + const adminTabloId = "test_tablo_owner_private"; + + beforeEach(() => { + vi.clearAllMocks(); + mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); + }); + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + const postInvite = (user: TestUserData, tabloId: string, email: string) => + client["client-invites"][":tabloId"].$post( + { param: { tabloId }, json: { email } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + const getPending = (user: TestUserData, tabloId: string) => + client["client-invites"][":tabloId"].pending.$get( + { param: { tabloId } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + const deleteInvite = (user: TestUserData, tabloId: string, inviteId: number) => + client["client-invites"][":tabloId"][":inviteId"].$delete( + { param: { tabloId, inviteId: String(inviteId) } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + const acceptInvite = (user: TestUserData, token: string) => + client["client-invites"].accept[":token"].$post( + { param: { token } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + // ─── Helper: insert a client_invite row directly via admin ────────────────── + + const insertClientInvite = async (opts: { + tabloId: string; + invitedEmail: string; + invitedBy: string; + token: string; + isPending?: boolean; + expiresAt?: string; + }) => { + const expiresAt = opts.expiresAt ?? new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString(); + + const { data, error } = await supabaseAdmin + .from("client_invites") + .insert({ + tablo_id: opts.tabloId, + invited_email: opts.invitedEmail, + invited_by: opts.invitedBy, + invite_token: opts.token, + is_pending: opts.isPending ?? true, + expires_at: expiresAt, + }) + .select("id") + .single(); + + if (error) throw new Error(`Failed to insert client_invite: ${error.message}`); + return data.id as number; + }; + + // ─── Cleanup helper ────────────────────────────────────────────────────────── + + const cleanupInvitesByEmail = async (email: string) => { + await supabaseAdmin.from("client_invites").delete().eq("invited_email", email); + // Also clean up any client user that may have been created + const { data: usersData } = await supabaseAdmin.auth.admin.listUsers(); + // biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime + const users = usersData as any; + // biome-ignore lint/suspicious/noExplicitAny: admin user type + const clientUser = users?.users?.find((u: any) => u.email === email); + if (clientUser) { + await supabaseAdmin.from("tablo_access").delete().eq("user_id", clientUser.id); + await supabaseAdmin.auth.admin.deleteUser(clientUser.id); + } + }; + + // ════════════════════════════════════════════════════════════════════════════ + // POST /:tabloId — Create client invite + // ════════════════════════════════════════════════════════════════════════════ + + describe("POST /client-invites/:tabloId", () => { + const testEmail = "test_client_invite_new@example.com"; + + beforeEach(async () => { + await cleanupInvitesByEmail(testEmail); + }); + + it("should create a client invite for a valid email (admin)", async () => { + const res = await postInvite(ownerUser, adminTabloId, testEmail); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + + // Verify row was inserted + const { data: invite } = await supabaseAdmin + .from("client_invites") + .select("id, invited_email, is_pending") + .eq("tablo_id", adminTabloId) + .eq("invited_email", testEmail) + .single(); + + expect(invite).toBeDefined(); + expect(invite?.is_pending).toBe(true); + }); + + it("should reject non-admin users with 403", async () => { + // tempUser is NOT admin of adminTabloId (owner user owns it) + const res = await postInvite(tempUser, adminTabloId, testEmail); + expect(res.status).toBe(403); + }); + + it("should return 400 for an invalid email", async () => { + const res = await postInvite(ownerUser, adminTabloId, "not-an-email"); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("valid email"); + }); + + it("should return 400 for a missing email", async () => { + const res = client["client-invites"][":tabloId"].$post( + { param: { tabloId: adminTabloId }, json: {} }, + { headers: { Authorization: `Bearer ${ownerUser.accessToken}` } } + ); + expect((await res).status).toBe(400); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"][":tabloId"].$post({ + param: { tabloId: adminTabloId }, + json: { email: testEmail }, + }); + expect(res.status).toBe(401); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // POST /accept/:token — Accept a client invite + // ════════════════════════════════════════════════════════════════════════════ + + describe("POST /client-invites/accept/:token", () => { + it("should accept an invite and return tabloId", async () => { + const token = `test_accept_valid_${Date.now()}`; + + // Insert invite for the owner user's email + await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: ownerUser.email, + invitedBy: ownerUser.userId, + token, + }); + + try { + const res = await acceptInvite(ownerUser, token); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.tabloId).toBe(adminTabloId); + + // Verify invite is now not pending + const { data: invite } = await supabaseAdmin + .from("client_invites") + .select("is_pending") + .eq("invite_token", token) + .single(); + expect(invite?.is_pending).toBe(false); + } finally { + await supabaseAdmin.from("client_invites").delete().eq("invite_token", token); + } + }); + + it("should return 410 for an expired invite", async () => { + const token = `test_expired_${Date.now()}`; + const pastDate = new Date(Date.now() - 1000).toISOString(); // already expired + + await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: ownerUser.email, + invitedBy: ownerUser.userId, + token, + expiresAt: pastDate, + }); + + try { + const res = await acceptInvite(ownerUser, token); + expect(res.status).toBe(410); + const data = await res.json(); + expect(data.error).toContain("expired"); + } finally { + await supabaseAdmin.from("client_invites").delete().eq("invite_token", token); + } + }); + + it("should return 403 when email does not match the authenticated user", async () => { + const token = `test_email_mismatch_${Date.now()}`; + + // Invite is for tempUser's email but we authenticate as ownerUser + await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: tempUser.email, + invitedBy: ownerUser.userId, + token, + }); + + try { + const res = await acceptInvite(ownerUser, token); // wrong user + expect(res.status).toBe(403); + } finally { + await supabaseAdmin.from("client_invites").delete().eq("invite_token", token); + } + }); + + it("should return 404 for a non-existent token", async () => { + const res = await acceptInvite(ownerUser, "nonexistent_token_xyz"); + expect(res.status).toBe(404); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"].accept[":token"].$post({ + param: { token: "some_token" }, + }); + expect(res.status).toBe(401); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // GET /:tabloId/pending — List pending client invites + // ════════════════════════════════════════════════════════════════════════════ + + describe("GET /client-invites/:tabloId/pending", () => { + const pendingEmail = "test_client_pending_list@example.com"; + let insertedId: number; + + beforeEach(async () => { + await cleanupInvitesByEmail(pendingEmail); + insertedId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: pendingEmail, + invitedBy: ownerUser.userId, + token: `test_pending_${Date.now()}`, + }); + }); + + it("should return pending invites for an admin", async () => { + const res = await getPending(ownerUser, adminTabloId); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.invites)).toBe(true); + + const found = data.invites.find((inv: { id: number }) => inv.id === insertedId); + expect(found).toBeDefined(); + expect(found.invited_email).toBe(pendingEmail); + expect(found.is_pending).toBe(true); + }); + + it("should return 403 for a non-admin user", async () => { + const res = await getPending(tempUser, adminTabloId); + expect(res.status).toBe(403); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"][":tabloId"].pending.$get({ + param: { tabloId: adminTabloId }, + }); + expect(res.status).toBe(401); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // DELETE /:tabloId/:inviteId — Cancel a client invite + // ════════════════════════════════════════════════════════════════════════════ + + describe("DELETE /client-invites/:tabloId/:inviteId", () => { + const cancelEmail = "test_client_cancel@example.com"; + + beforeEach(async () => { + await cleanupInvitesByEmail(cancelEmail); + }); + + it("should cancel a pending invite and revoke client access", async () => { + // First create a client user and tablo_access entry via the API + const token = `test_cancel_${Date.now()}`; + const inviteId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: cancelEmail, + invitedBy: ownerUser.userId, + token, + }); + + // Create a mock profile to revoke (uses admin client to simulate client user existing) + // We'll skip verifying the user's actual auth account since we just need to test cancellation + const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + + // Verify invite is now not pending + const { data: invite } = await supabaseAdmin + .from("client_invites") + .select("is_pending") + .eq("id", inviteId) + .single(); + expect(invite?.is_pending).toBe(false); + }); + + it("should return 403 for a non-admin user", async () => { + const token = `test_cancel_nonadmin_${Date.now()}`; + const inviteId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: cancelEmail, + invitedBy: ownerUser.userId, + token, + }); + + const res = await deleteInvite(tempUser, adminTabloId, inviteId); + expect(res.status).toBe(403); + }); + + it("should return 404 for a non-existent invite", async () => { + const res = await deleteInvite(ownerUser, adminTabloId, 999999); + expect(res.status).toBe(404); + }); + + it("should return 400 for an already-cancelled invite", async () => { + const token = `test_cancel_already_${Date.now()}`; + const inviteId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: cancelEmail, + invitedBy: ownerUser.userId, + token, + isPending: false, // already cancelled + }); + + const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("pending"); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"][":tabloId"][":inviteId"].$delete({ + param: { tabloId: adminTabloId, inviteId: "1" }, + }); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 25ec729..f559288 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -363,3 +363,68 @@ export const createInvitedUser = async ( return { success: true, userId: newUser.user.id }; }; + +/** + * Creates or finds a client user, marks them as is_client, and grants tablo access. + */ +export async function createClientUser( + supabase: SupabaseClient, + recipientEmail: string, + tabloId: string, + grantedBy: string +): Promise<{ success: boolean; error?: string; userId?: string }> { + // Check if user already exists + const { data: existingUsersData } = await supabase.auth.admin.listUsers(); + // biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime + const existingUsers = existingUsersData as any; + const existingUser = existingUsers?.users?.find( + // biome-ignore lint/suspicious/noExplicitAny: admin user type + (u: any) => u.email?.toLowerCase() === recipientEmail.toLowerCase() + ); + + let userId: string; + + if (existingUser) { + userId = existingUser.id; + // Mark as client if not already + await supabase + .from("profiles") + .update({ is_client: true }) + .eq("id", userId) + .eq("is_client", false); + } else { + // Create new auth user (no password — magic link only) + const { data: authData, error: authError } = await supabase.auth.admin.createUser({ + email: recipientEmail, + email_confirm: true, + user_metadata: { role: "client" }, + }); + if (authError || !authData?.user) { + return { success: false, error: authError?.message ?? "Failed to create user" }; + } + userId = authData.user.id; + await supabase.from("profiles").update({ is_client: true }).eq("id", userId); + } + + // Grant tablo access if not already granted + const { data: existingAccess } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", tabloId) + .eq("user_id", userId) + .single(); + + if (!existingAccess) { + await supabase.from("tablo_access").insert({ + tablo_id: tabloId, + user_id: userId, + granted_by: grantedBy, + is_admin: false, + is_active: true, + }); + } else if (!existingAccess.is_active) { + await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id); + } + + return { success: true, userId }; +} diff --git a/apps/api/src/routers/authRouter.ts b/apps/api/src/routers/authRouter.ts index 41f2c53..4c308b8 100644 --- a/apps/api/src/routers/authRouter.ts +++ b/apps/api/src/routers/authRouter.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { AppConfig } from "../config.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; +import { getClientInvitesRouter } from "./clientInvites.js"; import { getNotesRouter } from "./notes.js"; import { getStripeRouter } from "./stripe.js"; import { getTabloRouter } from "./tablo.js"; @@ -19,6 +20,7 @@ export const getAuthenticatedRouter = (config: AppConfig) => { authRouter.route("/tablos", getTabloRouter(config)); authRouter.route("/tablo-data", getTabloDataRouter()); authRouter.route("/notes", getNotesRouter()); + authRouter.route("/client-invites", getClientInvitesRouter()); // stripe routes authRouter.route("/stripe", getStripeRouter(config)); diff --git a/apps/api/src/routers/clientInvites.ts b/apps/api/src/routers/clientInvites.ts new file mode 100644 index 0000000..c93f7c0 --- /dev/null +++ b/apps/api/src/routers/clientInvites.ts @@ -0,0 +1,223 @@ +import { Hono } from "hono"; +import { createFactory } from "hono/factory"; +import { checkTabloAdmin, createClientUser } from "../helpers/helpers.js"; +import { generateToken } from "../helpers/token.js"; +import { MiddlewareManager } from "../middlewares/middleware.js"; +import type { AuthEnv } from "../types/app.types.js"; + +const factory = createFactory(); + +const CLIENT_INVITE_EXPIRY_HOURS = 72; + +/** POST /:tabloId — Create a client invite (admin only) */ +const createClientInvite = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const body = await c.req.json(); + const rawEmail = String(body.email || "") + .trim() + .toLowerCase(); + + if (!rawEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail)) { + return c.json({ error: "A valid email is required" }, 400); + } + + // Create / find the client user and grant tablo access + const result = await createClientUser(supabase, rawEmail, tabloId, user.id); + if (!result.success || !result.userId) { + return c.json({ error: result.error ?? "Failed to create client user" }, 500); + } + + const token = generateToken(); + const expiresAt = new Date( + Date.now() + CLIENT_INVITE_EXPIRY_HOURS * 60 * 60 * 1000 + ).toISOString(); + + const { error: insertError } = await supabase.from("client_invites").insert({ + tablo_id: tabloId, + invited_email: rawEmail, + invited_by: user.id, + invite_token: token, + is_pending: true, + expires_at: expiresAt, + }); + + if (insertError) { + if (insertError.code === "23505") { + return c.json({ error: "A pending invite already exists for this email and tablo" }, 409); + } + return c.json({ error: insertError.message }, 500); + } + + // Generate a Supabase magic link that redirects to the client portal callback + const clientsUrl = process.env.CLIENTS_URL || "https://clients.xtablo.com"; + const redirectTo = `${clientsUrl}/auth/callback?token=${encodeURIComponent(token)}`; + + const { error: magicLinkError } = await supabase.auth.admin.generateLink({ + type: "magiclink", + email: rawEmail, + options: { redirectTo }, + }); + + if (magicLinkError) { + console.error("Failed to generate magic link:", magicLinkError); + // Non-fatal: invite record is already created + } + + return c.json({ success: true }); + }); + +/** POST /accept/:token — Accept a client invite */ +const acceptClientInvite = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const token = c.req.param("token"); + + const { data: invite, error: inviteError } = await supabase + .from("client_invites") + .select("id, tablo_id, invited_email, invited_by, is_pending, expires_at") + .eq("invite_token", token) + .maybeSingle(); + + if (inviteError) { + return c.json({ error: inviteError.message }, 500); + } + + if (!invite || !invite.is_pending) { + return c.json({ error: "Invite not found or already used" }, 404); + } + + // Check expiration + if (invite.expires_at && new Date(invite.expires_at) < new Date()) { + return c.json({ error: "This invite has expired" }, 410); + } + + // Email must match the authenticated user + if (invite.invited_email?.toLowerCase() !== user.email?.toLowerCase()) { + return c.json({ error: "This invite was not issued to your account" }, 403); + } + + // Mark invite as accepted + await supabase.from("client_invites").update({ is_pending: false }).eq("id", invite.id); + + // Ensure tablo access is active + const { data: existingAccess } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", invite.tablo_id) + .eq("user_id", user.id) + .maybeSingle(); + + if (!existingAccess) { + await supabase.from("tablo_access").insert({ + tablo_id: invite.tablo_id, + user_id: user.id, + granted_by: invite.invited_by, + is_admin: false, + is_active: true, + }); + } else if (!existingAccess.is_active) { + await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id); + } + + return c.json({ success: true, tabloId: invite.tablo_id }); + }); + +/** GET /:tabloId/pending — List pending client invites (admin only) */ +const getPendingClientInvites = ( + middlewareManager: ReturnType +) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data: invites, error } = await supabase + .from("client_invites") + .select("id, invited_email, expires_at, is_pending, created_at") + .eq("tablo_id", tabloId) + .eq("is_pending", true) + .order("created_at", { ascending: false }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ invites: invites ?? [] }); + }); + +/** DELETE /:tabloId/:inviteId — Cancel a client invite (admin only) */ +const cancelClientInvite = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + const inviteId = Number(c.req.param("inviteId")); + + if (!Number.isInteger(inviteId) || inviteId <= 0) { + return c.json({ error: "Invalid invite id" }, 400); + } + + const { data: invite, error: inviteError } = await supabase + .from("client_invites") + .select("id, invited_email, is_pending") + .eq("id", inviteId) + .eq("tablo_id", tabloId) + .maybeSingle(); + + if (inviteError) { + return c.json({ error: inviteError.message }, 500); + } + + if (!invite) { + return c.json({ error: "Invite not found" }, 404); + } + + if (!invite.is_pending) { + return c.json({ error: "Invite is no longer pending" }, 400); + } + + // Mark invite as cancelled + const { error: cancelError } = await supabase + .from("client_invites") + .update({ is_pending: false }) + .eq("id", inviteId) + .eq("tablo_id", tabloId); + + if (cancelError) { + return c.json({ error: cancelError.message }, 500); + } + + // Revoke tablo access for the client user + if (invite.invited_email) { + const { data: clientProfile } = await supabase + .from("profiles") + .select("id") + .eq("email", invite.invited_email) + .maybeSingle(); + + if (clientProfile?.id) { + await supabase + .from("tablo_access") + .update({ is_active: false }) + .eq("tablo_id", tabloId) + .eq("user_id", clientProfile.id); + } + } + + return c.json({ success: true }); + }); + +export const getClientInvitesRouter = () => { + const router = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + router.post("/:tabloId", ...createClientInvite(middlewareManager)); + router.post("/accept/:token", ...acceptClientInvite(middlewareManager)); + router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager)); + router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager)); + + return router; +};