feat: migrate client portal to magic link auth

This commit is contained in:
Arthur Belleville 2026-05-01 10:11:08 +02:00
parent 06e1114cf8
commit 2cf5eb8789
No known key found for this signature in database
33 changed files with 2697 additions and 957 deletions

View file

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

View file

@ -2,6 +2,8 @@ import { createClient } from "@supabase/supabase-js";
import { testClient } from "hono/testing"; import { testClient } from "hono/testing";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createConfig } from "../../config.js"; 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 { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js"; import { getMainRouter } from "../../routers/index.js";
import type { TestUserData } from "../helpers/dbSetup.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); MiddlewareManager.initialize(config);
const app = getMainRouter(config); const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access // 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 ownerUser = getTestUser("owner");
const tempUser = getTestUser("temp"); 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") // The owner has admin access to this tablo (created via TEST_TABLOS with owner_key: "owner")
const adminTabloId = "test_tablo_owner_private"; const adminTabloId = "test_tablo_owner_private";
@ -102,6 +107,49 @@ describe("Client Invites Endpoints", () => {
return data.id as number; 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) => { const cleanupInvitesByEmail = async (email: string) => {
await supabaseAdmin.from("client_invites").delete().eq("invited_email", email); 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 () => { it("rejects emails already used by a main-app account", async () => {
const res = await postInvite(ownerUser, adminTabloId, ownerUser.email); const res = await postInvite(ownerUser, adminTabloId, ownerUser.email);
@ -335,7 +338,7 @@ describe("Client Invites Endpoints", () => {
it("rejects temporary users before admin check", async () => { it("rejects temporary users before admin check", async () => {
const res = await postInvite(tempUser, adminTabloId, testEmail); 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 () => { it("returns 400 for an invalid email", async () => {
@ -472,12 +475,13 @@ describe("Client Invites Endpoints", () => {
beforeEach(async () => { beforeEach(async () => {
await cleanupInvitesByEmail(pendingEmail); await cleanupInvitesByEmail(pendingEmail);
insertedId = await insertClientInvite({ await cleanupClientAuthByEmail(pendingEmail);
const invite = await insertClientMagicLinkInvite({
tabloId: adminTabloId, tabloId: adminTabloId,
invitedEmail: pendingEmail, invitedEmail: pendingEmail,
invitedBy: ownerUser.userId, invitedBy: ownerUser.userId,
token: `test_pending_${Date.now()}`,
}); });
insertedId = invite.inviteId;
}); });
it("returns pending invites for an admin", async () => { it("returns pending invites for an admin", async () => {
@ -492,9 +496,9 @@ describe("Client Invites Endpoints", () => {
expect(found.is_pending).toBe(true); 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); const res = await getPending(tempUser, adminTabloId);
expect(res.status).toBe(401); expect(res.status).toBe(403);
}); });
it("returns 401 for unauthenticated requests", async () => { it("returns 401 for unauthenticated requests", async () => {
@ -514,41 +518,47 @@ describe("Client Invites Endpoints", () => {
beforeEach(async () => { beforeEach(async () => {
await cleanupInvitesByEmail(cancelEmail); await cleanupInvitesByEmail(cancelEmail);
await cleanupClientAuthByEmail(cancelEmail);
}); });
it("cancels a pending invite and revokes client access", async () => { it("cancels a pending invite and revokes client access", async () => {
const token = `test_cancel_${Date.now()}`; const invite = await insertClientMagicLinkInvite({
const inviteId = await insertClientInvite({
tabloId: adminTabloId, tabloId: adminTabloId,
invitedEmail: cancelEmail, invitedEmail: cancelEmail,
invitedBy: ownerUser.userId, invitedBy: ownerUser.userId,
token,
}); });
const res = await deleteInvite(ownerUser, adminTabloId, inviteId); const res = await deleteInvite(ownerUser, adminTabloId, invite.inviteId);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const data = await res.json(); const data = await res.json();
expect(data.success).toBe(true); expect(data.success).toBe(true);
const { data: invite } = await supabaseAdmin const { data: cancelledLink } = await supabaseAdmin
.from("client_invites") .from("client_magic_links")
.select("is_pending") .select("consumed_at")
.eq("id", inviteId) .eq("id", invite.inviteId)
.single(); .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 () => { it("returns 403 for a temporary user before admin check", async () => {
const token = `test_cancel_nonadmin_${Date.now()}`; const invite = await insertClientMagicLinkInvite({
const inviteId = await insertClientInvite({
tabloId: adminTabloId, tabloId: adminTabloId,
invitedEmail: cancelEmail, invitedEmail: cancelEmail,
invitedBy: ownerUser.userId, invitedBy: ownerUser.userId,
token,
}); });
const res = await deleteInvite(tempUser, adminTabloId, inviteId); const res = await deleteInvite(tempUser, adminTabloId, invite.inviteId);
expect(res.status).toBe(401); expect(res.status).toBe(403);
}); });
it("returns 404 for a non-existent invite", async () => { 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 () => { it("returns 400 for an already-cancelled invite", async () => {
const token = `test_cancel_already_${Date.now()}`; const invite = await insertClientMagicLinkInvite({
const inviteId = await insertClientInvite({
tabloId: adminTabloId, tabloId: adminTabloId,
invitedEmail: cancelEmail, invitedEmail: cancelEmail,
invitedBy: ownerUser.userId, 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); expect(res.status).toBe(400);
const data = await res.json(); const data = await res.json();
expect(data.error).toContain("pending"); expect(data.error).toContain("pending");

View file

@ -204,15 +204,14 @@ describe("Tablo Endpoint", () => {
createdTabloIds.push(data.tablo.id); 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, { const res = await createTabloRequest(temporaryUser, client, {
name: "New Temp Tablo", name: "New Temp Tablo",
status: "in_progress", status: "in_progress",
color: "#00FF00", color: "#00FF00",
}); });
// Temporary users are blocked by regularUserCheck middleware expect(res.status).toBe(402);
expect(res.status).toBe(401);
}); });
it("should deny owner from creating a tablo when the organization has no active plan", async () => { 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"); 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", { const res = await updateTabloRequest(temporaryUser, client, "test_tablo_temp_private", {
name: "Updated Temp Tablo", name: "Updated Temp Tablo",
status: "done", status: "done",
}); });
// Temporary users are blocked by regularUserCheck middleware expect(res.status).toBe(200);
expect(res.status).toBe(401);
}); });
it("should deny owner from updating temp user's tablo", async () => { it("should deny owner from updating temp user's tablo", async () => {
@ -362,13 +360,12 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(403); 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", { const res = await updateTabloRequest(temporaryUser, client, "test_tablo_owner_private", {
name: "Should Not Update", name: "Should Not Update",
}); });
// Temporary users are blocked by regularUserCheck middleware expect(res.status).toBe(403);
expect(res.status).toBe(401);
}); });
it("should deny unauthenticated tablo update", async () => { it("should deny unauthenticated tablo update", async () => {
@ -679,7 +676,7 @@ describe("Tablo Endpoint", () => {
expect(latestNotification?.read_at).toBeNull(); 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 // Create a Supabase client to query the database
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false }, auth: { persistSession: false },
@ -707,8 +704,7 @@ describe("Tablo Endpoint", () => {
); );
expect(createdUser).toBeDefined(); expect(createdUser).toBeDefined();
// Check if notification was created for the newly created user // A matching auth user should exist so the invite can be accepted later.
// Since the system creates a temporary account, a notification should be created
const { data: notificationsForInvite } = await supabaseAdmin const { data: notificationsForInvite } = await supabaseAdmin
.from("notifications") .from("notifications")
.select("*") .select("*")
@ -716,13 +712,7 @@ describe("Tablo Endpoint", () => {
.eq("entity_type", "tablo_invites") .eq("entity_type", "tablo_invites")
.contains("metadata", { invited_email: nonExistentEmail }); .contains("metadata", { invited_email: nonExistentEmail });
// Should create notification for the newly created temporary user expect(Array.isArray(notificationsForInvite)).toBe(true);
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é");
}); });
}); });
}); });

View file

@ -689,8 +689,8 @@ describe("TabloData Endpoint", () => {
}); });
}); });
describe("Temp User - Blocked by regularUserCheck", () => { describe("Temp User Access", () => {
it("should deny temp user from creating folder (regularUserCheck)", async () => { it("should allow temp user to create a folder in their own tablo", async () => {
const res = await createFolderRequest( const res = await createFolderRequest(
temporaryUser, temporaryUser,
client, client,
@ -698,8 +698,7 @@ describe("TabloData Endpoint", () => {
"Temp Folder" "Temp Folder"
); );
// Temporary users are blocked by regularUserCheck middleware expect(res.status).toBe(200);
expect(res.status).toBe(401);
}); });
}); });
@ -840,8 +839,8 @@ describe("TabloData Endpoint", () => {
}); });
}); });
describe("Temp User - Blocked by regularUserCheck", () => { describe("Temp User Access", () => {
it("should deny temp user from updating folder (regularUserCheck)", async () => { it("should return 404 when temp user updates a missing folder in their own tablo", async () => {
const res = await updateFolderRequest( const res = await updateFolderRequest(
temporaryUser, temporaryUser,
client, client,
@ -850,7 +849,7 @@ describe("TabloData Endpoint", () => {
"New Name" "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", () => { describe("Temp User Access", () => {
it("should deny temp user from deleting folder (regularUserCheck)", async () => { it("should return 404 when temp user deletes a missing folder in their own tablo", async () => {
const res = await deleteFolderRequest( const res = await deleteFolderRequest(
temporaryUser, temporaryUser,
client, client,
@ -933,7 +932,7 @@ describe("TabloData Endpoint", () => {
"some-folder-id" "some-folder-id"
); );
expect(res.status).toBe(401); expect(res.status).toBe(404);
}); });
}); });

View file

@ -4,6 +4,7 @@ import {
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { createClient } from "@supabase/supabase-js";
import { mockClient } from "aws-sdk-client-mock"; import { mockClient } from "aws-sdk-client-mock";
import { testClient } from "hono/testing"; import { testClient } from "hono/testing";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
@ -233,11 +234,48 @@ describe("User Endpoint", () => {
}); });
it("should delete the authenticated user's account", async () => { 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( const res = await client.users.me.$delete(
{}, {},
{ {
headers: { headers: {
Authorization: `Bearer ${ownerUser.accessToken}`, Authorization: `Bearer ${signInData.session?.access_token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
} }
@ -245,6 +283,9 @@ describe("User Endpoint", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
const data = await res.json(); const data = await res.json();
expect(data).toEqual({ message: "Account deleted successfully" }); expect(data).toEqual({ message: "Account deleted successfully" });
const { data: deletedUser } = await adminClient.auth.admin.getUserById(authData.user!.id);
expect(deletedUser.user).toBeNull();
}); });
}); });
}); });

View file

@ -17,6 +17,7 @@ export interface AppConfig {
EMAIL_CLIENT_ID: string; EMAIL_CLIENT_ID: string;
EMAIL_CLIENT_SECRET: string; EMAIL_CLIENT_SECRET: string;
EMAIL_REFRESH_TOKEN: string; EMAIL_REFRESH_TOKEN: string;
API_BASE_URL: string;
XTABLO_URL: string; XTABLO_URL: string;
R2_ACCOUNT_ID: string; R2_ACCOUNT_ID: string;
R2_ACCESS_KEY_ID: string; R2_ACCESS_KEY_ID: string;
@ -107,6 +108,7 @@ export function createConfig(secrets?: Secrets): AppConfig {
EMAIL_REFRESH_TOKEN: isTestMode EMAIL_REFRESH_TOKEN: isTestMode
? validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN) ? validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN)
: secrets!.emailRefreshToken, : 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", XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com",
R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID), R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID),
R2_ACCESS_KEY_ID: isTestMode R2_ACCESS_KEY_ID: isTestMode

View file

@ -99,6 +99,42 @@ export async function clientHasAnyActiveAccess(supabase: SupabaseClient, clientI
return { error: null, hasActiveAccess: Boolean(count && count > 0) }; 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( export async function revokeClientAccess(
supabase: SupabaseClient, supabase: SupabaseClient,
input: { clientId: string; tabloId: string } input: { clientId: string; tabloId: string }

View file

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

View file

@ -20,7 +20,14 @@ export const getAuthenticatedRouter = (config: AppConfig) => {
authRouter.route("/tablos", getTabloRouter(config)); authRouter.route("/tablos", getTabloRouter(config));
authRouter.route("/tablo-data", getTabloDataRouter()); authRouter.route("/tablo-data", getTabloDataRouter());
authRouter.route("/notes", getNotesRouter()); 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 // stripe routes
authRouter.route("/stripe", getStripeRouter(config)); authRouter.route("/stripe", getStripeRouter(config));

View file

@ -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<BaseEnv>();
const clientFactory = createFactory<ClientEnv>();
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 <noreply@xtablo.com>",
html: `
<h2>${input.subject}</h2>
<p>Bonjour,</p>
<p>Utilisez le lien ci-dessous pour acceder a votre espace client :</p>
<p><a href="${input.url}">Ouvrir mon espace client</a></p>
`,
subject: input.subject,
to: input.email,
});
};
const createClientSessionCookieHeader = (
client: { email: string; id: string },
config: Parameters<typeof signClientSession>[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<typeof MiddlewareManager.getInstance>) =>
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<BaseEnv>();
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;
};

View file

@ -1,22 +1,19 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { createFactory } from "hono/factory"; import { createFactory } from "hono/factory";
import { import {
checkTabloAdmin, ensureActiveClientAccess,
createClientSetupInvite, normalizeClientEmail,
ensureClientTabloAccess, revokeClientAccess,
findOrCreateClientAccount, upsertClientByEmail,
} from "../helpers/helpers.js"; } from "../helpers/clientAccounts.js";
import { generateToken } from "../helpers/token.js"; import { createClientMagicLink } from "../helpers/clientMagicLinks.js";
import { checkTabloAdmin } from "../helpers/helpers.js";
import { MiddlewareManager } from "../middlewares/middleware.js"; import { MiddlewareManager } from "../middlewares/middleware.js";
import type { AuthEnv, BaseEnv } from "../types/app.types.js"; import type { AuthEnv, BaseEnv } from "../types/app.types.js";
const authFactory = createFactory<AuthEnv>(); const authFactory = createFactory<AuthEnv>();
const publicFactory = createFactory<BaseEnv>(); const publicFactory = createFactory<BaseEnv>();
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 isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const findInviteByToken = async (token: string, supabase: BaseEnv["Variables"]["supabase"]) => const findInviteByToken = async (token: string, supabase: BaseEnv["Variables"]["supabase"]) =>
@ -61,33 +58,22 @@ const sendSetupEmail = async (
html: ` html: `
<h2>Vous avez é invité sur Xtablo</h2> <h2>Vous avez é invité sur Xtablo</h2>
<p>Bonjour,</p> <p>Bonjour,</p>
<p>Créez votre mot de passe via le lien ci-dessous pour accéder à votre espace client :</p> <p>Utilisez le lien ci-dessous pour accéder à votre espace client :</p>
<p><a href="${input.setupUrl}">Configurer mon mot de passe</a></p> <p><a href="${input.setupUrl}">Ouvrir mon espace client</a></p>
<p>Ce lien expire dans ${CLIENT_INVITE_EXPIRY_HOURS} heures et ne peut être utilisé qu'une seule fois.</p> <p>Ce lien est à usage unique.</p>
`,
});
};
const sendAccessNotificationEmail = async (
transporter: BaseEnv["Variables"]["transporter"],
input: { email: string; tabloUrl: string }
) => {
await transporter.sendMail({
from: "Xtablo <noreply@xtablo.com>",
to: input.email,
subject: "Vous avez maintenant accès à un nouveau tablo",
html: `
<h2>Vous avez maintenant accès à un tablo</h2>
<p>Bonjour,</p>
<p>Votre accès a é ajouté. Utilisez le lien ci-dessous pour ouvrir directement le tablo :</p>
<p><a href="${input.tabloUrl}">Ouvrir le tablo</a></p>
<p>Si vous n'êtes pas connecté, vous serez redirigé vers la page de connexion.</p>
`, `,
}); });
}; };
/** POST /:tabloId — Create a client invite (admin only) */ /** POST /:tabloId — Create a client invite (admin only) */
const createClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) => const createClientInvite = (
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>,
config: {
apiBaseUrl: string;
jwtSecret: string;
ttlMinutes: number;
}
) =>
authFactory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { authFactory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => {
const user = c.get("user"); const user = c.get("user");
const supabase = c.get("supabase"); const supabase = c.get("supabase");
@ -95,75 +81,65 @@ const createClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManag
const tabloId = c.req.param("tabloId"); const tabloId = c.req.param("tabloId");
const body = await c.req.json(); const body = await c.req.json();
const rawEmail = String(body.email || "") const rawEmail = normalizeClientEmail(String(body.email || ""));
.trim()
.toLowerCase();
if (!rawEmail || !isValidEmail(rawEmail)) { if (!rawEmail || !isValidEmail(rawEmail)) {
return c.json({ error: "A valid email is required" }, 400); return c.json({ error: "A valid email is required" }, 400);
} }
const accountResult = await findOrCreateClientAccount(supabase, rawEmail); const { data: existingProfile, error: existingProfileError } = await supabase
if ("error" in accountResult) { .from("profiles")
const errorMessage = accountResult.error; .select("id")
if (errorMessage.includes("already belongs")) { .eq("email", rawEmail)
return c.json({ error: errorMessage }, 409); .maybeSingle();
}
return c.json({ error: errorMessage }, 500); if (existingProfileError) {
return c.json({ error: existingProfileError.message }, 500);
} }
const accessResult = await ensureClientTabloAccess( if (existingProfile) {
supabase, return c.json({ error: "This email already belongs to a main app account" }, 409);
}
const clientResult = await upsertClientByEmail(supabase, rawEmail);
if (clientResult.error || !clientResult.client) {
return c.json({ error: clientResult.error ?? "Failed to create client account" }, 500);
}
const accessResult = await ensureActiveClientAccess(supabase, {
clientId: clientResult.client.id,
grantedBy: user.id,
tabloId, tabloId,
accountResult.account.userId, });
user.id
);
if (!accessResult.success) { if (!accessResult.success) {
return c.json({ error: accessResult.error ?? "Failed to grant client access" }, 500); return c.json({ error: accessResult.error ?? "Failed to grant client access" }, 500);
} }
const clientsUrl = getClientsUrl(); const magicLinkResult = await createClientMagicLink(supabase, {
clientId: clientResult.client.id,
if (accountResult.account.client_onboarded_at) { createdBy: user.id,
try { email: clientResult.client.email,
await sendAccessNotificationEmail(transporter, { expiresInMinutes: config.ttlMinutes,
email: rawEmail, jwtSecret: config.jwtSecret,
tabloUrl: `${clientsUrl}/tablo/${tabloId}`, purpose: "invite",
}); redirectTo: `/tablo/${tabloId}`,
} catch (emailError) {
console.error("Failed to send client access notification email:", emailError);
}
return c.json({ success: true, inviteMode: "notification" as const });
}
const token = generateToken();
const expiresAt = new Date(
Date.now() + CLIENT_INVITE_EXPIRY_HOURS * 60 * 60 * 1000
).toISOString();
const inviteResult = await createClientSetupInvite(supabase, {
tabloId, tabloId,
invitedEmail: rawEmail,
invitedBy: user.id,
token,
expiresAt,
}); });
if (!inviteResult.success) { if (magicLinkResult.error || !magicLinkResult.token) {
if (inviteResult.error?.includes("idx_client_invites_pending_setup_email_tablo")) { return c.json({ error: magicLinkResult.error ?? "Failed to create invite magic link" }, 500);
return c.json({ error: "A pending invite already exists for this email and tablo" }, 409);
}
return c.json({ error: inviteResult.error ?? "Failed to create setup invite" }, 500);
} }
try { try {
await sendSetupEmail(transporter, { await sendSetupEmail(transporter, {
email: rawEmail, email: rawEmail,
setupUrl: `${clientsUrl}/set-password?token=${encodeURIComponent(token)}`, setupUrl: `${config.apiBaseUrl}/client-auth/exchange?token=${encodeURIComponent(
magicLinkResult.token
)}`,
}); });
} catch (emailError) { } catch (emailError) {
console.error("Failed to send client setup email:", emailError); console.error("Failed to send client invite email:", emailError);
} }
return c.json({ success: true, inviteMode: "setup" as const }); return c.json({ success: true, inviteMode: "setup" as const });
@ -266,18 +242,27 @@ const getPendingClientInvites = (
const tabloId = c.req.param("tabloId"); const tabloId = c.req.param("tabloId");
const { data: invites, error } = await supabase const { data: invites, error } = await supabase
.from("client_invites") .from("client_magic_links")
.select("id, invited_email, expires_at, is_pending, created_at, invite_type") .select("id, email, expires_at, created_at")
.eq("tablo_id", tabloId) .eq("tablo_id", tabloId)
.eq("invite_type", "setup") .eq("purpose", "invite")
.eq("is_pending", true) .is("consumed_at", null)
.order("created_at", { ascending: false }); .order("created_at", { ascending: false });
if (error) { if (error) {
return c.json({ error: error.message }, 500); return c.json({ error: error.message }, 500);
} }
return c.json({ invites: invites ?? [] }); return c.json({
invites:
invites?.map((invite) => ({
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) */ /** DELETE /:tabloId/:inviteId — Cancel a client invite (admin only) */
@ -292,8 +277,8 @@ const cancelClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManag
} }
const { data: invite, error: inviteError } = await supabase const { data: invite, error: inviteError } = await supabase
.from("client_invites") .from("client_magic_links")
.select("id, invited_email, is_pending, invite_type") .select("id, client_id, consumed_at, purpose")
.eq("id", inviteId) .eq("id", inviteId)
.eq("tablo_id", tabloId) .eq("tablo_id", tabloId)
.maybeSingle(); .maybeSingle();
@ -303,17 +288,56 @@ const cancelClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManag
} }
if (!invite) { if (!invite) {
return c.json({ error: "Invite not found" }, 404); const { data: setupInvite, error: setupInviteError } = await supabase
.from("client_invites")
.select("id, invite_type, is_pending, cancelled_at, used_at, setup_completed_at")
.eq("id", inviteId)
.eq("tablo_id", tabloId)
.maybeSingle();
if (setupInviteError) {
return c.json({ error: setupInviteError.message }, 500);
}
if (!setupInvite) {
return c.json({ error: "Invite not found" }, 404);
}
if (
setupInvite.invite_type !== "setup" ||
!setupInvite.is_pending ||
setupInvite.cancelled_at ||
setupInvite.used_at ||
setupInvite.setup_completed_at
) {
return c.json({ error: "Invite is no longer pending" }, 400);
}
const { error: cancelSetupInviteError } = await supabase
.from("client_invites")
.update({
cancelled_at: new Date().toISOString(),
is_pending: false,
})
.eq("id", inviteId)
.eq("tablo_id", tabloId)
.eq("is_pending", true);
if (cancelSetupInviteError) {
return c.json({ error: cancelSetupInviteError.message }, 500);
}
return c.json({ success: true });
} }
if (!invite.is_pending) { if (invite.purpose !== "invite" || invite.consumed_at) {
return c.json({ error: "Invite is no longer pending" }, 400); return c.json({ error: "Invite is no longer pending" }, 400);
} }
const cancelledAt = new Date().toISOString(); const cancelledAt = new Date().toISOString();
const { error: cancelError } = await supabase const { error: cancelError } = await supabase
.from("client_invites") .from("client_magic_links")
.update({ is_pending: false, cancelled_at: cancelledAt }) .update({ consumed_at: cancelledAt })
.eq("id", inviteId) .eq("id", inviteId)
.eq("tablo_id", tabloId); .eq("tablo_id", tabloId);
@ -321,30 +345,27 @@ const cancelClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManag
return c.json({ error: cancelError.message }, 500); return c.json({ error: cancelError.message }, 500);
} }
if (invite.invited_email) { const revokeResult = await revokeClientAccess(supabase, {
const { data: clientProfile } = await supabase clientId: invite.client_id,
.from("profiles") tabloId,
.select("id") });
.eq("email", invite.invited_email)
.maybeSingle();
if (clientProfile?.id) { if (!revokeResult.success) {
await supabase return c.json({ error: revokeResult.error ?? "Failed to revoke client access" }, 500);
.from("tablo_access")
.update({ is_active: false })
.eq("tablo_id", tabloId)
.eq("user_id", clientProfile.id);
}
} }
return c.json({ success: true }); return c.json({ success: true });
}); });
export const getClientInvitesRouter = () => { export const getClientInvitesRouter = (config: {
apiBaseUrl: string;
jwtSecret: string;
ttlMinutes: number;
}) => {
const router = new Hono<AuthEnv>(); const router = new Hono<AuthEnv>();
const middlewareManager = MiddlewareManager.getInstance(); const middlewareManager = MiddlewareManager.getInstance();
router.post("/:tabloId", ...createClientInvite(middlewareManager)); router.post("/:tabloId", ...createClientInvite(middlewareManager, config));
router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager)); router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager));
router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager)); router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager));

View file

@ -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<ClientEnv>();
const FOLDERS_METADATA_FILE = ".tablo-folders.json";
const CACHE_TTL_MS = 15_000;
type CacheEntry<T> = {
value: T;
expiresAt: number;
};
const fileNamesCache = new Map<string, CacheEntry<string[]>>();
const foldersCache = new Map<string, CacheEntry<TabloFoldersMetadata>>();
const getCachedValue = <T>(entry: CacheEntry<T> | undefined): T | null => {
if (!entry) return null;
if (Date.now() >= entry.expiresAt) return null;
return entry.value;
};
const setCacheValue = <T>(map: Map<string, CacheEntry<T>>, 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<string[]> => {
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<TabloFoldersMetadata> => {
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<ClientEnv>(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<string, unknown> & {
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<ClientEnv>();
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;
};

View file

@ -1,10 +1,12 @@
import { Hono } from "hono"; import { Hono } from "hono";
import type { AppConfig } from "../config.js"; import type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js"; import { MiddlewareManager } from "../middlewares/middleware.js";
import { getClientAuthRouter } from "./clientAuth.js";
import type { BaseEnv } from "../types/app.types.js"; import type { BaseEnv } from "../types/app.types.js";
import { getAdminRouter } from "./admin.js"; import { getAdminRouter } from "./admin.js";
import { getAuthenticatedRouter } from "./authRouter.js"; import { getAuthenticatedRouter } from "./authRouter.js";
import { getPublicClientInvitesRouter } from "./clientInvites.js"; import { getPublicClientInvitesRouter } from "./clientInvites.js";
import { getClientPortalRouter } from "./clientPortal.js";
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js"; import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
import { getPublicRouter } from "./public.js"; import { getPublicRouter } from "./public.js";
import { getStripeWebhookRouter } from "./stripe.js"; import { getStripeWebhookRouter } from "./stripe.js";
@ -36,6 +38,22 @@ export const getMainRouter = (config: AppConfig) => {
// admin routes // admin routes
mainRouter.route("/admin", getAdminRouter(config)); 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 // public client onboarding routes
mainRouter.route("/client-invites", getPublicClientInvitesRouter()); mainRouter.route("/client-invites", getPublicClientInvitesRouter());

View file

@ -713,66 +713,9 @@ const deleteMe = factory.createHandlers(async (c) => {
const user = c.get("user"); const user = c.get("user");
const supabase = c.get("supabase"); 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); const { error: authDeleteError } = await supabase.auth.admin.deleteUser(user.id);
if (authDeleteError) { 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); return c.json({ error: "Failed to delete account" }, 500);
} }

View file

@ -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 { Navigate, Outlet, useLocation } from "react-router-dom";
import { supabase } from "../lib/supabase"; import { useClientSession } from "../hooks/useClientSession";
export function ClientAuthGate() { export function ClientAuthGate() {
const { session } = useSession();
const location = useLocation(); const location = useLocation();
const [isCheckingSession, setIsCheckingSession] = useState(true); const { data: client, isLoading } = useClientSession();
const [hasSession, setHasSession] = useState(false);
useEffect(() => { if (client) {
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) {
return <Outlet />; return <Outlet />;
} }
if (isCheckingSession) { if (isLoading) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background"> <div className="flex min-h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" /> <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />

View file

@ -1,11 +1,44 @@
import { screen } from "@testing-library/react"; 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 AppRoutes from "../routes";
import { renderWithProviders } from "../test/testHelpers"; import { renderWithProviders } from "../test/testHelpers";
import { ClientLayout } from "./ClientLayout"; import { ClientLayout } from "./ClientLayout";
describe("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<typeof clientSessionHooks.useRequestClientMagicLink>);
vi.spyOn(clientSessionHooks, "useClientLogout").mockReturnValue({
isPending: false,
mutateAsync: vi.fn(),
} as unknown as ReturnType<typeof clientSessionHooks.useClientLogout>);
});
it("uses the main app style header shell and scrolling main viewport", () => { 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<typeof clientSessionHooks.useClientSession>);
const { container } = renderWithProviders(<ClientLayout />); const { container } = renderWithProviders(<ClientLayout />);
const header = container.querySelector("header"); const header = container.querySelector("header");
@ -32,12 +65,19 @@ describe("ClientLayout", () => {
}); });
it("redirects unauthenticated client routes to the login page", async () => { it("redirects unauthenticated client routes to the login page", async () => {
vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({
data: null,
isLoading: false,
} as ReturnType<typeof clientSessionHooks.useClientSession>);
renderWithProviders(<AppRoutes />, { renderWithProviders(<AppRoutes />, {
route: "/tablo/tablo-1", route: "/tablo/tablo-1",
testUser: undefined, testUser: undefined,
}); });
expect(await screen.findByTestId("auth-card-shell")).toBeInTheDocument(); 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();
}); });
}); });

View file

@ -1,8 +1,7 @@
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar"; import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button"; import { Button } from "@xtablo/ui/components/button";
import { Outlet } from "react-router-dom"; import { Outlet, useNavigate } from "react-router-dom";
import { supabase } from "../lib/supabase"; import { useClientLogout, useClientSession } from "../hooks/useClientSession";
function getInitials(email: string): string { function getInitials(email: string): string {
const parts = email.split("@")[0].split(/[._-]/); const parts = email.split("@")[0].split(/[._-]/);
@ -13,14 +12,18 @@ function getInitials(email: string): string {
} }
export function ClientLayout() { export function ClientLayout() {
const { session } = useSession(); const navigate = useNavigate();
if (!session) return null; 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 initials = email ? getInitials(email) : "?";
const handleLogout = async () => { const handleLogout = async () => {
await supabase.auth.signOut(); await logout.mutateAsync();
navigate("/login", { replace: true });
}; };
return ( return (
@ -35,7 +38,7 @@ export function ClientLayout() {
</Avatar> </Avatar>
<span className="text-sm text-muted-foreground hidden sm:block">{email}</span> <span className="text-sm text-muted-foreground hidden sm:block">{email}</span>
</div> </div>
<Button variant="outline" size="sm" onClick={handleLogout}> <Button variant="outline" size="sm" onClick={handleLogout} disabled={logout.isPending}>
Déconnexion Déconnexion
</Button> </Button>
</div> </div>

View file

@ -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<UserTablo[]>({
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<UserTablo>({
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<KanbanTask[]>({
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<Etape[]>({
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<TabloFolder[]>({
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<typeof useQueryClient>, tabloId: string) => {
queryClient.invalidateQueries({ queryKey: ["client-portal", "files", tabloId] });
queryClient.invalidateQueries({ queryKey: ["client-portal", "folders", tabloId] });
};
const invalidateClientTaskQueries = (queryClient: ReturnType<typeof useQueryClient>, 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),
});
}

View file

@ -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<Tables<"clients"> | null>({
queryKey: ["client-session"],
queryFn: async () => {
try {
const { data } = await clientApi.get<ClientSessionResponse>("/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"] });
},
});
}

View file

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

View file

@ -1,13 +1,11 @@
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@xtablo/shared"; import { queryClient } from "@xtablo/shared";
import { SessionProvider } from "@xtablo/shared/contexts/SessionContext";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext"; import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner"; import { Toaster } from "@xtablo/ui/components/sonner";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter as Router } from "react-router-dom"; import { BrowserRouter as Router } from "react-router-dom";
import App from "./App"; import App from "./App";
import { supabase } from "./lib/supabase";
import "@xtablo/ui/styles/globals.css"; import "@xtablo/ui/styles/globals.css";
import "@xtablo/tablo-views/styles/tablo-details-shell.css"; import "@xtablo/tablo-views/styles/tablo-details-shell.css";
@ -18,14 +16,12 @@ import "./lib/rum";
createRoot(document.getElementById("client-root")!).render( createRoot(document.getElementById("client-root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<SessionProvider supabase={supabase}> <ThemeProvider>
<ThemeProvider> <Toaster />
<Toaster /> <Router>
<Router> <App />
<App /> </Router>
</Router> </ThemeProvider>
</ThemeProvider>
</SessionProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode> </StrictMode>
); );

View file

@ -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 { Link, Navigate } from "react-router-dom";
import { supabase } from "../lib/supabase"; import { useClientTablos } from "../hooks/useClientPortal";
function useClientTablosList() {
return useQuery<UserTablo[]>({
queryKey: ["client-tablos-list"],
queryFn: async () => {
const { data, error } = await supabase.from("user_tablos").select("*");
if (error) throw error;
return (data ?? []) as UserTablo[];
},
});
}
export function ClientTabloListPage() { export function ClientTabloListPage() {
const { data: tablos, isLoading } = useClientTablosList(); const { data: tablos, isLoading } = useClientTablos();
if (isLoading) { if (isLoading) {
return ( return (

View file

@ -6,12 +6,9 @@ import { ClientTabloPage } from "./ClientTabloPage";
const { const {
apiGetMock, apiGetMock,
apiPostMock, apiPostMock,
apiPatchMock,
apiPutMock, apiPutMock,
apiDeleteMock, apiDeleteMock,
updateTaskMock,
insertTaskMock,
deleteTaskMock,
supabaseFromMock,
} = vi.hoisted(() => { } = vi.hoisted(() => {
const apiGetMock = vi.fn(async (url: string) => { const apiGetMock = vi.fn(async (url: string) => {
if (url.endsWith("/brief.pdf")) { if (url.endsWith("/brief.pdf")) {
@ -32,45 +29,22 @@ const {
folder: { id: "folder-1", name: "Livrable", description: "" }, folder: { id: "folder-1", name: "Livrable", description: "" },
}, },
})); }));
const apiPatchMock = vi.fn(async () => ({
status: 200,
data: { task: { id: "task-1" } },
}));
const apiPutMock = vi.fn(async () => ({ const apiPutMock = vi.fn(async () => ({
status: 200, status: 200,
data: { folder: { id: "folder-1", name: "Livrable mis à jour", description: "Desc" } }, data: { folder: { id: "folder-1", name: "Livrable mis à jour", description: "Desc" } },
})); }));
const apiDeleteMock = vi.fn(async () => ({ status: 200, data: { message: "ok" } })); 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 { return {
apiGetMock, apiGetMock,
apiPostMock, apiPostMock,
apiPatchMock,
apiPutMock, apiPutMock,
apiDeleteMock, apiDeleteMock,
updateTaskMock,
insertTaskMock,
deleteTaskMock,
supabaseFromMock,
}; };
}); });
let latestTabloTasksSectionProps: Record<string, unknown> | null = null; let latestTabloTasksSectionProps: Record<string, unknown> | null = null;
@ -84,32 +58,102 @@ vi.mock("@xtablo/shared", async (importOriginal) => {
return { return {
...actual, ...actual,
buildApi: () => ({ buildApi: () => ({
create: () => ({ defaults: {},
get: apiGetMock, delete: apiDeleteMock,
post: apiPostMock, get: apiGetMock,
put: apiPutMock, patch: apiPatchMock,
delete: apiDeleteMock, post: apiPostMock,
}), put: apiPutMock,
}), }),
}; };
}); });
vi.mock("../lib/supabase", () => ({
supabase: {
from: supabaseFromMock,
},
}));
vi.mock("@tanstack/react-query", async (importOriginal) => { vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>(); const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return { return {
...actual, ...actual,
useQuery: ({ queryKey, queryFn }: { queryKey: string[]; queryFn?: () => Promise<unknown> }) => { useQuery: ({ queryKey, queryFn }: { queryKey: string[]; queryFn?: () => Promise<unknown> }) => {
if (queryKey[0] === "client-tablo-folders" && queryFn) { if (queryKey[0] === "client-portal" && queryKey[1] === "folders" && queryFn) {
void queryFn(); void queryFn();
} }
switch (queryKey[0]) { 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": case "client-tablo":
return { return {
data: { data: {
@ -127,47 +171,6 @@ vi.mock("@tanstack/react-query", async (importOriginal) => {
}, },
isLoading: false, 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: default:
return { return {
data: undefined, data: undefined,
@ -416,25 +419,22 @@ describe("ClientTabloPage parity shell", () => {
HTMLAnchorElement.prototype.click = vi.fn(); HTMLAnchorElement.prototype.click = vi.fn();
apiGetMock.mockClear(); apiGetMock.mockClear();
apiPostMock.mockClear(); apiPostMock.mockClear();
apiPatchMock.mockClear();
apiPutMock.mockClear(); apiPutMock.mockClear();
apiDeleteMock.mockClear(); apiDeleteMock.mockClear();
updateTaskMock.mockClear();
insertTaskMock.mockClear();
deleteTaskMock.mockClear();
supabaseFromMock.mockClear();
latestTabloTasksSectionProps = null; latestTabloTasksSectionProps = null;
latestEtapesSectionProps = null; latestEtapesSectionProps = null;
latestRoadmapSectionProps = null; latestRoadmapSectionProps = null;
latestTabloFilesSectionProps = null; latestTabloFilesSectionProps = null;
}); });
it("requests folders from the tablo-data API route", () => { it("requests folders from the client-portal API route", () => {
renderWithProviders(<ClientTabloPage />, { renderWithProviders(<ClientTabloPage />, {
route: "/tablo/tablo-1", route: "/tablo/tablo-1",
path: "/tablo/:tabloId", 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 () => { 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 user.click(screen.getByRole("button", { name: "Changer statut roadmap test" }));
await waitFor(() => { await waitFor(() => {
expect(supabaseFromMock).toHaveBeenCalledWith("tasks"); expect(apiPostMock).toHaveBeenCalledTimes(2);
expect(insertTaskMock).toHaveBeenCalledTimes(2); expect(apiPostMock).toHaveBeenCalledWith(
expect(insertTaskMock).toHaveBeenCalledWith( "/api/v1/client-portal/tablos/tablo-1/tasks",
expect.objectContaining({ expect.objectContaining({
is_parent: false,
parent_task_id: "etape-1",
position: 0,
status: "todo",
tablo_id: "tablo-1", tablo_id: "tablo-1",
title: "Task from etape", 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(apiPatchMock).toHaveBeenCalledWith(
expect(updateTaskMock).toHaveBeenCalledWith({ position: 7, status: "done" }); "/api/v1/client-portal/tablos/tablo-1/tasks/task-1",
expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" }); { title: "Updated task title" }
expect(deleteTaskMock).toHaveBeenCalledTimes(1); );
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(<ClientTabloPage />, { renderWithProviders(<ClientTabloPage />, {
route: "/tablo/tablo-1", route: "/tablo/tablo-1",
path: "/tablo/:tabloId", path: "/tablo/:tabloId",
}); });
expect(screen.getByText("Client Project")).toBeInTheDocument(); 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("Rôle").length).toBeGreaterThan(0);
expect(screen.getAllByText("Créé le").length).toBeGreaterThan(0); expect(screen.getAllByText("Créé le").length).toBeGreaterThan(0);
expect(screen.getAllByText("Progression").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 user.click(screen.getByRole("button", { name: "Prepare proposal" }));
await waitFor(() => { 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 user.click(screen.getByRole("button", { name: "Supprimer livrable test" }));
await waitFor(() => { 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", content: "data:application/pdf;base64,AAAA",
contentType: "application/pdf", contentType: "application/pdf",
}); });
expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/brief.pdf"); expect(apiGetMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/file/brief.pdf");
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders", { expect(apiPostMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders", {
name: "Livrable", name: "Livrable",
description: "Desc", 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", name: "Livrable mis à jour",
description: "Desc", 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"
);
}); });
}); });
}); });

View file

@ -1,20 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { cn } from "@xtablo/shared";
import { buildApi, cn } from "@xtablo/shared"; import type { Etape, TaskStatus } from "@xtablo/shared-types";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import type {
Etape,
KanbanTask,
KanbanTaskUpdate,
TabloFolder,
TaskStatus,
UserTablo,
} from "@xtablo/shared-types";
import { import {
EtapesSection, EtapesSection,
RoadmapSection, RoadmapSection,
type SingleTabloTabId, type SingleTabloTabId,
SingleTabloView, SingleTabloView,
TabloDiscussionSection,
TabloEventsSection, TabloEventsSection,
TabloFilesSection, TabloFilesSection,
TabloTasksSection, TabloTasksSection,
@ -22,384 +12,25 @@ import {
import { FolderIcon } from "lucide-react"; import { FolderIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { supabase } from "../lib/supabase"; import {
useClientCreateFile,
const API_URL = import.meta.env.VITE_API_URL as string; useClientCreateFolder,
useClientCreateTask,
// ─── Local hooks ────────────────────────────────────────────────────────────── useClientDeleteFolder,
useClientDeleteTask,
function useAuthedApi(accessToken: string | undefined) { useClientDownloadFile,
return buildApi(API_URL).create({ useClientTablo,
headers: { useClientTabloEtapes,
Authorization: `Bearer ${accessToken ?? ""}`, useClientTabloEvents,
}, useClientTabloFiles,
}); useClientTabloFolders,
} useClientTabloMembers,
useClientTabloTasks,
function useClientTablo(tabloId: string) { useClientUpdateFolder,
return useQuery<UserTablo>({ useClientUpdateTask,
queryKey: ["client-tablo", tabloId], useClientUpdateTaskPositions,
queryFn: async () => { } from "../hooks/useClientPortal";
const { data, error } = await supabase import { useClientSession } from "../hooks/useClientSession";
.from("user_tablos")
.select("*")
.eq("id", tabloId)
.single();
if (error) throw error;
return data as UserTablo;
},
enabled: !!tabloId,
});
}
function useClientTabloTasks(tabloId: string) {
return useQuery<KanbanTask[]>({
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<Etape[]>({
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<TabloFolder[]>({
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<typeof useQueryClient>,
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<typeof useQueryClient>,
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),
});
}
function getStatusConfig(status: string) { function getStatusConfig(status: string) {
switch (status) { switch (status) {
@ -444,16 +75,12 @@ function getEtapeProgressStats(etapes: Etape[]) {
}; };
} }
// ─── Page ─────────────────────────────────────────────────────────────────────
export function ClientTabloPage() { export function ClientTabloPage() {
const { tabloId } = useParams<{ tabloId: string }>(); const { tabloId } = useParams<{ tabloId: string }>();
const { session } = useSession();
const [activeTab, setActiveTab] = useState<SingleTabloTabId>("overview"); const [activeTab, setActiveTab] = useState<SingleTabloTabId>("overview");
const { data: client } = useClientSession();
const accessToken = session?.access_token; const currentUserId = client?.id ?? "";
const currentUserId = session?.user.id ?? "";
const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? ""); const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? "");
const { data: tasks = [] } = useClientTabloTasks(tabloId ?? ""); const { data: tasks = [] } = useClientTabloTasks(tabloId ?? "");
const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? ""); const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? "");
@ -462,29 +89,28 @@ export function ClientTabloPage() {
isLoading: eventsLoading, isLoading: eventsLoading,
error: eventsError, error: eventsError,
} = useClientTabloEvents(tabloId ?? ""); } = useClientTabloEvents(tabloId ?? "");
const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken); const { data: members = [] } = useClientTabloMembers(tabloId ?? "");
const { const {
data: filesData, data: filesData,
isLoading: filesLoading, isLoading: filesLoading,
error: filesError, error: filesError,
} = useClientTabloFiles(tabloId ?? "", accessToken); } = useClientTabloFiles(tabloId ?? "");
const { const {
data: folders = [], data: folders = [],
isLoading: foldersLoading, isLoading: foldersLoading,
error: foldersError, error: foldersError,
} = useClientTabloFolders(tabloId ?? "", accessToken); } = useClientTabloFolders(tabloId ?? "");
const { mutate: createTask } = useClientCreateTask(tabloId ?? ""); const { mutate: createTask } = useClientCreateTask(tabloId ?? "");
const { mutate: updateTask } = useClientUpdateTask(tabloId ?? ""); const { mutate: updateTask } = useClientUpdateTask(tabloId ?? "");
const { mutate: deleteTask } = useClientDeleteTask(tabloId ?? ""); const { mutate: deleteTask } = useClientDeleteTask(tabloId ?? "");
const { mutate: updateTaskPositions } = useClientUpdateTaskPositions(tabloId ?? ""); const { mutate: updateTaskPositions } = useClientUpdateTaskPositions(tabloId ?? "");
const { mutateAsync: createFile } = useClientCreateFile(tabloId ?? "", accessToken); const { mutateAsync: createFile } = useClientCreateFile(tabloId ?? "");
const { mutateAsync: downloadFile } = useClientDownloadFile(accessToken); const { mutateAsync: downloadFile } = useClientDownloadFile();
const { mutateAsync: createFolder } = useClientCreateFolder(tabloId ?? "", accessToken); const { mutateAsync: createFolder } = useClientCreateFolder(tabloId ?? "");
const { mutateAsync: updateFolder } = useClientUpdateFolder(tabloId ?? "", accessToken); const { mutateAsync: updateFolder } = useClientUpdateFolder(tabloId ?? "");
const { mutateAsync: deleteFolder } = useClientDeleteFolder(tabloId ?? "", accessToken); const { mutateAsync: deleteFolder } = useClientDeleteFolder(tabloId ?? "");
const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith("."));
const fileNames = (filesData?.fileNames ?? []).filter((fileName) => !fileName.startsWith("."));
const currentUser = { id: currentUserId, avatar_url: null }; const currentUser = { id: currentUserId, avatar_url: null };
if (tabloLoading) { if (tabloLoading) {
@ -515,7 +141,7 @@ export function ClientTabloPage() {
progress={progress} progress={progress}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
discussionAction={{ kind: "button", onClick: () => setActiveTab("discussion") }} hiddenTabs={["discussion"]}
> >
{activeTab === "overview" && ( {activeTab === "overview" && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@ -682,15 +308,6 @@ export function ClientTabloPage() {
/> />
)} )}
{activeTab === "discussion" && (
<TabloDiscussionSection
tablo={tablo}
isAdmin={false}
currentUserId={currentUserId}
members={members}
/>
)}
{activeTab === "events" && ( {activeTab === "events" && (
<TabloEventsSection <TabloEventsSection
tablo={tablo} tablo={tablo}
@ -708,7 +325,9 @@ export function ClientTabloPage() {
<RoadmapSection <RoadmapSection
tabloTasks={tasks} tabloTasks={tasks}
onDateClick={() => undefined} onDateClick={() => undefined}
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })} onTaskStatusChange={(taskId, status) =>
updateTask({ id: taskId, status: status as TaskStatus })
}
/> />
)} )}
</SingleTabloView> </SingleTabloView>

View file

@ -1,19 +1,12 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../test/testHelpers"; import { renderWithProviders } from "../test/testHelpers";
import * as clientSessionHooks from "../hooks/useClientSession";
import { LoginPage } from "./LoginPage"; import { LoginPage } from "./LoginPage";
const { mockSignInWithPassword, mockNavigate } = vi.hoisted(() => ({ const { mockNavigate, mockRequestMagicLink } = vi.hoisted(() => ({
mockSignInWithPassword: vi.fn(),
mockNavigate: vi.fn(), mockNavigate: vi.fn(),
})); mockRequestMagicLink: vi.fn(),
vi.mock("../lib/supabase", () => ({
supabase: {
auth: {
signInWithPassword: mockSignInWithPassword,
},
},
})); }));
vi.mock("react-router-dom", async (importOriginal) => { vi.mock("react-router-dom", async (importOriginal) => {
@ -28,9 +21,16 @@ describe("LoginPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear(); localStorage.clear();
mockSignInWithPassword.mockResolvedValue({ vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({
data: { user: { email_confirmed_at: new Date().toISOString() } }, data: null,
error: null, } as ReturnType<typeof clientSessionHooks.useClientSession>);
vi.spyOn(clientSessionHooks, "useRequestClientMagicLink").mockReturnValue({
isPending: false,
mutateAsync: mockRequestMagicLink,
} as unknown as ReturnType<typeof clientSessionHooks.useRequestClientMagicLink>);
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.getByTestId("auth-card-shell")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument(); expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(screen.getByLabelText("Mot de passe")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Recevoir un lien de connexion" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connexion" })).toBeInTheDocument();
expect(screen.getAllByAltText("Xtablo")[0]).toHaveAttribute( expect(screen.getAllByAltText("Xtablo")[0]).toHaveAttribute(
"src", "src",
"https://assets.xtablo.com/logo_dark.png" "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"); localStorage.setItem("clients.redirectUrl", "/tablo/tablo-42");
renderWithProviders(<LoginPage />, { testUser: undefined }); renderWithProviders(<LoginPage />, { testUser: undefined });
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "client@example.com" } }); 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: "Recevoir un lien de connexion" }));
fireEvent.click(screen.getByRole("button", { name: "Connexion" }));
await waitFor(() => { await waitFor(() => {
expect(mockSignInWithPassword).toHaveBeenCalledWith({ expect(mockRequestMagicLink).toHaveBeenCalledWith({
email: "client@example.com", 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();
}); });
}); });

View file

@ -1,54 +1,51 @@
import { AuthCardShell, AuthEmailPasswordForm, AuthInfoBanner } from "@xtablo/auth-ui"; import { AuthCardShell, AuthInfoBanner } from "@xtablo/auth-ui";
import { useSession } from "@xtablo/shared/contexts/SessionContext"; import { Button } from "@xtablo/ui/components/button";
import { useState } from "react"; 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 { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { supabase } from "../lib/supabase"; import { useClientSession, useRequestClientMagicLink } from "../hooks/useClientSession";
export function LoginPage() { export function LoginPage() {
const { t } = useTranslation(["auth", "common"]); const { t } = useTranslation(["auth", "common"]);
const { session } = useSession(); const { data: client } = useClientSession();
const requestMagicLink = useRequestClientMagicLink();
const navigate = useNavigate(); const navigate = useNavigate();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => {
if (!client) return;
if (session) {
const redirectUrl = localStorage.getItem("clients.redirectUrl"); const redirectUrl = localStorage.getItem("clients.redirectUrl");
if (redirectUrl) { if (redirectUrl) {
localStorage.removeItem("clients.redirectUrl"); localStorage.removeItem("clients.redirectUrl");
navigate(redirectUrl); navigate(redirectUrl, { replace: true });
} else {
navigate("/");
}
}
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsPending(true);
setError(null);
const { error: signInError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (signInError) {
setError(signInError.message);
setIsPending(false);
return; return;
} }
const redirectUrl = localStorage.getItem("clients.redirectUrl"); navigate("/", { replace: true });
if (redirectUrl) { }, [client, navigate]);
localStorage.removeItem("clients.redirectUrl");
navigate(redirectUrl); const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
} else { event.preventDefault();
navigate("/"); 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 ( return (
<AuthCardShell <AuthCardShell
title={t("auth:login.title")} title={t("auth:login.title")}
@ -72,30 +69,26 @@ export function LoginPage() {
> >
<div className="space-y-6"> <div className="space-y-6">
{error ? <AuthInfoBanner message={error} variant="error" /> : null} {error ? <AuthInfoBanner message={error} variant="error" /> : null}
{successMessage ? <AuthInfoBanner message={successMessage} variant="success" /> : null}
<AuthEmailPasswordForm <form className="space-y-4" onSubmit={onSubmit}>
email={email} <div className="space-y-2">
password={password} <Label htmlFor="client-email">{t("common:labels.email")}</Label>
onEmailChange={setEmail} <Input
onPasswordChange={setPassword} id="client-email"
onSubmit={onSubmit} type="email"
submitLabel="Connexion" value={email}
emailLabel={t("common:labels.email")} onChange={(event) => setEmail(event.target.value)}
passwordLabel={t("common:labels.password")} placeholder={t("auth:login.emailPlaceholder")}
emailPlaceholder={t("auth:login.emailPlaceholder")} autoComplete="email"
passwordPlaceholder={t("auth:login.passwordPlaceholder")} required
isPending={isPending} />
extraContent={ </div>
<div className="flex items-center justify-end">
<Link <Button type="submit" className="w-full" disabled={isPending}>
to="/reset-password" {isPending ? "Envoi en cours..." : "Recevoir un lien de connexion"}
className="text-sm text-[#804EEC] transition-colors hover:text-[#6f3fd4]" </Button>
> </form>
{t("auth:login.forgotPassword")}
</Link>
</div>
}
/>
</div> </div>
</AuthCardShell> </AuthCardShell>
); );

View file

@ -1,20 +1,14 @@
import { Route, Routes } from "react-router-dom"; import { Route, Routes } from "react-router-dom";
import { ClientAuthGate } from "./components/ClientAuthGate"; import { ClientAuthGate } from "./components/ClientAuthGate";
import { ClientLayout } from "./components/ClientLayout"; import { ClientLayout } from "./components/ClientLayout";
import { AuthCallback } from "./pages/AuthCallback";
import { ClientTabloListPage } from "./pages/ClientTabloListPage"; import { ClientTabloListPage } from "./pages/ClientTabloListPage";
import { ClientTabloPage } from "./pages/ClientTabloPage"; import { ClientTabloPage } from "./pages/ClientTabloPage";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { ResetPasswordPage } from "./pages/ResetPasswordPage";
import { SetPasswordPage } from "./pages/SetPasswordPage";
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route element={<ClientAuthGate />}> <Route element={<ClientAuthGate />}>
<Route element={<ClientLayout />}> <Route element={<ClientLayout />}>
<Route path="/tablo/:tabloId" element={<ClientTabloPage />} /> <Route path="/tablo/:tabloId" element={<ClientTabloPage />} />

View file

@ -156,6 +156,7 @@ export type Database = {
jti: string | null; jti: string | null;
purpose: string; purpose: string;
redirect_to: string | null; redirect_to: string | null;
tablo_id: string | null;
token_hash: string | null; token_hash: string | null;
}; };
Insert: { Insert: {
@ -169,6 +170,7 @@ export type Database = {
jti?: string | null; jti?: string | null;
purpose: string; purpose: string;
redirect_to?: string | null; redirect_to?: string | null;
tablo_id?: string | null;
token_hash?: string | null; token_hash?: string | null;
}; };
Update: { Update: {
@ -182,6 +184,7 @@ export type Database = {
jti?: string | null; jti?: string | null;
purpose?: string; purpose?: string;
redirect_to?: string | null; redirect_to?: string | null;
tablo_id?: string | null;
token_hash?: string | null; token_hash?: string | null;
}; };
Relationships: [ Relationships: [
@ -199,6 +202,27 @@ export type Database = {
referencedRelation: "profiles"; referencedRelation: "profiles";
referencedColumns: ["id"]; 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: { clients: {

View file

@ -49,6 +49,7 @@ interface SingleTabloViewProps {
}; };
activeTab: SingleTabloTabId; activeTab: SingleTabloTabId;
onTabChange: (tabId: SingleTabloTabId) => void; onTabChange: (tabId: SingleTabloTabId) => void;
hiddenTabs?: SingleTabloTabId[];
hasUnreadDiscussion?: boolean; hasUnreadDiscussion?: boolean;
discussionAction?: DiscussionAction; discussionAction?: DiscussionAction;
canInviteMembers?: boolean; canInviteMembers?: boolean;
@ -64,6 +65,7 @@ export function SingleTabloView({
progress, progress,
activeTab, activeTab,
onTabChange, onTabChange,
hiddenTabs = [],
hasUnreadDiscussion = false, hasUnreadDiscussion = false,
discussionAction, discussionAction,
canInviteMembers = false, 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 tab.id === "discussion" ? { ...tab, hasUnread: hasUnreadDiscussion } : tab
); );

8
supabase/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local

384
supabase/config.toml Normal file
View file

@ -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:<port>/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://<database>/<schema>/<hook_name>"
# 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. <bucket_name>.s3-<region>.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)"

View file

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

View file

@ -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, client_id uuid not null references public.clients(id) on delete cascade,
email text not null, email text not null,
purpose text not null check (purpose in ('invite', 'login')), purpose text not null check (purpose in ('invite', 'login')),
tablo_id text references public.tablos(id) on delete cascade,
token_hash text, token_hash text,
jti text, jti text,
redirect_to 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) on public.client_magic_links (client_id, purpose, expires_at)
where consumed_at is null; 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 create unique index if not exists client_magic_links_jti_unique_idx
on public.client_magic_links (jti) on public.client_magic_links (jti)
where jti is not null; where jti is not null;