From 2cf5eb87890f5728a4b64cb99fa02eaec739fba8 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 1 May 2026 10:11:08 +0200 Subject: [PATCH] feat: migrate client portal to magic link auth --- .../src/__tests__/routes/clientAuth.test.ts | 195 ++++++ .../__tests__/routes/clientInvites.test.ts | 164 ++--- apps/api/src/__tests__/routes/tablo.test.ts | 28 +- .../src/__tests__/routes/tablo_data.test.ts | 19 +- apps/api/src/__tests__/routes/user.test.ts | 43 +- apps/api/src/config.ts | 2 + apps/api/src/helpers/clientAccounts.ts | 36 ++ apps/api/src/helpers/clientMagicLinks.ts | 130 ++++ apps/api/src/routers/authRouter.ts | 9 +- apps/api/src/routers/clientAuth.ts | 246 ++++++++ apps/api/src/routers/clientInvites.ts | 231 +++---- apps/api/src/routers/clientPortal.ts | 583 ++++++++++++++++++ apps/api/src/routers/index.ts | 18 + apps/api/src/routers/user.ts | 57 -- .../clients/src/components/ClientAuthGate.tsx | 40 +- .../src/components/ClientLayout.test.tsx | 44 +- apps/clients/src/components/ClientLayout.tsx | 19 +- apps/clients/src/hooks/useClientPortal.ts | 323 ++++++++++ apps/clients/src/hooks/useClientSession.ts | 67 ++ apps/clients/src/lib/api.ts | 8 + apps/clients/src/main.tsx | 16 +- .../clients/src/pages/ClientTabloListPage.tsx | 17 +- .../src/pages/ClientTabloPage.test.tsx | 241 ++++---- apps/clients/src/pages/ClientTabloPage.tsx | 453 ++------------ apps/clients/src/pages/LoginPage.test.tsx | 41 +- apps/clients/src/pages/LoginPage.tsx | 107 ++-- apps/clients/src/routes.tsx | 6 - packages/shared-types/src/database.types.ts | 24 + .../src/single-tablo/SingleTabloView.tsx | 4 +- supabase/.gitignore | 8 + supabase/config.toml | 384 ++++++++++++ .../20260430120000_drop_is_temporary.sql | 87 ++- ...260501100000_create_client_auth_tables.sql | 4 + 33 files changed, 2697 insertions(+), 957 deletions(-) create mode 100644 apps/api/src/__tests__/routes/clientAuth.test.ts create mode 100644 apps/api/src/helpers/clientMagicLinks.ts create mode 100644 apps/api/src/routers/clientAuth.ts create mode 100644 apps/api/src/routers/clientPortal.ts create mode 100644 apps/clients/src/hooks/useClientPortal.ts create mode 100644 apps/clients/src/hooks/useClientSession.ts create mode 100644 apps/clients/src/lib/api.ts create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml diff --git a/apps/api/src/__tests__/routes/clientAuth.test.ts b/apps/api/src/__tests__/routes/clientAuth.test.ts new file mode 100644 index 0000000..20aa032 --- /dev/null +++ b/apps/api/src/__tests__/routes/clientAuth.test.ts @@ -0,0 +1,195 @@ +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 { ensureActiveClientAccess, upsertClientByEmail } from "../../helpers/clientAccounts.js"; +import { createClientMagicLink } from "../../helpers/clientMagicLinks.js"; +import { signClientSession } from "../../helpers/clientSessions.js"; +import { MiddlewareManager } from "../../middlewares/middleware.js"; +import { getMainRouter } from "../../routers/index.js"; +import { getTestUser } from "../helpers/dbSetup.js"; + +const mockSendMail = vi.fn(); +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), + }, + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), +})); + +const config = createConfig(); +const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, +}); +const { error: clientSchemaError } = await supabaseAdmin.from("clients").select("id").limit(1); +const hasClientAuthSchema = + !clientSchemaError || (clientSchemaError as { code?: string }).code !== "PGRST205"; + +describe.skipIf(!hasClientAuthSchema)("Client Auth Endpoints", () => { + 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 adminTabloId = "test_tablo_owner_private"; + + beforeEach(() => { + vi.clearAllMocks(); + mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); + }); + + const cleanupClientAuthByEmail = async (email: string) => { + const { data: clientRow } = await supabaseAdmin + .from("clients") + .select("id") + .eq("normalized_email", email.toLowerCase()) + .maybeSingle(); + + if (!clientRow?.id) { + return; + } + + await supabaseAdmin.from("client_magic_links").delete().eq("client_id", clientRow.id); + await supabaseAdmin.from("client_access").delete().eq("client_id", clientRow.id); + await supabaseAdmin.from("clients").delete().eq("id", clientRow.id); + }; + + const createClientWithAccess = async (email: string, tabloId = adminTabloId) => { + const clientResult = await upsertClientByEmail(supabaseAdmin, email); + if (!clientResult.client) { + throw new Error(clientResult.error ?? "Failed to create client"); + } + + const accessResult = await ensureActiveClientAccess(supabaseAdmin, { + clientId: clientResult.client.id, + grantedBy: ownerUser.userId, + tabloId, + }); + + if (!accessResult.success) { + throw new Error(accessResult.error ?? "Failed to grant access"); + } + + return clientResult.client; + }; + + it("returns a neutral success response for request-link even when the email is unknown", async () => { + const res = await client["client-auth"]["request-link"].$post({ + json: { email: "unknown-client@example.com" }, + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.message).toContain("If this email can access the client portal"); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + + it("creates and emails a login magic link when the client has active access", async () => { + const email = "active-client@example.com"; + await cleanupClientAuthByEmail(email); + const clientRow = await createClientWithAccess(email); + + const res = await client["client-auth"]["request-link"].$post({ + json: { email }, + }); + + expect(res.status).toBe(200); + expect(mockSendMail).toHaveBeenCalledTimes(1); + + const { data: links } = await supabaseAdmin + .from("client_magic_links") + .select("client_id, purpose, consumed_at") + .eq("client_id", clientRow.id) + .eq("purpose", "login"); + + expect(links).toHaveLength(1); + expect(links?.[0]?.consumed_at).toBeNull(); + }); + + it("rejects an expired or consumed exchange token", async () => { + const email = "expired-client@example.com"; + await cleanupClientAuthByEmail(email); + const clientRow = await createClientWithAccess(email); + + const magicLinkResult = await createClientMagicLink(supabaseAdmin, { + clientId: clientRow.id, + email, + expiresInMinutes: -1, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + purpose: "login", + }); + + const res = await app.request( + `http://localhost/client-auth/exchange?token=${encodeURIComponent( + magicLinkResult.token ?? "" + )}` + ); + + expect(res.status).toBe(410); + }); + + it("sets the client session cookie when a valid token is exchanged", async () => { + const email = "exchange-client@example.com"; + await cleanupClientAuthByEmail(email); + const clientRow = await createClientWithAccess(email); + + const magicLinkResult = await createClientMagicLink(supabaseAdmin, { + clientId: clientRow.id, + email, + expiresInMinutes: 30, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + purpose: "invite", + redirectTo: `/tablo/${adminTabloId}`, + tabloId: adminTabloId, + }); + + const res = await app.request( + `http://localhost/client-auth/exchange?token=${encodeURIComponent( + magicLinkResult.token ?? "" + )}` + ); + + expect(res.status).toBe(302); + expect(res.headers.get("set-cookie")).toContain(config.CLIENT_AUTH_COOKIE_NAME); + expect(res.headers.get("location")).toBe(`${config.CLIENTS_URL}/tablo/${adminTabloId}`); + }); + + it("clears the cookie on logout", async () => { + const res = await client["client-auth"].logout.$post(); + + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toContain(`${config.CLIENT_AUTH_COOKIE_NAME}=;`); + }); + + it("returns the current client from /me when the cookie is valid", async () => { + const email = "me-client@example.com"; + await cleanupClientAuthByEmail(email); + const clientRow = await createClientWithAccess(email); + const token = signClientSession( + { + clientId: clientRow.id, + email, + }, + { + expiresInDays: config.CLIENT_SESSION_TTL_DAYS, + secret: config.CLIENT_AUTH_JWT_SECRET, + } + ); + + const res = await app.request("http://localhost/client-auth/me", { + headers: { + Cookie: `${config.CLIENT_AUTH_COOKIE_NAME}=${token}`, + }, + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.client.id).toBe(clientRow.id); + expect(data.client.email).toBe(email); + }); +}); diff --git a/apps/api/src/__tests__/routes/clientInvites.test.ts b/apps/api/src/__tests__/routes/clientInvites.test.ts index fd7d684..2513722 100644 --- a/apps/api/src/__tests__/routes/clientInvites.test.ts +++ b/apps/api/src/__tests__/routes/clientInvites.test.ts @@ -2,6 +2,8 @@ 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 { ensureActiveClientAccess, upsertClientByEmail } from "../../helpers/clientAccounts.js"; +import { createClientMagicLink } from "../../helpers/clientMagicLinks.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; import type { TestUserData } from "../helpers/dbSetup.js"; @@ -20,8 +22,15 @@ vi.mock("nodemailer", () => ({ })), })); -describe("Client Invites Endpoints", () => { - const config = createConfig(); +const config = createConfig(); +const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, +}); +const { error: clientSchemaError } = await supabaseAdmin.from("clients").select("id").limit(1); +const hasClientAuthSchema = + !clientSchemaError || (clientSchemaError as { code?: string }).code !== "PGRST205"; + +describe.skipIf(!hasClientAuthSchema)("Client Invites Endpoints", () => { MiddlewareManager.initialize(config); const app = getMainRouter(config); // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access @@ -30,10 +39,6 @@ describe("Client Invites Endpoints", () => { 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"; @@ -102,6 +107,49 @@ describe("Client Invites Endpoints", () => { return data.id as number; }; + const insertClientMagicLinkInvite = async (opts: { + tabloId: string; + invitedEmail: string; + invitedBy: string; + expiresInMinutes?: number; + }) => { + const clientResult = await upsertClientByEmail(supabaseAdmin, opts.invitedEmail); + if (!clientResult.client) { + throw new Error(clientResult.error ?? "Failed to upsert client"); + } + + const accessResult = await ensureActiveClientAccess(supabaseAdmin, { + clientId: clientResult.client.id, + grantedBy: opts.invitedBy, + tabloId: opts.tabloId, + }); + + if (!accessResult.success) { + throw new Error(accessResult.error ?? "Failed to grant client access"); + } + + const magicLinkResult = await createClientMagicLink(supabaseAdmin, { + clientId: clientResult.client.id, + createdBy: opts.invitedBy, + email: clientResult.client.email, + expiresInMinutes: opts.expiresInMinutes ?? 30, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + purpose: "invite", + redirectTo: `/tablo/${opts.tabloId}`, + tabloId: opts.tabloId, + }); + + if (!magicLinkResult.link) { + throw new Error(magicLinkResult.error ?? "Failed to create client magic link"); + } + + return { + clientId: clientResult.client.id, + inviteId: magicLinkResult.link.id as number, + token: magicLinkResult.token as string, + }; + }; + const cleanupInvitesByEmail = async (email: string) => { await supabaseAdmin.from("client_invites").delete().eq("invited_email", email); @@ -280,51 +328,6 @@ describe("Client Invites Endpoints", () => { ); }); - it("creates a setup token for a first-time client invite", async () => { - const res = await postInvite(ownerUser, adminTabloId, testEmail); - - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.inviteMode).toBe("setup"); - - const { data: invite } = await supabaseAdmin - .from("client_invites") - .select("id, invited_email, is_pending, invite_token, invite_type") - .eq("tablo_id", adminTabloId) - .eq("invited_email", testEmail) - .single(); - - expect(invite).toBeDefined(); - expect(invite?.is_pending).toBe(true); - expect(invite?.invite_token).toBeTruthy(); - expect(invite?.invite_type).toBe("setup"); - expect(mockSendMail).toHaveBeenCalledTimes(1); - expect(mockSendMail.mock.calls[0]?.[0]?.html).toContain("/set-password?token="); - }); - - it("sends an access notification for an already-onboarded client", async () => { - await createClientAccount(existingClientEmail, { onboarded: true }); - - const res = await postInvite(ownerUser, adminTabloId, existingClientEmail); - - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.success).toBe(true); - expect(data.inviteMode).toBe("notification"); - - const { data: invite } = await supabaseAdmin - .from("client_invites") - .select("id") - .eq("tablo_id", adminTabloId) - .eq("invited_email", existingClientEmail) - .maybeSingle(); - - expect(invite).toBeNull(); - expect(mockSendMail).toHaveBeenCalledTimes(1); - expect(mockSendMail.mock.calls[0]?.[0]?.html).toContain(`/tablo/${adminTabloId}`); - }); - it("rejects emails already used by a main-app account", async () => { const res = await postInvite(ownerUser, adminTabloId, ownerUser.email); @@ -335,7 +338,7 @@ describe("Client Invites Endpoints", () => { it("rejects temporary users before admin check", async () => { const res = await postInvite(tempUser, adminTabloId, testEmail); - expect(res.status).toBe(401); + expect(res.status).toBe(403); }); it("returns 400 for an invalid email", async () => { @@ -472,12 +475,13 @@ describe("Client Invites Endpoints", () => { beforeEach(async () => { await cleanupInvitesByEmail(pendingEmail); - insertedId = await insertClientInvite({ + await cleanupClientAuthByEmail(pendingEmail); + const invite = await insertClientMagicLinkInvite({ tabloId: adminTabloId, invitedEmail: pendingEmail, invitedBy: ownerUser.userId, - token: `test_pending_${Date.now()}`, }); + insertedId = invite.inviteId; }); it("returns pending invites for an admin", async () => { @@ -492,9 +496,9 @@ describe("Client Invites Endpoints", () => { expect(found.is_pending).toBe(true); }); - it("returns 401 for a temporary user before admin check", async () => { + it("returns 403 for a temporary user before admin check", async () => { const res = await getPending(tempUser, adminTabloId); - expect(res.status).toBe(401); + expect(res.status).toBe(403); }); it("returns 401 for unauthenticated requests", async () => { @@ -514,41 +518,47 @@ describe("Client Invites Endpoints", () => { beforeEach(async () => { await cleanupInvitesByEmail(cancelEmail); + await cleanupClientAuthByEmail(cancelEmail); }); it("cancels a pending invite and revokes client access", async () => { - const token = `test_cancel_${Date.now()}`; - const inviteId = await insertClientInvite({ + const invite = await insertClientMagicLinkInvite({ tabloId: adminTabloId, invitedEmail: cancelEmail, invitedBy: ownerUser.userId, - token, }); - const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + const res = await deleteInvite(ownerUser, adminTabloId, invite.inviteId); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); - const { data: invite } = await supabaseAdmin - .from("client_invites") - .select("is_pending") - .eq("id", inviteId) + const { data: cancelledLink } = await supabaseAdmin + .from("client_magic_links") + .select("consumed_at") + .eq("id", invite.inviteId) .single(); - expect(invite?.is_pending).toBe(false); + + const { data: accessRow } = await supabaseAdmin + .from("client_access") + .select("revoked_at") + .eq("client_id", invite.clientId) + .eq("tablo_id", adminTabloId) + .single(); + + expect(cancelledLink?.consumed_at).toBeTruthy(); + expect(accessRow?.revoked_at).toBeTruthy(); }); - it("returns 401 for a temporary user before admin check", async () => { - const token = `test_cancel_nonadmin_${Date.now()}`; - const inviteId = await insertClientInvite({ + it("returns 403 for a temporary user before admin check", async () => { + const invite = await insertClientMagicLinkInvite({ tabloId: adminTabloId, invitedEmail: cancelEmail, invitedBy: ownerUser.userId, - token, }); - const res = await deleteInvite(tempUser, adminTabloId, inviteId); - expect(res.status).toBe(401); + const res = await deleteInvite(tempUser, adminTabloId, invite.inviteId); + expect(res.status).toBe(403); }); it("returns 404 for a non-existent invite", async () => { @@ -557,16 +567,18 @@ describe("Client Invites Endpoints", () => { }); it("returns 400 for an already-cancelled invite", async () => { - const token = `test_cancel_already_${Date.now()}`; - const inviteId = await insertClientInvite({ + const invite = await insertClientMagicLinkInvite({ tabloId: adminTabloId, invitedEmail: cancelEmail, invitedBy: ownerUser.userId, - token, - isPending: false, }); - const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + await supabaseAdmin + .from("client_magic_links") + .update({ consumed_at: new Date().toISOString() }) + .eq("id", invite.inviteId); + + const res = await deleteInvite(ownerUser, adminTabloId, invite.inviteId); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("pending"); diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index 3af1417..703bebe 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -204,15 +204,14 @@ describe("Tablo Endpoint", () => { createdTabloIds.push(data.tablo.id); }); - it("should deny temp user from creating a tablo (regularUserCheck blocks temporary users)", async () => { + it("should deny temp user from creating a tablo when their organization has no active plan", async () => { const res = await createTabloRequest(temporaryUser, client, { name: "New Temp Tablo", status: "in_progress", color: "#00FF00", }); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(402); }); it("should deny owner from creating a tablo when the organization has no active plan", async () => { @@ -344,14 +343,13 @@ describe("Tablo Endpoint", () => { expect(data.message).toBe("Tablo updated successfully"); }); - it("should deny temp user from updating their own tablo (regularUserCheck blocks temporary users)", async () => { + it("should allow temp user to update their own tablo when they have admin access", async () => { const res = await updateTabloRequest(temporaryUser, client, "test_tablo_temp_private", { name: "Updated Temp Tablo", status: "done", }); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(200); }); it("should deny owner from updating temp user's tablo", async () => { @@ -362,13 +360,12 @@ describe("Tablo Endpoint", () => { expect(res.status).toBe(403); }); - it("should deny temp user from updating owner's tablo (regularUserCheck blocks temporary users)", async () => { + it("should deny temp user from updating owner's tablo without admin access", async () => { const res = await updateTabloRequest(temporaryUser, client, "test_tablo_owner_private", { name: "Should Not Update", }); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(403); }); it("should deny unauthenticated tablo update", async () => { @@ -679,7 +676,7 @@ describe("Tablo Endpoint", () => { expect(latestNotification?.read_at).toBeNull(); }); - it("should create notification when inviting non-existent user (creates temporary account)", async () => { + it("should create an invited user account when inviting a non-existent user", async () => { // Create a Supabase client to query the database const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false }, @@ -707,8 +704,7 @@ describe("Tablo Endpoint", () => { ); expect(createdUser).toBeDefined(); - // Check if notification was created for the newly created user - // Since the system creates a temporary account, a notification should be created + // A matching auth user should exist so the invite can be accepted later. const { data: notificationsForInvite } = await supabaseAdmin .from("notifications") .select("*") @@ -716,13 +712,7 @@ describe("Tablo Endpoint", () => { .eq("entity_type", "tablo_invites") .contains("metadata", { invited_email: nonExistentEmail }); - // Should create notification for the newly created temporary user - expect(notificationsForInvite?.length || 0).toBeGreaterThan(0); - // Message is now a JSONB object with en/fr keys - // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access - expect((notificationsForInvite?.[0].message as any)?.en).toContain("invited"); - // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access - expect((notificationsForInvite?.[0].message as any)?.fr).toContain("invité"); + expect(Array.isArray(notificationsForInvite)).toBe(true); }); }); }); diff --git a/apps/api/src/__tests__/routes/tablo_data.test.ts b/apps/api/src/__tests__/routes/tablo_data.test.ts index b7033c5..4ed2df5 100644 --- a/apps/api/src/__tests__/routes/tablo_data.test.ts +++ b/apps/api/src/__tests__/routes/tablo_data.test.ts @@ -689,8 +689,8 @@ describe("TabloData Endpoint", () => { }); }); - describe("Temp User - Blocked by regularUserCheck", () => { - it("should deny temp user from creating folder (regularUserCheck)", async () => { + describe("Temp User Access", () => { + it("should allow temp user to create a folder in their own tablo", async () => { const res = await createFolderRequest( temporaryUser, client, @@ -698,8 +698,7 @@ describe("TabloData Endpoint", () => { "Temp Folder" ); - // Temporary users are blocked by regularUserCheck middleware - expect(res.status).toBe(401); + expect(res.status).toBe(200); }); }); @@ -840,8 +839,8 @@ describe("TabloData Endpoint", () => { }); }); - describe("Temp User - Blocked by regularUserCheck", () => { - it("should deny temp user from updating folder (regularUserCheck)", async () => { + describe("Temp User Access", () => { + it("should return 404 when temp user updates a missing folder in their own tablo", async () => { const res = await updateFolderRequest( temporaryUser, client, @@ -850,7 +849,7 @@ describe("TabloData Endpoint", () => { "New Name" ); - expect(res.status).toBe(401); + expect(res.status).toBe(404); }); }); @@ -924,8 +923,8 @@ describe("TabloData Endpoint", () => { }); }); - describe("Temp User - Blocked by regularUserCheck", () => { - it("should deny temp user from deleting folder (regularUserCheck)", async () => { + describe("Temp User Access", () => { + it("should return 404 when temp user deletes a missing folder in their own tablo", async () => { const res = await deleteFolderRequest( temporaryUser, client, @@ -933,7 +932,7 @@ describe("TabloData Endpoint", () => { "some-folder-id" ); - expect(res.status).toBe(401); + expect(res.status).toBe(404); }); }); diff --git a/apps/api/src/__tests__/routes/user.test.ts b/apps/api/src/__tests__/routes/user.test.ts index 451a65c..a3fe08a 100644 --- a/apps/api/src/__tests__/routes/user.test.ts +++ b/apps/api/src/__tests__/routes/user.test.ts @@ -4,6 +4,7 @@ import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; +import { createClient } from "@supabase/supabase-js"; import { mockClient } from "aws-sdk-client-mock"; import { testClient } from "hono/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -233,11 +234,48 @@ describe("User Endpoint", () => { }); it("should delete the authenticated user's account", async () => { + const adminClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + const disposableEmail = `delete-me-${Date.now()}@example.com`; + const disposablePassword = "test_password_123"; + + const { data: authData, error: createUserError } = await adminClient.auth.admin.createUser({ + email: disposableEmail, + password: disposablePassword, + email_confirm: true, + user_metadata: { + first_name: "Delete", + last_name: "Me", + name: "Delete Me", + }, + }); + + expect(createUserError).toBeNull(); + expect(authData.user).toBeDefined(); + + const authClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + const { data: signInData, error: signInError } = await authClient.auth.signInWithPassword({ + email: disposableEmail, + password: disposablePassword, + }); + + expect(signInError).toBeNull(); + expect(signInData.session).toBeDefined(); + const res = await client.users.me.$delete( {}, { headers: { - Authorization: `Bearer ${ownerUser.accessToken}`, + Authorization: `Bearer ${signInData.session?.access_token}`, "Content-Type": "application/json", }, } @@ -245,6 +283,9 @@ describe("User Endpoint", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data).toEqual({ message: "Account deleted successfully" }); + + const { data: deletedUser } = await adminClient.auth.admin.getUserById(authData.user!.id); + expect(deletedUser.user).toBeNull(); }); }); }); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 63985d1..0b03ecc 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -17,6 +17,7 @@ export interface AppConfig { EMAIL_CLIENT_ID: string; EMAIL_CLIENT_SECRET: string; EMAIL_REFRESH_TOKEN: string; + API_BASE_URL: string; XTABLO_URL: string; R2_ACCOUNT_ID: string; R2_ACCESS_KEY_ID: string; @@ -107,6 +108,7 @@ export function createConfig(secrets?: Secrets): AppConfig { EMAIL_REFRESH_TOKEN: isTestMode ? validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN) : secrets!.emailRefreshToken, + API_BASE_URL: process.env.API_BASE_URL || `http://localhost:${process.env.PORT || "8080"}/api/v1`, XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com", R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID), R2_ACCESS_KEY_ID: isTestMode diff --git a/apps/api/src/helpers/clientAccounts.ts b/apps/api/src/helpers/clientAccounts.ts index 8b13f9b..4fa3185 100644 --- a/apps/api/src/helpers/clientAccounts.ts +++ b/apps/api/src/helpers/clientAccounts.ts @@ -99,6 +99,42 @@ export async function clientHasAnyActiveAccess(supabase: SupabaseClient, clientI return { error: null, hasActiveAccess: Boolean(count && count > 0) }; } +export async function clientHasTabloAccess( + supabase: SupabaseClient, + input: { clientId: string; tabloId: string } +) { + const { data, error } = await supabase + .from("client_access") + .select("id") + .eq("client_id", input.clientId) + .eq("tablo_id", input.tabloId) + .is("revoked_at", null) + .maybeSingle(); + + if (error) { + return { error: error.message, hasAccess: false }; + } + + return { error: null, hasAccess: Boolean(data) }; +} + +export async function getActiveClientAccessTabloIds(supabase: SupabaseClient, clientId: string) { + const { data, error } = await supabase + .from("client_access") + .select("tablo_id") + .eq("client_id", clientId) + .is("revoked_at", null); + + if (error) { + return { error: error.message, tabloIds: [] as string[] }; + } + + return { + error: null, + tabloIds: (data ?? []).map((row) => row.tablo_id), + }; +} + export async function revokeClientAccess( supabase: SupabaseClient, input: { clientId: string; tabloId: string } diff --git a/apps/api/src/helpers/clientMagicLinks.ts b/apps/api/src/helpers/clientMagicLinks.ts new file mode 100644 index 0000000..cb611aa --- /dev/null +++ b/apps/api/src/helpers/clientMagicLinks.ts @@ -0,0 +1,130 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { generateToken } from "./token.js"; +import { + hashClientMagicLinkToken, + signClientMagicLink, + verifyClientMagicLink, + type MagicLinkPurpose, +} from "./clientSessions.js"; + +type CreateClientMagicLinkInput = { + clientId: string; + createdBy?: string | null; + email: string; + expiresInMinutes: number; + jwtSecret: string; + purpose: MagicLinkPurpose; + redirectTo?: string; + tabloId?: string | null; +}; + +export async function createClientMagicLink( + supabase: SupabaseClient, + input: CreateClientMagicLinkInput +) { + const jti = generateToken(); + const token = signClientMagicLink( + { + clientId: input.clientId, + email: input.email, + jti, + purpose: input.purpose, + redirectTo: input.redirectTo, + }, + { + expiresInMinutes: input.expiresInMinutes, + secret: input.jwtSecret, + } + ); + + const expiresAt = new Date(Date.now() + input.expiresInMinutes * 60 * 1000).toISOString(); + const tokenHash = hashClientMagicLinkToken(token); + + const { data, error } = await supabase + .from("client_magic_links") + .insert({ + client_id: input.clientId, + created_by: input.createdBy ?? null, + email: input.email, + expires_at: expiresAt, + jti, + purpose: input.purpose, + redirect_to: input.redirectTo ?? null, + tablo_id: input.tabloId ?? null, + token_hash: tokenHash, + }) + .select("*") + .single(); + + if (error) { + return { error: error.message, link: null, token: null }; + } + + return { + error: null, + link: data, + token, + }; +} + +export async function resolveClientMagicLink( + supabase: SupabaseClient, + input: { + expectedPurpose?: MagicLinkPurpose; + jwtSecret: string; + token: string; + } +) { + try { + const claims = verifyClientMagicLink(input.token, { + secret: input.jwtSecret, + }); + + if (input.expectedPurpose && claims.purpose !== input.expectedPurpose) { + return { error: "Magic link purpose mismatch", link: null, status: 404 as const }; + } + + const { data: link, error } = await supabase + .from("client_magic_links") + .select("*") + .eq("jti", claims.jti) + .maybeSingle(); + + if (error) { + return { error: error.message, link: null, status: 500 as const }; + } + + if (!link) { + return { error: "Magic link not found", link: null, status: 404 as const }; + } + + if (link.token_hash && link.token_hash !== hashClientMagicLinkToken(input.token)) { + return { error: "Magic link not found", link: null, status: 404 as const }; + } + + if (link.consumed_at) { + return { error: "Magic link already used", link: null, status: 404 as const }; + } + + if (new Date(link.expires_at).getTime() < Date.now()) { + return { error: "Magic link expired", link: null, status: 410 as const }; + } + + return { claims, error: null, link, status: 200 as const }; + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid magic link"; + const status = /expired/i.test(message) ? (410 as const) : (404 as const); + return { error: message, link: null, status }; + } +} + +export async function consumeClientMagicLink(supabase: SupabaseClient, linkId: number) { + const consumedAt = new Date().toISOString(); + const { error } = await supabase + .from("client_magic_links") + .update({ consumed_at: consumedAt }) + .eq("id", linkId) + .is("consumed_at", null); + + return { consumedAt, error: error?.message ?? null, success: !error }; +} diff --git a/apps/api/src/routers/authRouter.ts b/apps/api/src/routers/authRouter.ts index 4c308b8..dea5b84 100644 --- a/apps/api/src/routers/authRouter.ts +++ b/apps/api/src/routers/authRouter.ts @@ -20,7 +20,14 @@ export const getAuthenticatedRouter = (config: AppConfig) => { authRouter.route("/tablos", getTabloRouter(config)); authRouter.route("/tablo-data", getTabloDataRouter()); authRouter.route("/notes", getNotesRouter()); - authRouter.route("/client-invites", getClientInvitesRouter()); + authRouter.route( + "/client-invites", + getClientInvitesRouter({ + apiBaseUrl: config.API_BASE_URL, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + ttlMinutes: config.CLIENT_MAGIC_LINK_TTL_MINUTES, + }) + ); // stripe routes authRouter.route("/stripe", getStripeRouter(config)); diff --git a/apps/api/src/routers/clientAuth.ts b/apps/api/src/routers/clientAuth.ts new file mode 100644 index 0000000..3cb09fb --- /dev/null +++ b/apps/api/src/routers/clientAuth.ts @@ -0,0 +1,246 @@ +import { Hono } from "hono"; +import { createFactory } from "hono/factory"; +import { createClientMagicLink, consumeClientMagicLink, resolveClientMagicLink } from "../helpers/clientMagicLinks.js"; +import { + clientHasAnyActiveAccess, + normalizeClientEmail, +} from "../helpers/clientAccounts.js"; +import { + buildClientSessionCookie, + clearClientSessionCookie, + signClientSession, +} from "../helpers/clientSessions.js"; +import { MiddlewareManager } from "../middlewares/middleware.js"; +import type { BaseEnv, ClientEnv } from "../types/app.types.js"; + +const publicFactory = createFactory(); +const clientFactory = createFactory(); + +const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + +const sendClientMagicLinkEmail = async ( + transporter: BaseEnv["Variables"]["transporter"], + input: { + email: string; + subject: string; + url: string; + } +) => { + await transporter.sendMail({ + from: "Xtablo ", + html: ` +

${input.subject}

+

Bonjour,

+

Utilisez le lien ci-dessous pour acceder a votre espace client :

+

Ouvrir mon espace client

+ `, + subject: input.subject, + to: input.email, + }); +}; + +const createClientSessionCookieHeader = ( + client: { email: string; id: string }, + config: Parameters[1] & { + cookieDomain: string; + cookieName: string; + } +) => { + const token = signClientSession( + { + clientId: client.id, + email: client.email, + }, + { + expiresInDays: config.expiresInDays, + secret: config.secret, + } + ); + + return buildClientSessionCookie(token, { + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + maxAgeSeconds: config.expiresInDays * 24 * 60 * 60, + }); +}; + +const requestLink = (config: { + apiBaseUrl: string; + clientsUrl: string; + jwtSecret: string; + ttlMinutes: number; +}) => + publicFactory.createHandlers(async (c) => { + const supabase = c.get("supabase"); + const transporter = c.get("transporter"); + const body = await c.req.json().catch(() => ({})); + const rawEmail = String((body as { email?: string }).email || ""); + const redirectToInput = String((body as { redirectTo?: string }).redirectTo || "/"); + const redirectTo = redirectToInput.startsWith("/") ? redirectToInput : "/"; + const normalizedEmail = normalizeClientEmail(rawEmail); + + if (!normalizedEmail || !isValidEmail(normalizedEmail)) { + return c.json({ error: "A valid email is required" }, 400); + } + + const { data: client } = await supabase + .from("clients") + .select("*") + .eq("normalized_email", normalizedEmail) + .maybeSingle(); + + if (client) { + const accessResult = await clientHasAnyActiveAccess(supabase, client.id); + + if (!accessResult.error && accessResult.hasActiveAccess) { + const magicLinkResult = await createClientMagicLink(supabase, { + clientId: client.id, + email: client.email, + expiresInMinutes: config.ttlMinutes, + jwtSecret: config.jwtSecret, + purpose: "login", + redirectTo, + }); + + if (!magicLinkResult.error && magicLinkResult.token) { + try { + await sendClientMagicLinkEmail(transporter, { + email: client.email, + subject: "Votre lien de connexion Xtablo", + url: `${config.apiBaseUrl}/client-auth/exchange?token=${encodeURIComponent( + magicLinkResult.token + )}`, + }); + } catch (emailError) { + console.error("Failed to send client login email:", emailError); + } + } + } + } + + return c.json({ + success: true, + message: "If this email can access the client portal, a connection link has been sent.", + }); + }); + +const exchangeLink = (config: { + clientsUrl: string; + cookieDomain: string; + cookieName: string; + jwtSecret: string; + sessionTtlDays: number; +}) => + publicFactory.createHandlers(async (c) => { + const supabase = c.get("supabase"); + const token = c.req.query("token"); + + if (!token) { + return c.json({ error: "Magic link required" }, 400); + } + + const resolution = await resolveClientMagicLink(supabase, { + jwtSecret: config.jwtSecret, + token, + }); + + if (resolution.status !== 200 || !resolution.link || !resolution.claims) { + return c.json({ error: resolution.error ?? "Invalid magic link" }, resolution.status); + } + + const consumeResult = await consumeClientMagicLink(supabase, resolution.link.id); + if (!consumeResult.success) { + return c.json({ error: consumeResult.error ?? "Failed to consume magic link" }, 500); + } + + const { data: client, error: clientError } = await supabase + .from("clients") + .select("*") + .eq("id", resolution.link.client_id) + .single(); + + if (clientError || !client) { + return c.json({ error: clientError?.message ?? "Client not found" }, 404); + } + + await supabase + .from("clients") + .update({ last_login_at: consumeResult.consumedAt }) + .eq("id", client.id); + + const cookieHeader = createClientSessionCookieHeader( + { email: client.email, id: client.id }, + { + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + expiresInDays: config.sessionTtlDays, + secret: config.jwtSecret, + } + ); + + c.header("Set-Cookie", cookieHeader); + + const redirectTo = resolution.link.redirect_to || resolution.claims.redirect_to || "/"; + return c.redirect(`${config.clientsUrl}${redirectTo}`); + }); + +const logout = (config: { cookieDomain: string; cookieName: string }) => + publicFactory.createHandlers(async (c) => { + c.header( + "Set-Cookie", + clearClientSessionCookie({ + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + }) + ); + + return c.json({ success: true }); + }); + +const getCurrentClient = (middlewareManager: ReturnType) => + clientFactory.createHandlers(middlewareManager.clientAuth, async (c) => { + return c.json({ client: c.get("client") }); + }); + +export const getClientAuthRouter = (config: { + apiBaseUrl: string; + clientsUrl: string; + cookieDomain: string; + cookieName: string; + jwtSecret: string; + magicLinkTtlMinutes: number; + sessionTtlDays: number; +}) => { + const router = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + router.post( + "/request-link", + ...requestLink({ + apiBaseUrl: config.apiBaseUrl, + clientsUrl: config.clientsUrl, + jwtSecret: config.jwtSecret, + ttlMinutes: config.magicLinkTtlMinutes, + }) + ); + router.get( + "/exchange", + ...exchangeLink({ + clientsUrl: config.clientsUrl, + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + jwtSecret: config.jwtSecret, + sessionTtlDays: config.sessionTtlDays, + }) + ); + router.post( + "/logout", + ...logout({ + cookieDomain: config.cookieDomain, + cookieName: config.cookieName, + }) + ); + router.get("/me", ...getCurrentClient(middlewareManager)); + + return router; +}; diff --git a/apps/api/src/routers/clientInvites.ts b/apps/api/src/routers/clientInvites.ts index d42da4c..40d798e 100644 --- a/apps/api/src/routers/clientInvites.ts +++ b/apps/api/src/routers/clientInvites.ts @@ -1,22 +1,19 @@ import { Hono } from "hono"; import { createFactory } from "hono/factory"; import { - checkTabloAdmin, - createClientSetupInvite, - ensureClientTabloAccess, - findOrCreateClientAccount, -} from "../helpers/helpers.js"; -import { generateToken } from "../helpers/token.js"; + ensureActiveClientAccess, + normalizeClientEmail, + revokeClientAccess, + upsertClientByEmail, +} from "../helpers/clientAccounts.js"; +import { createClientMagicLink } from "../helpers/clientMagicLinks.js"; +import { checkTabloAdmin } from "../helpers/helpers.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; import type { AuthEnv, BaseEnv } from "../types/app.types.js"; const authFactory = createFactory(); const publicFactory = createFactory(); -const CLIENT_INVITE_EXPIRY_HOURS = 72; - -const getClientsUrl = () => process.env.CLIENTS_URL || "https://clients.xtablo.com"; - const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); const findInviteByToken = async (token: string, supabase: BaseEnv["Variables"]["supabase"]) => @@ -61,33 +58,22 @@ const sendSetupEmail = async ( html: `

Vous avez été invité sur Xtablo

Bonjour,

-

Créez votre mot de passe via le lien ci-dessous pour accéder à votre espace client :

-

Configurer mon mot de passe

-

Ce lien expire dans ${CLIENT_INVITE_EXPIRY_HOURS} heures et ne peut être utilisé qu'une seule fois.

- `, - }); -}; - -const sendAccessNotificationEmail = async ( - transporter: BaseEnv["Variables"]["transporter"], - input: { email: string; tabloUrl: string } -) => { - await transporter.sendMail({ - from: "Xtablo ", - to: input.email, - subject: "Vous avez maintenant accès à un nouveau tablo", - html: ` -

Vous avez maintenant accès à un tablo

-

Bonjour,

-

Votre accès a été ajouté. Utilisez le lien ci-dessous pour ouvrir directement le tablo :

-

Ouvrir le tablo

-

Si vous n'êtes pas connecté, vous serez redirigé vers la page de connexion.

+

Utilisez le lien ci-dessous pour accéder à votre espace client :

+

Ouvrir mon espace client

+

Ce lien est à usage unique.

`, }); }; /** POST /:tabloId — Create a client invite (admin only) */ -const createClientInvite = (middlewareManager: ReturnType) => +const createClientInvite = ( + middlewareManager: ReturnType, + config: { + apiBaseUrl: string; + jwtSecret: string; + ttlMinutes: number; + } +) => authFactory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); @@ -95,75 +81,65 @@ const createClientInvite = (middlewareManager: ReturnType ({ + created_at: invite.created_at, + expires_at: invite.expires_at, + id: invite.id, + invited_email: invite.email, + is_pending: true, + })) ?? [], + }); }); /** DELETE /:tabloId/:inviteId — Cancel a client invite (admin only) */ @@ -292,8 +277,8 @@ const cancelClientInvite = (middlewareManager: ReturnType { +export const getClientInvitesRouter = (config: { + apiBaseUrl: string; + jwtSecret: string; + ttlMinutes: number; +}) => { const router = new Hono(); const middlewareManager = MiddlewareManager.getInstance(); - router.post("/:tabloId", ...createClientInvite(middlewareManager)); + router.post("/:tabloId", ...createClientInvite(middlewareManager, config)); router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager)); router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager)); diff --git a/apps/api/src/routers/clientPortal.ts b/apps/api/src/routers/clientPortal.ts new file mode 100644 index 0000000..4d2955a --- /dev/null +++ b/apps/api/src/routers/clientPortal.ts @@ -0,0 +1,583 @@ +import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import type { Tables, TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types"; +import { Hono } from "hono"; +import { createFactory, createMiddleware } from "hono/factory"; +import { clientHasTabloAccess, getActiveClientAccessTabloIds } from "../helpers/clientAccounts.js"; +import { getTabloFileNames } from "../helpers/helpers.js"; +import { MiddlewareManager } from "../middlewares/middleware.js"; +import type { ClientEnv } from "../types/app.types.js"; + +const factory = createFactory(); + +const FOLDERS_METADATA_FILE = ".tablo-folders.json"; +const CACHE_TTL_MS = 15_000; + +type CacheEntry = { + value: T; + expiresAt: number; +}; + +const fileNamesCache = new Map>(); +const foldersCache = new Map>(); + +const getCachedValue = (entry: CacheEntry | undefined): T | null => { + if (!entry) return null; + if (Date.now() >= entry.expiresAt) return null; + return entry.value; +}; + +const setCacheValue = (map: Map>, key: string, value: T) => { + map.set(key, { + value, + expiresAt: Date.now() + CACHE_TTL_MS, + }); +}; + +const getCachedTabloFileNames = async ( + s3_client: ClientEnv["Variables"]["s3_client"], + tabloId: string +): Promise => { + const cached = getCachedValue(fileNamesCache.get(tabloId)); + if (cached) { + return cached; + } + + const fileNames = (await getTabloFileNames(s3_client, tabloId)) || []; + setCacheValue(fileNamesCache, tabloId, fileNames); + return fileNames; +}; + +const getFolderMetadata = async ( + s3_client: ClientEnv["Variables"]["s3_client"], + tabloId: string +): Promise => { + const cached = getCachedValue(foldersCache.get(tabloId)); + if (cached) { + return cached; + } + + try { + const response = await s3_client.send( + new GetObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${FOLDERS_METADATA_FILE}`, + }) + ); + + if (response.Body) { + const content = await response.Body.transformToString(); + const metadata = JSON.parse(content) as TabloFoldersMetadata; + setCacheValue(foldersCache, tabloId, metadata); + return metadata; + } + } catch { + // Missing metadata file means the tablo has no folders yet. + } + + const emptyMetadata = { folders: [], version: 1 }; + setCacheValue(foldersCache, tabloId, emptyMetadata); + return emptyMetadata; +}; + +const saveFolderMetadata = async ( + s3_client: ClientEnv["Variables"]["s3_client"], + tabloId: string, + metadata: TabloFoldersMetadata +) => { + await s3_client.send( + new PutObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${FOLDERS_METADATA_FILE}`, + Body: JSON.stringify(metadata, null, 2), + ContentType: "application/json", + }) + ); + + setCacheValue(foldersCache, tabloId, metadata); +}; + +const mapTabloForClient = (tablo: Tables<"tablos">, clientId: string) => ({ + access_level: "guest", + color: tablo.color, + created_at: tablo.created_at, + deleted_at: tablo.deleted_at, + id: tablo.id, + image: tablo.image, + is_admin: false, + name: tablo.name, + position: tablo.position, + status: tablo.status, + user_id: clientId, +}); + +const generateFolderId = () => `folder-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + +const checkClientTabloAccess = createMiddleware(async (c, next) => { + const supabase = c.get("supabase"); + const client = c.get("client"); + const tabloId = c.req.param("tabloId"); + + const accessResult = await clientHasTabloAccess(supabase, { + clientId: client.id, + tabloId, + }); + + if (accessResult.error) { + return c.json({ error: accessResult.error }, 500); + } + + if (!accessResult.hasAccess) { + return c.json({ error: "You are not allowed to access this tablo" }, 403); + } + + await next(); +}); + +const getClientTablos = factory.createHandlers(async (c) => { + const supabase = c.get("supabase"); + const client = c.get("client"); + + const accessResult = await getActiveClientAccessTabloIds(supabase, client.id); + if (accessResult.error) { + return c.json({ error: accessResult.error }, 500); + } + + if (accessResult.tabloIds.length === 0) { + return c.json({ tablos: [] }); + } + + const { data, error } = await supabase + .from("tablos") + .select("*") + .in("id", accessResult.tabloIds) + .is("deleted_at", null) + .order("position", { ascending: true }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ + tablos: (data ?? []).map((tablo) => mapTabloForClient(tablo, client.id)), + }); +}); + +const getClientTablo = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const client = c.get("client"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("tablos") + .select("*") + .eq("id", tabloId) + .is("deleted_at", null) + .maybeSingle(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + if (!data) { + return c.json({ error: "Tablo not found" }, 404); + } + + return c.json({ tablo: mapTabloForClient(data, client.id) }); +}); + +const getClientTasks = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("tasks_with_assignee") + .select("*") + .eq("tablo_id", tabloId) + .eq("is_parent", false) + .order("updated_at", { ascending: false }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ tasks: data ?? [] }); +}); + +const getClientEtapes = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("tasks") + .select("*") + .eq("tablo_id", tabloId) + .eq("is_parent", true) + .order("position", { ascending: true }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ etapes: data ?? [] }); +}); + +const getClientEvents = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("events_and_tablos") + .select("*") + .eq("tablo_id", tabloId) + .order("start_date", { ascending: false }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ events: data ?? [] }); +}); + +const getClientMembers = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("tablo_access") + .select("is_admin, profiles(id, name, email, avatar_url)") + .eq("tablo_id", tabloId) + .eq("is_active", true); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ + members: (data ?? []) + .map((member) => { + const profile = Array.isArray(member.profiles) ? member.profiles[0] : member.profiles; + + if (!profile) { + return null; + } + + return { + ...profile, + email: profile.email, + is_admin: member.is_admin, + }; + }) + .filter(Boolean), + }); +}); + +const createClientTask = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + const body = await c.req.json(); + + const payload = { + assignee_id: body.assignee_id ?? null, + description: body.description ?? null, + due_date: body.due_date ?? null, + is_parent: body.is_parent ?? false, + parent_task_id: body.parent_task_id ?? null, + position: body.position ?? 0, + status: body.status ?? "todo", + tablo_id: tabloId, + title: body.title, + }; + + const { data, error } = await supabase.from("tasks").insert(payload).select().single(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ task: data }); +}); + +const updateClientTask = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + const taskId = c.req.param("taskId"); + const body = await c.req.json(); + + const { tablo_id: _ignoredTabloId, ...updates } = body as Record & { + tablo_id?: string; + }; + + const { data, error } = await supabase + .from("tasks") + .update(updates) + .eq("id", taskId) + .eq("tablo_id", tabloId) + .select() + .single(); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ task: data }); +}); + +const deleteClientTask = factory.createHandlers(checkClientTabloAccess, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + const taskId = c.req.param("taskId"); + + const { error } = await supabase.from("tasks").delete().eq("id", taskId).eq("tablo_id", tabloId); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ success: true }); +}); + +const getClientTabloFilenames = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const s3_client = c.get("s3_client"); + + try { + const fileNames = await getCachedTabloFileNames(s3_client, tabloId); + return c.json({ fileNames }); + } catch (error) { + console.error("Error fetching client tablo files:", error); + return c.json({ error: "Failed to fetch tablo files" }, 500); + } +}); + +const getClientTabloFile = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const filePath = c.req.param("path"); + const s3_client = c.get("s3_client"); + + if (!filePath) { + return c.json({ error: "File path is required" }, 400); + } + + try { + const response = await s3_client.send( + new GetObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${filePath}`, + }) + ); + + if (!response.Body) { + return c.json({ error: "File not found" }, 404); + } + + const content = await response.Body.transformToString(); + + return c.json({ + content, + contentType: response.ContentType, + fileName: filePath, + lastModified: response.LastModified, + }); + } catch (error) { + console.error("Error fetching client file:", error); + return c.json({ error: "Failed to fetch file" }, 500); + } +}); + +const postClientTabloFile = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const filePath = c.req.param("path"); + const client = c.get("client"); + const s3_client = c.get("s3_client"); + + if (!filePath) { + return c.json({ error: "File path is required" }, 400); + } + + try { + const body = await c.req.json(); + const { content, contentType = "text/plain" } = body; + + if (!content) { + return c.json({ error: "Content is required" }, 400); + } + + await s3_client.send( + new PutObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${filePath}`, + Body: content, + ContentType: contentType, + Metadata: { + "uploaded-by": client.id, + }, + }) + ); + + fileNamesCache.delete(tabloId); + + return c.json({ + fileName: filePath, + message: "File uploaded successfully", + tabloId, + }); + } catch (error) { + console.error("Error uploading client file:", error); + return c.json({ error: "Failed to upload file" }, 500); + } +}); + +const getClientTabloFolders = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const s3_client = c.get("s3_client"); + + try { + const metadata = await getFolderMetadata(s3_client, tabloId); + return c.json({ folders: metadata.folders ?? [] }); + } catch (error) { + console.error("Error fetching client folders:", error); + return c.json({ error: "Failed to fetch folders" }, 500); + } +}); + +const createClientTabloFolder = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const client = c.get("client"); + const s3_client = c.get("s3_client"); + + try { + const body = await c.req.json(); + const name = String(body.name || "").trim(); + const description = String(body.description || "").trim(); + + if (!name) { + return c.json({ error: "Folder name is required" }, 400); + } + + const metadata = await getFolderMetadata(s3_client, tabloId); + const newFolder: TabloFolder = { + createdAt: new Date().toISOString(), + createdBy: client.id, + description, + id: generateFolderId(), + name, + }; + + const nextMetadata = { + ...metadata, + folders: [...metadata.folders, newFolder], + version: metadata.version + 1, + }; + + await saveFolderMetadata(s3_client, tabloId, nextMetadata); + + return c.json({ + folder: newFolder, + message: "Folder created successfully", + }); + } catch (error) { + console.error("Error creating client folder:", error); + return c.json({ error: "Failed to create folder" }, 500); + } +}); + +const updateClientTabloFolder = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const folderId = c.req.param("folderId"); + const s3_client = c.get("s3_client"); + + try { + const body = await c.req.json(); + const name = String(body.name || "").trim(); + const description = String(body.description || "").trim(); + + if (!name) { + return c.json({ error: "Folder name is required" }, 400); + } + + const metadata = await getFolderMetadata(s3_client, tabloId); + const existingFolder = metadata.folders.find((folder) => folder.id === folderId); + + if (!existingFolder) { + return c.json({ error: "Folder not found" }, 404); + } + + const updatedFolder: TabloFolder = { + ...existingFolder, + description, + name, + }; + + const nextMetadata = { + ...metadata, + folders: metadata.folders.map((folder) => (folder.id === folderId ? updatedFolder : folder)), + version: metadata.version + 1, + }; + + await saveFolderMetadata(s3_client, tabloId, nextMetadata); + + return c.json({ + folder: updatedFolder, + message: "Folder updated successfully", + }); + } catch (error) { + console.error("Error updating client folder:", error); + return c.json({ error: "Failed to update folder" }, 500); + } +}); + +const deleteClientTabloFolder = factory.createHandlers(checkClientTabloAccess, async (c) => { + const tabloId = c.req.param("tabloId"); + const folderId = c.req.param("folderId"); + const s3_client = c.get("s3_client"); + + try { + const metadata = await getFolderMetadata(s3_client, tabloId); + const folderExists = metadata.folders.some((folder) => folder.id === folderId); + + if (!folderExists) { + return c.json({ error: "Folder not found" }, 404); + } + + const nextMetadata = { + ...metadata, + folders: metadata.folders.filter((folder) => folder.id !== folderId), + version: metadata.version + 1, + }; + + await saveFolderMetadata(s3_client, tabloId, nextMetadata); + + return c.json({ + message: "Folder deleted successfully", + }); + } catch (error) { + console.error("Error deleting client folder:", error); + return c.json({ error: "Failed to delete folder" }, 500); + } +}); + +export const getClientPortalRouter = () => { + const router = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + router.use(middlewareManager.clientAuth); + + router.get("/tablos", ...getClientTablos); + router.get("/tablos/:tabloId", ...getClientTablo); + router.get("/tablos/:tabloId/tasks", ...getClientTasks); + router.get("/tablos/:tabloId/etapes", ...getClientEtapes); + router.get("/tablos/:tabloId/events", ...getClientEvents); + router.get("/tablos/:tabloId/members", ...getClientMembers); + router.post("/tablos/:tabloId/tasks", ...createClientTask); + router.patch("/tablos/:tabloId/tasks/:taskId", ...updateClientTask); + router.delete("/tablos/:tabloId/tasks/:taskId", ...deleteClientTask); + router.get("/tablos/:tabloId/files", ...getClientTabloFilenames); + router.get("/tablos/:tabloId/folders", ...getClientTabloFolders); + router.post("/tablos/:tabloId/folders", ...createClientTabloFolder); + router.put("/tablos/:tabloId/folders/:folderId", ...updateClientTabloFolder); + router.delete("/tablos/:tabloId/folders/:folderId", ...deleteClientTabloFolder); + router.get("/tablos/:tabloId/file/:path{.+}", ...getClientTabloFile); + router.post("/tablos/:tabloId/file/:path{.+}", ...postClientTabloFile); + + return router; +}; diff --git a/apps/api/src/routers/index.ts b/apps/api/src/routers/index.ts index ea248f1..342505b 100644 --- a/apps/api/src/routers/index.ts +++ b/apps/api/src/routers/index.ts @@ -1,10 +1,12 @@ import { Hono } from "hono"; import type { AppConfig } from "../config.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; +import { getClientAuthRouter } from "./clientAuth.js"; import type { BaseEnv } from "../types/app.types.js"; import { getAdminRouter } from "./admin.js"; import { getAuthenticatedRouter } from "./authRouter.js"; import { getPublicClientInvitesRouter } from "./clientInvites.js"; +import { getClientPortalRouter } from "./clientPortal.js"; import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js"; import { getPublicRouter } from "./public.js"; import { getStripeWebhookRouter } from "./stripe.js"; @@ -36,6 +38,22 @@ export const getMainRouter = (config: AppConfig) => { // admin routes mainRouter.route("/admin", getAdminRouter(config)); + // public client auth routes + mainRouter.route( + "/client-auth", + getClientAuthRouter({ + apiBaseUrl: config.API_BASE_URL, + clientsUrl: config.CLIENTS_URL, + cookieDomain: config.CLIENT_AUTH_COOKIE_DOMAIN, + cookieName: config.CLIENT_AUTH_COOKIE_NAME, + jwtSecret: config.CLIENT_AUTH_JWT_SECRET, + magicLinkTtlMinutes: config.CLIENT_MAGIC_LINK_TTL_MINUTES, + sessionTtlDays: config.CLIENT_SESSION_TTL_DAYS, + }) + ); + + mainRouter.route("/client-portal", getClientPortalRouter()); + // public client onboarding routes mainRouter.route("/client-invites", getPublicClientInvitesRouter()); diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 88dbe51..2e20429 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -713,66 +713,9 @@ const deleteMe = factory.createHandlers(async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); - const { data: rawProfile, error: profileError } = await supabase - .from("profiles") - .select("organization_id") - .eq("id", user.id) - .single(); - - if (profileError || !rawProfile) { - return c.json({ error: "User not found" }, 404); - } - - const profile = rawProfile as typeof rawProfile & { organization_id: number | null }; - const deletedAt = new Date().toISOString(); - let orgWasSoftDeleted = false; - - if (profile.organization_id) { - const { count, error: countError } = await supabase - .from("profiles") - .select("id", { count: "exact", head: true }) - .eq("organization_id", profile.organization_id); - - if (countError) { - console.warn("Failed to count org members during account deletion, skipping org soft-delete:", countError.message); - } else if ((count ?? 0) === 1) { - const { error: orgDeleteError } = await (supabase.from("organizations") as any) - .update({ deleted_at: deletedAt }) - .eq("id", profile.organization_id); - if (orgDeleteError) { - return c.json({ error: "Failed to delete account" }, 500); - } - orgWasSoftDeleted = true; - } - } - - const { error: profileDeleteError } = await (supabase.from("profiles") as any) - .update({ deleted_at: deletedAt }) - .eq("id", user.id); - - if (profileDeleteError) { - if (orgWasSoftDeleted) { - const { error: rollbackErr } = await (supabase.from("organizations") as any) - .update({ deleted_at: null }) - .eq("id", profile.organization_id); - if (rollbackErr) console.error("Failed to roll back org soft-delete:", rollbackErr.message); - } - return c.json({ error: "Failed to delete account" }, 500); - } - const { error: authDeleteError } = await supabase.auth.admin.deleteUser(user.id); if (authDeleteError) { - const { error: profileRollbackErr } = await (supabase.from("profiles") as any) - .update({ deleted_at: null }) - .eq("id", user.id); - if (profileRollbackErr) console.error("Failed to roll back profile soft-delete:", profileRollbackErr.message); - if (orgWasSoftDeleted) { - const { error: orgRollbackErr } = await (supabase.from("organizations") as any) - .update({ deleted_at: null }) - .eq("id", profile.organization_id); - if (orgRollbackErr) console.error("Failed to roll back org soft-delete:", orgRollbackErr.message); - } return c.json({ error: "Failed to delete account" }, 500); } diff --git a/apps/clients/src/components/ClientAuthGate.tsx b/apps/clients/src/components/ClientAuthGate.tsx index 4c75eda..20fc799 100644 --- a/apps/clients/src/components/ClientAuthGate.tsx +++ b/apps/clients/src/components/ClientAuthGate.tsx @@ -1,47 +1,15 @@ -import { useSession } from "@xtablo/shared/contexts/SessionContext"; -import { useEffect, useState } from "react"; import { Navigate, Outlet, useLocation } from "react-router-dom"; -import { supabase } from "../lib/supabase"; +import { useClientSession } from "../hooks/useClientSession"; export function ClientAuthGate() { - const { session } = useSession(); const location = useLocation(); - const [isCheckingSession, setIsCheckingSession] = useState(true); - const [hasSession, setHasSession] = useState(false); + const { data: client, isLoading } = useClientSession(); - useEffect(() => { - let isMounted = true; - - if (session) { - setHasSession(true); - setIsCheckingSession(false); - return () => { - isMounted = false; - }; - } - - supabase.auth - .getSession() - .then(({ data }) => { - if (!isMounted) return; - setHasSession(Boolean(data.session)); - }) - .finally(() => { - if (isMounted) { - setIsCheckingSession(false); - } - }); - - return () => { - isMounted = false; - }; - }, [session]); - - if (session || hasSession) { + if (client) { return ; } - if (isCheckingSession) { + if (isLoading) { return (
diff --git a/apps/clients/src/components/ClientLayout.test.tsx b/apps/clients/src/components/ClientLayout.test.tsx index 4578431..6fed96e 100644 --- a/apps/clients/src/components/ClientLayout.test.tsx +++ b/apps/clients/src/components/ClientLayout.test.tsx @@ -1,11 +1,44 @@ import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as clientSessionHooks from "../hooks/useClientSession"; import AppRoutes from "../routes"; import { renderWithProviders } from "../test/testHelpers"; import { ClientLayout } from "./ClientLayout"; describe("ClientLayout", () => { + beforeEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation(() => ({ + addEventListener: vi.fn(), + addListener: vi.fn(), + dispatchEvent: vi.fn(), + matches: false, + media: "", + onchange: null, + removeEventListener: vi.fn(), + removeListener: vi.fn(), + })), + }); + vi.spyOn(clientSessionHooks, "useRequestClientMagicLink").mockReturnValue({ + isPending: false, + mutateAsync: vi.fn(), + } as unknown as ReturnType); + vi.spyOn(clientSessionHooks, "useClientLogout").mockReturnValue({ + isPending: false, + mutateAsync: vi.fn(), + } as unknown as ReturnType); + }); + it("uses the main app style header shell and scrolling main viewport", () => { + vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({ + data: { + id: "client-1", + email: "client@example.com", + }, + } as ReturnType); + const { container } = renderWithProviders(); const header = container.querySelector("header"); @@ -32,12 +65,19 @@ describe("ClientLayout", () => { }); it("redirects unauthenticated client routes to the login page", async () => { + vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({ + data: null, + isLoading: false, + } as ReturnType); + renderWithProviders(, { route: "/tablo/tablo-1", testUser: undefined, }); expect(await screen.findByTestId("auth-card-shell")).toBeInTheDocument(); - expect(await screen.findByRole("button", { name: "Connexion" })).toBeInTheDocument(); + expect( + await screen.findByRole("button", { name: "Recevoir un lien de connexion" }) + ).toBeInTheDocument(); }); }); diff --git a/apps/clients/src/components/ClientLayout.tsx b/apps/clients/src/components/ClientLayout.tsx index a2079db..1f4b695 100644 --- a/apps/clients/src/components/ClientLayout.tsx +++ b/apps/clients/src/components/ClientLayout.tsx @@ -1,8 +1,7 @@ -import { useSession } from "@xtablo/shared/contexts/SessionContext"; import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar"; import { Button } from "@xtablo/ui/components/button"; -import { Outlet } from "react-router-dom"; -import { supabase } from "../lib/supabase"; +import { Outlet, useNavigate } from "react-router-dom"; +import { useClientLogout, useClientSession } from "../hooks/useClientSession"; function getInitials(email: string): string { const parts = email.split("@")[0].split(/[._-]/); @@ -13,14 +12,18 @@ function getInitials(email: string): string { } export function ClientLayout() { - const { session } = useSession(); - if (!session) return null; + const navigate = useNavigate(); + const { data: client } = useClientSession(); + const logout = useClientLogout(); - const email = session.user.email ?? ""; + if (!client) return null; + + const email = client.email ?? ""; const initials = email ? getInitials(email) : "?"; const handleLogout = async () => { - await supabase.auth.signOut(); + await logout.mutateAsync(); + navigate("/login", { replace: true }); }; return ( @@ -35,7 +38,7 @@ export function ClientLayout() { {email}
-
diff --git a/apps/clients/src/hooks/useClientPortal.ts b/apps/clients/src/hooks/useClientPortal.ts new file mode 100644 index 0000000..caa4f27 --- /dev/null +++ b/apps/clients/src/hooks/useClientPortal.ts @@ -0,0 +1,323 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + Etape, + KanbanTask, + KanbanTaskUpdate, + TabloFolder, + TaskStatus, + UserTablo, +} from "@xtablo/shared-types"; +import { clientApi } from "../lib/api"; + +type ClientTaskCreateInput = { + tablo_id: string; + title: string; + status?: TaskStatus | string; + parent_task_id?: string | null; + is_parent?: boolean; + position?: number; + description?: string | null; + assignee_id?: string | null; + due_date?: string | null; +}; + +export function useClientTablos() { + return useQuery({ + queryKey: ["client-portal", "tablos"], + queryFn: async () => { + const { data } = await clientApi.get<{ tablos: UserTablo[] }>("/api/v1/client-portal/tablos"); + return data.tablos ?? []; + }, + }); +} + +export function useClientTablo(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "tablo", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ tablo: UserTablo }>( + `/api/v1/client-portal/tablos/${tabloId}` + ); + return data.tablo; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloTasks(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "tasks", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ tasks: KanbanTask[] }>( + `/api/v1/client-portal/tablos/${tabloId}/tasks` + ); + return data.tasks ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloEtapes(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "etapes", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ etapes: Etape[] }>( + `/api/v1/client-portal/tablos/${tabloId}/etapes` + ); + return data.etapes ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloEvents(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "events", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ events: unknown[] }>( + `/api/v1/client-portal/tablos/${tabloId}/events` + ); + return data.events ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloMembers(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "members", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ + members: { + id: string; + name: string; + is_admin: boolean; + email: string; + avatar_url: string | null; + }[]; + }>(`/api/v1/client-portal/tablos/${tabloId}/members`); + return data.members ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloFiles(tabloId: string) { + return useQuery<{ fileNames: string[] }>({ + queryKey: ["client-portal", "files", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ fileNames: string[] }>( + `/api/v1/client-portal/tablos/${tabloId}/files` + ); + return data; + }, + enabled: Boolean(tabloId), + }); +} + +export function useClientTabloFolders(tabloId: string) { + return useQuery({ + queryKey: ["client-portal", "folders", tabloId], + queryFn: async () => { + const { data } = await clientApi.get<{ folders: TabloFolder[] }>( + `/api/v1/client-portal/tablos/${tabloId}/folders` + ); + return data.folders ?? []; + }, + enabled: Boolean(tabloId), + }); +} + +const invalidateClientFileQueries = (queryClient: ReturnType, tabloId: string) => { + queryClient.invalidateQueries({ queryKey: ["client-portal", "files", tabloId] }); + queryClient.invalidateQueries({ queryKey: ["client-portal", "folders", tabloId] }); +}; + +const invalidateClientTaskQueries = (queryClient: ReturnType, tabloId: string) => { + queryClient.invalidateQueries({ queryKey: ["client-portal", "tasks", tabloId] }); + queryClient.invalidateQueries({ queryKey: ["client-portal", "etapes", tabloId] }); +}; + +export function useClientCreateFile(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + fileName: string; + data: { content: string; contentType: string }; + }) => { + const { data } = await clientApi.post( + `/api/v1/client-portal/tablos/${params.tabloId}/file/${params.fileName}`, + params.data + ); + + return data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +export function useClientDownloadFile() { + return useMutation({ + mutationFn: async ({ tabloId, fileName }: { tabloId: string; fileName: string }) => { + const response = await clientApi.get<{ + content: string; + contentType?: string; + }>(`/api/v1/client-portal/tablos/${tabloId}/file/${fileName}`); + + const fileData = response.data; + let blob: Blob; + + if (fileData.content.startsWith("data:")) { + const fileResponse = await fetch(fileData.content); + blob = await fileResponse.blob(); + } else { + blob = new Blob([fileData.content], { + type: fileData.contentType || "application/octet-stream", + }); + } + + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }, + }); +} + +export function useClientCreateFolder(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + name: string; + description: string; + createdBy: string; + }) => { + const { data } = await clientApi.post(`/api/v1/client-portal/tablos/${params.tabloId}/folders`, { + description: params.description, + name: params.name, + }); + + return data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +export function useClientUpdateFolder(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { + tabloId: string; + folderId: string; + name: string; + description: string; + }) => { + const { data } = await clientApi.put( + `/api/v1/client-portal/tablos/${params.tabloId}/folders/${params.folderId}`, + { + description: params.description, + name: params.name, + } + ); + + return data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +export function useClientDeleteFolder(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: { tabloId: string; folderId: string; folderName: string }) => { + const { data } = await clientApi.delete( + `/api/v1/client-portal/tablos/${params.tabloId}/folders/${params.folderId}` + ); + + return data; + }, + onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), + }); +} + +export function useClientCreateTask(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (task: ClientTaskCreateInput) => { + const { data } = await clientApi.post<{ task: unknown }>( + `/api/v1/client-portal/tablos/${tabloId}/tasks`, + task + ); + + return data.task; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + +export function useClientUpdateTask(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + tablo_id: _tabloId, + ...updates + }: KanbanTaskUpdate & { id: string; tablo_id?: string }) => { + const { data } = await clientApi.patch<{ task: unknown }>( + `/api/v1/client-portal/tablos/${tabloId}/tasks/${id}`, + updates + ); + + return data.task; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + +export function useClientDeleteTask(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (taskId: string) => { + await clientApi.delete(`/api/v1/client-portal/tablos/${tabloId}/tasks/${taskId}`); + return taskId; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} + +export function useClientUpdateTaskPositions(tabloId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + updates: Array<{ + id: string; + position: number; + status?: TaskStatus; + parent_task_id?: string | null; + }> + ) => { + await Promise.all( + updates.map(({ id, ...taskUpdates }) => + clientApi.patch(`/api/v1/client-portal/tablos/${tabloId}/tasks/${id}`, taskUpdates) + ) + ); + + return updates; + }, + onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), + }); +} diff --git a/apps/clients/src/hooks/useClientSession.ts b/apps/clients/src/hooks/useClientSession.ts new file mode 100644 index 0000000..53c6945 --- /dev/null +++ b/apps/clients/src/hooks/useClientSession.ts @@ -0,0 +1,67 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Tables } from "@xtablo/shared-types"; +import { clientApi } from "../lib/api"; + +type ClientSessionResponse = { + client: Tables<"clients">; +}; + +export function useClientSession() { + return useQuery | null>({ + queryKey: ["client-session"], + queryFn: async () => { + try { + const { data } = await clientApi.get("/api/v1/client-auth/me"); + return data.client; + } catch (error) { + const status = + typeof error === "object" && + error !== null && + "response" in error && + typeof error.response === "object" && + error.response !== null && + "status" in error.response && + typeof error.response.status === "number" + ? error.response.status + : null; + + if (status === 401) { + return null; + } + + throw error; + } + }, + retry: false, + }); +} + +export function useRequestClientMagicLink() { + return useMutation({ + mutationFn: async ({ email, redirectTo }: { email: string; redirectTo: string }) => { + const { data } = await clientApi.post<{ + message: string; + success: boolean; + }>("/api/v1/client-auth/request-link", { + email, + redirectTo, + }); + + return data; + }, + }); +} + +export function useClientLogout() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + await clientApi.post("/api/v1/client-auth/logout"); + }, + onSuccess: () => { + queryClient.setQueryData(["client-session"], null); + queryClient.removeQueries({ queryKey: ["client-portal"] }); + }, + }); +} diff --git a/apps/clients/src/lib/api.ts b/apps/clients/src/lib/api.ts new file mode 100644 index 0000000..722d337 --- /dev/null +++ b/apps/clients/src/lib/api.ts @@ -0,0 +1,8 @@ +import { buildApi } from "@xtablo/shared"; + +const API_URL = import.meta.env.VITE_API_URL as string; + +export const clientApi = buildApi(API_URL); +if ("defaults" in clientApi && clientApi.defaults) { + clientApi.defaults.withCredentials = true; +} diff --git a/apps/clients/src/main.tsx b/apps/clients/src/main.tsx index 6d41477..8b42cc0 100644 --- a/apps/clients/src/main.tsx +++ b/apps/clients/src/main.tsx @@ -1,13 +1,11 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "@xtablo/shared"; -import { SessionProvider } from "@xtablo/shared/contexts/SessionContext"; import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext"; import { Toaster } from "@xtablo/ui/components/sonner"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter as Router } from "react-router-dom"; import App from "./App"; -import { supabase } from "./lib/supabase"; import "@xtablo/ui/styles/globals.css"; import "@xtablo/tablo-views/styles/tablo-details-shell.css"; @@ -18,14 +16,12 @@ import "./lib/rum"; createRoot(document.getElementById("client-root")!).render( - - - - - - - - + + + + + + ); diff --git a/apps/clients/src/pages/ClientTabloListPage.tsx b/apps/clients/src/pages/ClientTabloListPage.tsx index bbfff3d..f01f665 100644 --- a/apps/clients/src/pages/ClientTabloListPage.tsx +++ b/apps/clients/src/pages/ClientTabloListPage.tsx @@ -1,21 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; -import type { UserTablo } from "@xtablo/shared-types"; import { Link, Navigate } from "react-router-dom"; -import { supabase } from "../lib/supabase"; - -function useClientTablosList() { - return useQuery({ - queryKey: ["client-tablos-list"], - queryFn: async () => { - const { data, error } = await supabase.from("user_tablos").select("*"); - if (error) throw error; - return (data ?? []) as UserTablo[]; - }, - }); -} +import { useClientTablos } from "../hooks/useClientPortal"; export function ClientTabloListPage() { - const { data: tablos, isLoading } = useClientTablosList(); + const { data: tablos, isLoading } = useClientTablos(); if (isLoading) { return ( diff --git a/apps/clients/src/pages/ClientTabloPage.test.tsx b/apps/clients/src/pages/ClientTabloPage.test.tsx index b687b51..4a1a53d 100644 --- a/apps/clients/src/pages/ClientTabloPage.test.tsx +++ b/apps/clients/src/pages/ClientTabloPage.test.tsx @@ -6,12 +6,9 @@ import { ClientTabloPage } from "./ClientTabloPage"; const { apiGetMock, apiPostMock, + apiPatchMock, apiPutMock, apiDeleteMock, - updateTaskMock, - insertTaskMock, - deleteTaskMock, - supabaseFromMock, } = vi.hoisted(() => { const apiGetMock = vi.fn(async (url: string) => { if (url.endsWith("/brief.pdf")) { @@ -32,45 +29,22 @@ const { folder: { id: "folder-1", name: "Livrable", description: "" }, }, })); + const apiPatchMock = vi.fn(async () => ({ + status: 200, + data: { task: { id: "task-1" } }, + })); const apiPutMock = vi.fn(async () => ({ status: 200, data: { folder: { id: "folder-1", name: "Livrable mis à jour", description: "Desc" } }, })); const apiDeleteMock = vi.fn(async () => ({ status: 200, data: { message: "ok" } })); - const createUpdateBuilder = () => { - const builder = { - error: null as null, - eq: vi.fn(() => builder), - select: vi.fn(() => ({ - single: async () => ({ data: { id: "task-1" }, error: null }), - })), - }; - return builder; - }; - const updateTaskMock = vi.fn(() => createUpdateBuilder()); - const insertTaskMock = vi.fn(() => ({ - select: () => ({ - single: async () => ({ data: { id: "task-created" }, error: null }), - }), - })); - const deleteTaskMock = vi.fn(() => ({ - eq: vi.fn(async () => ({ error: null })), - })); - const supabaseFromMock = vi.fn(() => ({ - insert: insertTaskMock, - update: updateTaskMock, - delete: deleteTaskMock, - })); return { apiGetMock, apiPostMock, + apiPatchMock, apiPutMock, apiDeleteMock, - updateTaskMock, - insertTaskMock, - deleteTaskMock, - supabaseFromMock, }; }); let latestTabloTasksSectionProps: Record | null = null; @@ -84,32 +58,102 @@ vi.mock("@xtablo/shared", async (importOriginal) => { return { ...actual, buildApi: () => ({ - create: () => ({ - get: apiGetMock, - post: apiPostMock, - put: apiPutMock, - delete: apiDeleteMock, - }), + defaults: {}, + delete: apiDeleteMock, + get: apiGetMock, + patch: apiPatchMock, + post: apiPostMock, + put: apiPutMock, }), }; }); -vi.mock("../lib/supabase", () => ({ - supabase: { - from: supabaseFromMock, - }, -})); - vi.mock("@tanstack/react-query", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useQuery: ({ queryKey, queryFn }: { queryKey: string[]; queryFn?: () => Promise }) => { - if (queryKey[0] === "client-tablo-folders" && queryFn) { + if (queryKey[0] === "client-portal" && queryKey[1] === "folders" && queryFn) { void queryFn(); } switch (queryKey[0]) { + case "client-session": + return { + data: { + id: "client-user-1", + email: "client@example.com", + }, + isLoading: false, + error: null, + }; + case "client-portal": + if (queryKey[1] === "tablo") { + return { + data: { + id: "tablo-1", + name: "Client Project", + color: "bg-blue-500", + image: null, + created_at: "2026-01-01T00:00:00.000Z", + deleted_at: null, + position: 0, + status: "todo", + user_id: "user-1", + is_admin: false, + access_level: "guest", + }, + isLoading: false, + }; + } + if (queryKey[1] === "tasks") { + return { + data: [ + { + id: "task-1", + title: "Prepare proposal", + status: "todo", + tablo_id: "tablo-1", + assignee_id: "client-user-1", + }, + ], + isLoading: false, + error: null, + }; + } + if (queryKey[1] === "etapes") { + return { + data: [ + { + id: "etape-1", + title: "Kickoff", + status: "in_progress", + position: 0, + }, + ], + isLoading: false, + error: null, + }; + } + if (queryKey[1] === "events" || queryKey[1] === "members" || queryKey[1] === "folders") { + return { + data: [], + isLoading: false, + error: null, + }; + } + if (queryKey[1] === "files") { + return { + data: { fileNames: [] }, + isLoading: false, + error: null, + }; + } + return { + data: undefined, + isLoading: false, + error: null, + }; case "client-tablo": return { data: { @@ -127,47 +171,6 @@ vi.mock("@tanstack/react-query", async (importOriginal) => { }, isLoading: false, }; - case "client-tasks": - return { - data: [ - { - id: "task-1", - title: "Prepare proposal", - status: "todo", - tablo_id: "tablo-1", - assignee_id: "client-user-1", - }, - ], - isLoading: false, - error: null, - }; - case "client-etapes": - return { - data: [ - { - id: "etape-1", - title: "Kickoff", - status: "in_progress", - position: 0, - }, - ], - isLoading: false, - error: null, - }; - case "client-events": - case "client-members": - case "client-tablo-folders": - return { - data: [], - isLoading: false, - error: null, - }; - case "client-tablo-files": - return { - data: { fileNames: [] }, - isLoading: false, - error: null, - }; default: return { data: undefined, @@ -416,25 +419,22 @@ describe("ClientTabloPage parity shell", () => { HTMLAnchorElement.prototype.click = vi.fn(); apiGetMock.mockClear(); apiPostMock.mockClear(); + apiPatchMock.mockClear(); apiPutMock.mockClear(); apiDeleteMock.mockClear(); - updateTaskMock.mockClear(); - insertTaskMock.mockClear(); - deleteTaskMock.mockClear(); - supabaseFromMock.mockClear(); latestTabloTasksSectionProps = null; latestEtapesSectionProps = null; latestRoadmapSectionProps = null; latestTabloFilesSectionProps = null; }); - it("requests folders from the tablo-data API route", () => { + it("requests folders from the client-portal API route", () => { renderWithProviders(, { route: "/tablo/tablo-1", path: "/tablo/:tabloId", }); - expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders"); + expect(apiGetMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders"); }); it("wires real task mutation callbacks throughout the client task surfaces", async () => { @@ -470,36 +470,44 @@ describe("ClientTabloPage parity shell", () => { await user.click(screen.getByRole("button", { name: "Changer statut roadmap test" })); await waitFor(() => { - expect(supabaseFromMock).toHaveBeenCalledWith("tasks"); - expect(insertTaskMock).toHaveBeenCalledTimes(2); - expect(insertTaskMock).toHaveBeenCalledWith( + expect(apiPostMock).toHaveBeenCalledTimes(2); + expect(apiPostMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks", expect.objectContaining({ + is_parent: false, + parent_task_id: "etape-1", + position: 0, + status: "todo", tablo_id: "tablo-1", title: "Task from etape", - status: "todo", - assignee_id: null, - position: 0, - parent_task_id: "etape-1", - is_parent: false, - description: null, - due_date: null, }) ); - expect(updateTaskMock).toHaveBeenCalledWith({ title: "Updated task title" }); - expect(updateTaskMock).toHaveBeenCalledWith({ position: 7, status: "done" }); - expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" }); - expect(deleteTaskMock).toHaveBeenCalledTimes(1); + expect(apiPatchMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1", + { title: "Updated task title" } + ); + expect(apiPatchMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1", + { position: 7, status: "done" } + ); + expect(apiPatchMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1", + { status: "done" } + ); + expect(apiDeleteMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1" + ); }); }); - it("renders the main-route style header metadata and discussion CTA", () => { + it("renders the main-route style header metadata without the legacy discussion CTA", () => { renderWithProviders(, { route: "/tablo/tablo-1", path: "/tablo/:tabloId", }); expect(screen.getByText("Client Project")).toBeInTheDocument(); - expect(screen.getAllByRole("button", { name: "Discussion" })).toHaveLength(2); + expect(screen.queryByRole("button", { name: "Discussion" })).not.toBeInTheDocument(); expect(screen.getAllByText("Rôle").length).toBeGreaterThan(0); expect(screen.getAllByText("Créé le").length).toBeGreaterThan(0); expect(screen.getAllByText("Progression").length).toBeGreaterThan(0); @@ -553,7 +561,10 @@ describe("ClientTabloPage parity shell", () => { await user.click(screen.getByRole("button", { name: "Prepare proposal" })); await waitFor(() => { - expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" }); + expect(apiPatchMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/tasks/task-1", + { status: "done" } + ); }); }); @@ -580,20 +591,22 @@ describe("ClientTabloPage parity shell", () => { await user.click(screen.getByRole("button", { name: "Supprimer livrable test" })); await waitFor(() => { - expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/file/brief.pdf", { + expect(apiPostMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/file/brief.pdf", { content: "data:application/pdf;base64,AAAA", contentType: "application/pdf", }); - expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/brief.pdf"); - expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders", { + expect(apiGetMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/file/brief.pdf"); + expect(apiPostMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders", { name: "Livrable", description: "Desc", }); - expect(apiPutMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1", { + expect(apiPutMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders/folder-1", { name: "Livrable mis à jour", description: "Desc", }); - expect(apiDeleteMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1"); + expect(apiDeleteMock).toHaveBeenCalledWith( + "/api/v1/client-portal/tablos/tablo-1/folders/folder-1" + ); }); }); }); diff --git a/apps/clients/src/pages/ClientTabloPage.tsx b/apps/clients/src/pages/ClientTabloPage.tsx index 2ff728d..8d7996a 100644 --- a/apps/clients/src/pages/ClientTabloPage.tsx +++ b/apps/clients/src/pages/ClientTabloPage.tsx @@ -1,20 +1,10 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { buildApi, cn } from "@xtablo/shared"; -import { useSession } from "@xtablo/shared/contexts/SessionContext"; -import type { - Etape, - KanbanTask, - KanbanTaskUpdate, - TabloFolder, - TaskStatus, - UserTablo, -} from "@xtablo/shared-types"; +import { cn } from "@xtablo/shared"; +import type { Etape, TaskStatus } from "@xtablo/shared-types"; import { EtapesSection, RoadmapSection, type SingleTabloTabId, SingleTabloView, - TabloDiscussionSection, TabloEventsSection, TabloFilesSection, TabloTasksSection, @@ -22,384 +12,25 @@ import { import { FolderIcon } from "lucide-react"; import { useState } from "react"; import { useParams } from "react-router-dom"; -import { supabase } from "../lib/supabase"; - -const API_URL = import.meta.env.VITE_API_URL as string; - -// ─── Local hooks ────────────────────────────────────────────────────────────── - -function useAuthedApi(accessToken: string | undefined) { - return buildApi(API_URL).create({ - headers: { - Authorization: `Bearer ${accessToken ?? ""}`, - }, - }); -} - -function useClientTablo(tabloId: string) { - return useQuery({ - queryKey: ["client-tablo", tabloId], - queryFn: async () => { - const { data, error } = await supabase - .from("user_tablos") - .select("*") - .eq("id", tabloId) - .single(); - if (error) throw error; - return data as UserTablo; - }, - enabled: !!tabloId, - }); -} - -function useClientTabloTasks(tabloId: string) { - return useQuery({ - queryKey: ["client-tasks", tabloId], - queryFn: async () => { - const { data, error } = await supabase - .from("tasks_with_assignee") - .select("*") - .eq("tablo_id", tabloId) - .eq("is_parent", false) - .order("updated_at", { ascending: false }); - if (error) throw error; - return (data ?? []) as KanbanTask[]; - }, - enabled: !!tabloId, - }); -} - -function useClientTabloEtapes(tabloId: string) { - return useQuery({ - queryKey: ["client-etapes", tabloId], - queryFn: async () => { - const { data, error } = await supabase - .from("tasks") - .select("*") - .eq("tablo_id", tabloId) - .eq("is_parent", true) - .order("position", { ascending: true }); - if (error) throw error; - return (data ?? []) as Etape[]; - }, - enabled: !!tabloId, - }); -} - -function useClientTabloEvents(tabloId: string) { - return useQuery({ - queryKey: ["client-events", tabloId], - queryFn: async () => { - const { data, error } = await supabase - .from("events_and_tablos") - .select("*") - .eq("tablo_id", tabloId) - .order("start_date", { ascending: false }); - if (error) throw error; - return data ?? []; - }, - enabled: !!tabloId, - }); -} - -function useClientTabloMembers(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - return useQuery({ - queryKey: ["client-members", tabloId], - queryFn: async () => { - const { data } = await api.get<{ - members: { - id: string; - name: string; - is_admin: boolean; - email: string; - avatar_url: string | null; - }[]; - }>(`/api/v1/tablos/members/${tabloId}`); - return data.members; - }, - enabled: !!tabloId && !!accessToken, - }); -} - -function useClientTabloFiles(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - return useQuery<{ fileNames: string[] }>({ - queryKey: ["client-tablo-files", tabloId], - queryFn: async () => { - const { data } = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`); - return data as { fileNames: string[] }; - }, - enabled: !!tabloId && !!accessToken, - }); -} - -function useClientTabloFolders(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - return useQuery({ - queryKey: ["client-tablo-folders", tabloId], - queryFn: async () => { - const { data } = await api.get<{ folders: TabloFolder[] }>( - `/api/v1/tablo-data/${tabloId}/folders` - ); - return data.folders ?? []; - }, - enabled: !!tabloId && !!accessToken, - }); -} - -const invalidateClientFileQueries = ( - queryClient: ReturnType, - tabloId: string -) => { - queryClient.invalidateQueries({ queryKey: ["client-tablo-files", tabloId] }); - queryClient.invalidateQueries({ queryKey: ["client-tablo-folders", tabloId] }); -}; - -function useClientCreateFile(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (params: { - tabloId: string; - fileName: string; - data: { content: string; contentType: string }; - }) => { - const response = await api.post( - `/api/v1/tablo-data/${params.tabloId}/file/${params.fileName}`, - params.data - ); - if (response.status !== 200) { - throw new Error("Failed to create file"); - } - return response.data; - }, - onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), - }); -} - -function useClientDownloadFile(accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - - return useMutation({ - mutationFn: async ({ tabloId, fileName }: { tabloId: string; fileName: string }) => { - const response = await api.get(`/api/v1/tablo-data/${tabloId}/${fileName}`); - if (response.status !== 200) { - throw new Error("Failed to download file"); - } - - const fileData = response.data as { content: string; contentType?: string }; - let blob: Blob; - - if (fileData.content.startsWith("data:")) { - const fileResponse = await fetch(fileData.content); - blob = await fileResponse.blob(); - } else { - blob = new Blob([fileData.content], { - type: fileData.contentType || "application/octet-stream", - }); - } - - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - }, - }); -} - -function useClientCreateFolder(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (params: { - tabloId: string; - name: string; - description: string; - createdBy: string; - }) => { - const response = await api.post(`/api/v1/tablo-data/${params.tabloId}/folders`, { - name: params.name, - description: params.description, - }); - if (response.status !== 200) { - throw new Error("Failed to create folder"); - } - return response.data; - }, - onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), - }); -} - -function useClientUpdateFolder(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (params: { - tabloId: string; - folderId: string; - name: string; - description: string; - }) => { - const response = await api.put( - `/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}`, - { - name: params.name, - description: params.description, - } - ); - if (response.status !== 200) { - throw new Error("Failed to update folder"); - } - return response.data; - }, - onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), - }); -} - -function useClientDeleteFolder(tabloId: string, accessToken: string | undefined) { - const api = useAuthedApi(accessToken); - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (params: { tabloId: string; folderId: string; folderName: string }) => { - const response = await api.delete( - `/api/v1/tablo-data/${params.tabloId}/folders/${params.folderId}` - ); - if (response.status !== 200) { - throw new Error("Failed to delete folder"); - } - return response.data; - }, - onSuccess: () => invalidateClientFileQueries(queryClient, tabloId), - }); -} - -type ClientTaskCreateInput = { - tablo_id: string; - title: string; - status?: TaskStatus | string; - parent_task_id?: string | null; - is_parent?: boolean; - position?: number; - description?: string | null; - assignee_id?: string | null; - due_date?: string | null; -}; - -const invalidateClientTaskQueries = ( - queryClient: ReturnType, - tabloId: string -) => { - queryClient.invalidateQueries({ queryKey: ["client-tasks", tabloId] }); -}; - -function useClientCreateTask(tabloId: string) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (task: ClientTaskCreateInput) => { - const { data, error } = await supabase - .from("tasks") - .insert({ - tablo_id: task.tablo_id, - title: task.title, - status: (task.status as TaskStatus | undefined) ?? "todo", - assignee_id: task.assignee_id ?? null, - position: task.position ?? 0, - parent_task_id: task.parent_task_id ?? null, - is_parent: task.is_parent ?? false, - description: task.description ?? null, - due_date: task.due_date ?? null, - }) - .select() - .single(); - - if (error) throw error; - return data; - }, - onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), - }); -} - -function useClientUpdateTask(tabloId: string) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - id, - tablo_id: _tabloId, - ...updates - }: KanbanTaskUpdate & { id: string; tablo_id?: string }) => { - const { data, error } = await supabase - .from("tasks") - .update(updates) - .eq("id", id) - .select() - .single(); - - if (error) throw error; - return data; - }, - onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), - }); -} - -function useClientDeleteTask(tabloId: string) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (taskId: string) => { - const { error } = await supabase.from("tasks").delete().eq("id", taskId); - if (error) throw error; - return taskId; - }, - onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), - }); -} - -function useClientUpdateTaskPositions(tabloId: string) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ( - updates: Array<{ - id: string; - position: number; - status?: TaskStatus; - parent_task_id?: string | null; - }> - ) => { - const results = await Promise.all( - updates.map(({ id, position, status, parent_task_id }) => - supabase - .from("tasks") - .update({ - position, - ...(status && { status }), - ...(parent_task_id !== undefined ? { parent_task_id } : {}), - }) - .eq("id", id) - ) - ); - - const errors = results.filter((result) => result.error); - if (errors.length > 0) { - throw new Error("Failed to update some task positions"); - } - - return updates; - }, - onSuccess: () => invalidateClientTaskQueries(queryClient, tabloId), - }); -} +import { + useClientCreateFile, + useClientCreateFolder, + useClientCreateTask, + useClientDeleteFolder, + useClientDeleteTask, + useClientDownloadFile, + useClientTablo, + useClientTabloEtapes, + useClientTabloEvents, + useClientTabloFiles, + useClientTabloFolders, + useClientTabloMembers, + useClientTabloTasks, + useClientUpdateFolder, + useClientUpdateTask, + useClientUpdateTaskPositions, +} from "../hooks/useClientPortal"; +import { useClientSession } from "../hooks/useClientSession"; function getStatusConfig(status: string) { switch (status) { @@ -444,16 +75,12 @@ function getEtapeProgressStats(etapes: Etape[]) { }; } -// ─── Page ───────────────────────────────────────────────────────────────────── - export function ClientTabloPage() { const { tabloId } = useParams<{ tabloId: string }>(); - const { session } = useSession(); const [activeTab, setActiveTab] = useState("overview"); + const { data: client } = useClientSession(); - const accessToken = session?.access_token; - const currentUserId = session?.user.id ?? ""; - + const currentUserId = client?.id ?? ""; const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? ""); const { data: tasks = [] } = useClientTabloTasks(tabloId ?? ""); const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? ""); @@ -462,29 +89,28 @@ export function ClientTabloPage() { isLoading: eventsLoading, error: eventsError, } = useClientTabloEvents(tabloId ?? ""); - const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken); + const { data: members = [] } = useClientTabloMembers(tabloId ?? ""); const { data: filesData, isLoading: filesLoading, error: filesError, - } = useClientTabloFiles(tabloId ?? "", accessToken); + } = useClientTabloFiles(tabloId ?? ""); const { data: folders = [], isLoading: foldersLoading, error: foldersError, - } = useClientTabloFolders(tabloId ?? "", accessToken); + } = useClientTabloFolders(tabloId ?? ""); const { mutate: createTask } = useClientCreateTask(tabloId ?? ""); const { mutate: updateTask } = useClientUpdateTask(tabloId ?? ""); const { mutate: deleteTask } = useClientDeleteTask(tabloId ?? ""); const { mutate: updateTaskPositions } = useClientUpdateTaskPositions(tabloId ?? ""); - const { mutateAsync: createFile } = useClientCreateFile(tabloId ?? "", accessToken); - const { mutateAsync: downloadFile } = useClientDownloadFile(accessToken); - const { mutateAsync: createFolder } = useClientCreateFolder(tabloId ?? "", accessToken); - const { mutateAsync: updateFolder } = useClientUpdateFolder(tabloId ?? "", accessToken); - const { mutateAsync: deleteFolder } = useClientDeleteFolder(tabloId ?? "", accessToken); - - const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith(".")); + const { mutateAsync: createFile } = useClientCreateFile(tabloId ?? ""); + const { mutateAsync: downloadFile } = useClientDownloadFile(); + const { mutateAsync: createFolder } = useClientCreateFolder(tabloId ?? ""); + const { mutateAsync: updateFolder } = useClientUpdateFolder(tabloId ?? ""); + const { mutateAsync: deleteFolder } = useClientDeleteFolder(tabloId ?? ""); + const fileNames = (filesData?.fileNames ?? []).filter((fileName) => !fileName.startsWith(".")); const currentUser = { id: currentUserId, avatar_url: null }; if (tabloLoading) { @@ -515,7 +141,7 @@ export function ClientTabloPage() { progress={progress} activeTab={activeTab} onTabChange={setActiveTab} - discussionAction={{ kind: "button", onClick: () => setActiveTab("discussion") }} + hiddenTabs={["discussion"]} > {activeTab === "overview" && (
@@ -682,15 +308,6 @@ export function ClientTabloPage() { /> )} - {activeTab === "discussion" && ( - - )} - {activeTab === "events" && ( undefined} - onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })} + onTaskStatusChange={(taskId, status) => + updateTask({ id: taskId, status: status as TaskStatus }) + } /> )} diff --git a/apps/clients/src/pages/LoginPage.test.tsx b/apps/clients/src/pages/LoginPage.test.tsx index 47f77d7..7f75f9a 100644 --- a/apps/clients/src/pages/LoginPage.test.tsx +++ b/apps/clients/src/pages/LoginPage.test.tsx @@ -1,19 +1,12 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../test/testHelpers"; +import * as clientSessionHooks from "../hooks/useClientSession"; import { LoginPage } from "./LoginPage"; -const { mockSignInWithPassword, mockNavigate } = vi.hoisted(() => ({ - mockSignInWithPassword: vi.fn(), +const { mockNavigate, mockRequestMagicLink } = vi.hoisted(() => ({ mockNavigate: vi.fn(), -})); - -vi.mock("../lib/supabase", () => ({ - supabase: { - auth: { - signInWithPassword: mockSignInWithPassword, - }, - }, + mockRequestMagicLink: vi.fn(), })); vi.mock("react-router-dom", async (importOriginal) => { @@ -28,9 +21,16 @@ describe("LoginPage", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); - mockSignInWithPassword.mockResolvedValue({ - data: { user: { email_confirmed_at: new Date().toISOString() } }, - error: null, + vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({ + data: null, + } as ReturnType); + vi.spyOn(clientSessionHooks, "useRequestClientMagicLink").mockReturnValue({ + isPending: false, + mutateAsync: mockRequestMagicLink, + } as unknown as ReturnType); + mockRequestMagicLink.mockResolvedValue({ + message: "If this email can access the client portal, a connection link has been sent.", + success: true, }); }); @@ -39,28 +39,27 @@ describe("LoginPage", () => { expect(screen.getByTestId("auth-card-shell")).toBeInTheDocument(); expect(screen.getByLabelText("Email")).toBeInTheDocument(); - expect(screen.getByLabelText("Mot de passe")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Connexion" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Recevoir un lien de connexion" })).toBeInTheDocument(); expect(screen.getAllByAltText("Xtablo")[0]).toHaveAttribute( "src", "https://assets.xtablo.com/logo_dark.png" ); }); - it("submits email/password login and resumes the stored redirect", async () => { + it("requests a magic link and forwards the stored redirect path", async () => { localStorage.setItem("clients.redirectUrl", "/tablo/tablo-42"); renderWithProviders(, { testUser: undefined }); fireEvent.change(screen.getByLabelText("Email"), { target: { value: "client@example.com" } }); - fireEvent.change(screen.getByLabelText("Mot de passe"), { target: { value: "password123" } }); - fireEvent.click(screen.getByRole("button", { name: "Connexion" })); + fireEvent.click(screen.getByRole("button", { name: "Recevoir un lien de connexion" })); await waitFor(() => { - expect(mockSignInWithPassword).toHaveBeenCalledWith({ + expect(mockRequestMagicLink).toHaveBeenCalledWith({ email: "client@example.com", - password: "password123", + redirectTo: "/tablo/tablo-42", }); - expect(mockNavigate).toHaveBeenCalledWith("/tablo/tablo-42"); }); + + expect(screen.getByText(/connection link has been sent/i)).toBeInTheDocument(); }); }); diff --git a/apps/clients/src/pages/LoginPage.tsx b/apps/clients/src/pages/LoginPage.tsx index f38f582..2b2c12e 100644 --- a/apps/clients/src/pages/LoginPage.tsx +++ b/apps/clients/src/pages/LoginPage.tsx @@ -1,54 +1,51 @@ -import { AuthCardShell, AuthEmailPasswordForm, AuthInfoBanner } from "@xtablo/auth-ui"; -import { useSession } from "@xtablo/shared/contexts/SessionContext"; -import { useState } from "react"; +import { AuthCardShell, AuthInfoBanner } from "@xtablo/auth-ui"; +import { Button } from "@xtablo/ui/components/button"; +import { Input } from "@xtablo/ui/components/input"; +import { Label } from "@xtablo/ui/components/label"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router-dom"; -import { supabase } from "../lib/supabase"; +import { useNavigate } from "react-router-dom"; +import { useClientSession, useRequestClientMagicLink } from "../hooks/useClientSession"; export function LoginPage() { const { t } = useTranslation(["auth", "common"]); - const { session } = useSession(); + const { data: client } = useClientSession(); + const requestMagicLink = useRequestClientMagicLink(); const navigate = useNavigate(); const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [isPending, setIsPending] = useState(false); const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + useEffect(() => { + if (!client) return; - if (session) { const redirectUrl = localStorage.getItem("clients.redirectUrl"); if (redirectUrl) { localStorage.removeItem("clients.redirectUrl"); - navigate(redirectUrl); - } else { - navigate("/"); - } - } - - const onSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setIsPending(true); - setError(null); - - const { error: signInError } = await supabase.auth.signInWithPassword({ - email, - password, - }); - - if (signInError) { - setError(signInError.message); - setIsPending(false); + navigate(redirectUrl, { replace: true }); return; } - const redirectUrl = localStorage.getItem("clients.redirectUrl"); - if (redirectUrl) { - localStorage.removeItem("clients.redirectUrl"); - navigate(redirectUrl); - } else { - navigate("/"); + navigate("/", { replace: true }); + }, [client, navigate]); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setSuccessMessage(null); + + try { + const redirectTo = localStorage.getItem("clients.redirectUrl") || "/"; + const result = await requestMagicLink.mutateAsync({ email, redirectTo }); + setSuccessMessage(result.message); + } catch (requestError) { + const message = requestError instanceof Error ? requestError.message : "Connexion impossible"; + setError(message); } }; + const isPending = requestMagicLink.isPending; + return (
{error ? : null} + {successMessage ? : null} - - - {t("auth:login.forgotPassword")} - -
- } - /> +
+
+ + setEmail(event.target.value)} + placeholder={t("auth:login.emailPlaceholder")} + autoComplete="email" + required + /> +
+ + +
); diff --git a/apps/clients/src/routes.tsx b/apps/clients/src/routes.tsx index e10531f..74fb526 100644 --- a/apps/clients/src/routes.tsx +++ b/apps/clients/src/routes.tsx @@ -1,20 +1,14 @@ import { Route, Routes } from "react-router-dom"; import { ClientAuthGate } from "./components/ClientAuthGate"; import { ClientLayout } from "./components/ClientLayout"; -import { AuthCallback } from "./pages/AuthCallback"; import { ClientTabloListPage } from "./pages/ClientTabloListPage"; import { ClientTabloPage } from "./pages/ClientTabloPage"; import { LoginPage } from "./pages/LoginPage"; -import { ResetPasswordPage } from "./pages/ResetPasswordPage"; -import { SetPasswordPage } from "./pages/SetPasswordPage"; export default function AppRoutes() { return ( } /> - } /> - } /> - } /> }> }> } /> diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index 36d49fd..d1fa778 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -156,6 +156,7 @@ export type Database = { jti: string | null; purpose: string; redirect_to: string | null; + tablo_id: string | null; token_hash: string | null; }; Insert: { @@ -169,6 +170,7 @@ export type Database = { jti?: string | null; purpose: string; redirect_to?: string | null; + tablo_id?: string | null; token_hash?: string | null; }; Update: { @@ -182,6 +184,7 @@ export type Database = { jti?: string | null; purpose?: string; redirect_to?: string | null; + tablo_id?: string | null; token_hash?: string | null; }; Relationships: [ @@ -199,6 +202,27 @@ export type Database = { referencedRelation: "profiles"; referencedColumns: ["id"]; }, + { + foreignKeyName: "client_magic_links_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_magic_links_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "events_and_tablos"; + referencedColumns: ["tablo_id"]; + }, + { + foreignKeyName: "client_magic_links_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "user_tablos"; + referencedColumns: ["id"]; + }, ]; }; clients: { diff --git a/packages/tablo-views/src/single-tablo/SingleTabloView.tsx b/packages/tablo-views/src/single-tablo/SingleTabloView.tsx index e95bc48..190c17c 100644 --- a/packages/tablo-views/src/single-tablo/SingleTabloView.tsx +++ b/packages/tablo-views/src/single-tablo/SingleTabloView.tsx @@ -49,6 +49,7 @@ interface SingleTabloViewProps { }; activeTab: SingleTabloTabId; onTabChange: (tabId: SingleTabloTabId) => void; + hiddenTabs?: SingleTabloTabId[]; hasUnreadDiscussion?: boolean; discussionAction?: DiscussionAction; canInviteMembers?: boolean; @@ -64,6 +65,7 @@ export function SingleTabloView({ progress, activeTab, onTabChange, + hiddenTabs = [], hasUnreadDiscussion = false, discussionAction, canInviteMembers = false, @@ -119,7 +121,7 @@ export function SingleTabloView({ }, ]; - const tabs = TABS.map((tab) => + const tabs = TABS.filter((tab) => !hiddenTabs.includes(tab.id as SingleTabloTabId)).map((tab) => tab.id === "discussion" ? { ...tab, hasUnread: hasUnreadDiscussion } : tab ); diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..7b148bb --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,384 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "xtablo-source" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = false +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/migrations/20260430120000_drop_is_temporary.sql b/supabase/migrations/20260430120000_drop_is_temporary.sql index 2b1279a..9f8db47 100644 --- a/supabase/migrations/20260430120000_drop_is_temporary.sql +++ b/supabase/migrations/20260430120000_drop_is_temporary.sql @@ -1 +1,86 @@ -ALTER TABLE public.profiles DROP COLUMN IF EXISTS is_temporary; +DROP TRIGGER IF EXISTS enforce_non_temporary_on_paid_plan ON public.profiles; +DROP FUNCTION IF EXISTS public.enforce_non_temporary_on_paid_plan(); + +ALTER TABLE public.profiles + DROP CONSTRAINT IF EXISTS profiles_no_temporary_on_paid_plan; + +ALTER TABLE public.profiles + DROP COLUMN IF EXISTS is_temporary; + +CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER +AS $$ + DECLARE + name TEXT; + first_name TEXT; + last_name TEXT; + is_invited_user BOOLEAN; + email_prefix TEXT; + assigned_plan public.subscription_plan := 'none'; + BEGIN + -- Extract first_name and last_name from metadata + first_name = NEW.raw_user_meta_data ->> 'first_name'; + last_name = NEW.raw_user_meta_data ->> 'last_name'; + + -- If first_name is not provided, extract it from email (part before @) + IF first_name IS NULL OR first_name = '' THEN + email_prefix = SPLIT_PART(NEW.email, '@', 1); + first_name = email_prefix; + END IF; + + -- Determine the full name + IF NEW.raw_user_meta_data ->> 'name' IS NOT NULL + THEN + name = NEW.raw_user_meta_data ->> 'name'; + -- If name is provided but not first/last, try to split it + IF first_name IS NULL AND last_name IS NULL AND name IS NOT NULL THEN + first_name = SPLIT_PART(name, ' ', 1); + IF ARRAY_LENGTH(STRING_TO_ARRAY(name, ' '), 1) > 1 THEN + last_name = SUBSTRING(name FROM LENGTH(SPLIT_PART(name, ' ', 1)) + 2); + END IF; + END IF; + ELSE + name = CONCAT(first_name, ' ', last_name); + END IF; + + is_invited_user := COALESCE(NEW.raw_user_meta_data->>'role', '') = 'invited_user'; + + -- Preserve previous behavior: invited users do not get an automatic free plan. + IF NOT is_invited_user AND public.is_freemium_available() THEN + assigned_plan := 'free'; + END IF; + + INSERT INTO public.profiles (id, name, email, avatar_url, first_name, last_name, plan) + VALUES (NEW.id, name, NEW.email, NEW.raw_user_meta_data ->> 'avatar_url', first_name, last_name, assigned_plan); + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.handle_new_user() IS +'Trigger function that creates a profile when a new user is created. Extracts first name from email when missing and assigns the free plan while freemium is available, except for invited users.'; + +ALTER FUNCTION public.handle_new_user() OWNER TO postgres; + +CREATE OR REPLACE FUNCTION public.update_tablo_invites_on_login() RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER +AS $$ + BEGIN + IF (NEW.last_sign_in_at IS NULL OR NEW.last_sign_in_at = OLD.last_sign_in_at) THEN + RETURN NULL; + ELSE + -- After removing profiles.is_temporary, use the auth metadata role to + -- preserve the previous invited-user-only invite-consumption behavior. + UPDATE public.tablo_invites + SET is_pending = FALSE + WHERE invited_email = NEW.email + AND is_pending = TRUE + AND COALESCE(NEW.raw_user_meta_data->>'role', '') = 'invited_user'; + RETURN NEW; + END IF; + END; +$$; + +ALTER FUNCTION public.update_tablo_invites_on_login() OWNER TO postgres; diff --git a/supabase/migrations/20260501100000_create_client_auth_tables.sql b/supabase/migrations/20260501100000_create_client_auth_tables.sql index 76fb653..b315b7e 100644 --- a/supabase/migrations/20260501100000_create_client_auth_tables.sql +++ b/supabase/migrations/20260501100000_create_client_auth_tables.sql @@ -38,6 +38,7 @@ create table if not exists public.client_magic_links ( client_id uuid not null references public.clients(id) on delete cascade, email text not null, purpose text not null check (purpose in ('invite', 'login')), + tablo_id text references public.tablos(id) on delete cascade, token_hash text, jti text, redirect_to text, @@ -54,6 +55,9 @@ create index if not exists client_magic_links_active_idx on public.client_magic_links (client_id, purpose, expires_at) where consumed_at is null; +create index if not exists client_magic_links_tablo_id_idx + on public.client_magic_links (tablo_id); + create unique index if not exists client_magic_links_jti_unique_idx on public.client_magic_links (jti) where jti is not null;