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:
parent
ccb66f99d8
commit
e10145d991
4 changed files with 676 additions and 0 deletions
386
apps/api/src/__tests__/routes/clientInvites.test.ts
Normal file
386
apps/api/src/__tests__/routes/clientInvites.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
223
apps/api/src/routers/clientInvites.ts
Normal file
223
apps/api/src/routers/clientInvites.ts
Normal 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;
|
||||
};
|
||||
Loading…
Reference in a new issue