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) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-15 09:22:11 +02:00
parent ccb66f99d8
commit e10145d991
No known key found for this signature in database
4 changed files with 676 additions and 0 deletions

View file

@ -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);
});
});
});

View file

@ -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 };
}

View file

@ -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));

View file

@ -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<AuthEnv>();
const CLIENT_INVITE_EXPIRY_HOURS = 72;
/** POST /:tabloId — Create a client invite (admin only) */
const createClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManager.getInstance>
) =>
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<typeof MiddlewareManager.getInstance>) =>
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<AuthEnv>();
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;
};