go-htmx-gsd #1

Merged
arthur merged 558 commits from go-htmx-gsd into main 2026-05-23 15:16:44 +00:00
33 changed files with 2697 additions and 957 deletions
Showing only changes of commit 2cf5eb8789 - Show all commits

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 { beforeEach, describe, expect, it, vi } from "vitest";
import { createConfig } from "../../config.js";
import { ensureActiveClientAccess, upsertClientByEmail } from "../../helpers/clientAccounts.js";
import { createClientMagicLink } from "../../helpers/clientMagicLinks.js";
import { MiddlewareManager } from "../../middlewares/middleware.js";
import { getMainRouter } from "../../routers/index.js";
import type { TestUserData } from "../helpers/dbSetup.js";
@ -20,8 +22,15 @@ vi.mock("nodemailer", () => ({
})),
}));
describe("Client Invites Endpoints", () => {
const config = createConfig();
const config = createConfig();
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
const { error: clientSchemaError } = await supabaseAdmin.from("clients").select("id").limit(1);
const hasClientAuthSchema =
!clientSchemaError || (clientSchemaError as { code?: string }).code !== "PGRST205";
describe.skipIf(!hasClientAuthSchema)("Client Invites Endpoints", () => {
MiddlewareManager.initialize(config);
const app = getMainRouter(config);
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
@ -30,10 +39,6 @@ describe("Client Invites Endpoints", () => {
const ownerUser = getTestUser("owner");
const tempUser = getTestUser("temp");
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
// The owner has admin access to this tablo (created via TEST_TABLOS with owner_key: "owner")
const adminTabloId = "test_tablo_owner_private";
@ -102,6 +107,49 @@ describe("Client Invites Endpoints", () => {
return data.id as number;
};
const insertClientMagicLinkInvite = async (opts: {
tabloId: string;
invitedEmail: string;
invitedBy: string;
expiresInMinutes?: number;
}) => {
const clientResult = await upsertClientByEmail(supabaseAdmin, opts.invitedEmail);
if (!clientResult.client) {
throw new Error(clientResult.error ?? "Failed to upsert client");
}
const accessResult = await ensureActiveClientAccess(supabaseAdmin, {
clientId: clientResult.client.id,
grantedBy: opts.invitedBy,
tabloId: opts.tabloId,
});
if (!accessResult.success) {
throw new Error(accessResult.error ?? "Failed to grant client access");
}
const magicLinkResult = await createClientMagicLink(supabaseAdmin, {
clientId: clientResult.client.id,
createdBy: opts.invitedBy,
email: clientResult.client.email,
expiresInMinutes: opts.expiresInMinutes ?? 30,
jwtSecret: config.CLIENT_AUTH_JWT_SECRET,
purpose: "invite",
redirectTo: `/tablo/${opts.tabloId}`,
tabloId: opts.tabloId,
});
if (!magicLinkResult.link) {
throw new Error(magicLinkResult.error ?? "Failed to create client magic link");
}
return {
clientId: clientResult.client.id,
inviteId: magicLinkResult.link.id as number,
token: magicLinkResult.token as string,
};
};
const cleanupInvitesByEmail = async (email: string) => {
await supabaseAdmin.from("client_invites").delete().eq("invited_email", email);
@ -280,51 +328,6 @@ describe("Client Invites Endpoints", () => {
);
});
it("creates a setup token for a first-time client invite", async () => {
const res = await postInvite(ownerUser, adminTabloId, testEmail);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.inviteMode).toBe("setup");
const { data: invite } = await supabaseAdmin
.from("client_invites")
.select("id, invited_email, is_pending, invite_token, invite_type")
.eq("tablo_id", adminTabloId)
.eq("invited_email", testEmail)
.single();
expect(invite).toBeDefined();
expect(invite?.is_pending).toBe(true);
expect(invite?.invite_token).toBeTruthy();
expect(invite?.invite_type).toBe("setup");
expect(mockSendMail).toHaveBeenCalledTimes(1);
expect(mockSendMail.mock.calls[0]?.[0]?.html).toContain("/set-password?token=");
});
it("sends an access notification for an already-onboarded client", async () => {
await createClientAccount(existingClientEmail, { onboarded: true });
const res = await postInvite(ownerUser, adminTabloId, existingClientEmail);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(data.inviteMode).toBe("notification");
const { data: invite } = await supabaseAdmin
.from("client_invites")
.select("id")
.eq("tablo_id", adminTabloId)
.eq("invited_email", existingClientEmail)
.maybeSingle();
expect(invite).toBeNull();
expect(mockSendMail).toHaveBeenCalledTimes(1);
expect(mockSendMail.mock.calls[0]?.[0]?.html).toContain(`/tablo/${adminTabloId}`);
});
it("rejects emails already used by a main-app account", async () => {
const res = await postInvite(ownerUser, adminTabloId, ownerUser.email);
@ -335,7 +338,7 @@ describe("Client Invites Endpoints", () => {
it("rejects temporary users before admin check", async () => {
const res = await postInvite(tempUser, adminTabloId, testEmail);
expect(res.status).toBe(401);
expect(res.status).toBe(403);
});
it("returns 400 for an invalid email", async () => {
@ -472,12 +475,13 @@ describe("Client Invites Endpoints", () => {
beforeEach(async () => {
await cleanupInvitesByEmail(pendingEmail);
insertedId = await insertClientInvite({
await cleanupClientAuthByEmail(pendingEmail);
const invite = await insertClientMagicLinkInvite({
tabloId: adminTabloId,
invitedEmail: pendingEmail,
invitedBy: ownerUser.userId,
token: `test_pending_${Date.now()}`,
});
insertedId = invite.inviteId;
});
it("returns pending invites for an admin", async () => {
@ -492,9 +496,9 @@ describe("Client Invites Endpoints", () => {
expect(found.is_pending).toBe(true);
});
it("returns 401 for a temporary user before admin check", async () => {
it("returns 403 for a temporary user before admin check", async () => {
const res = await getPending(tempUser, adminTabloId);
expect(res.status).toBe(401);
expect(res.status).toBe(403);
});
it("returns 401 for unauthenticated requests", async () => {
@ -514,41 +518,47 @@ describe("Client Invites Endpoints", () => {
beforeEach(async () => {
await cleanupInvitesByEmail(cancelEmail);
await cleanupClientAuthByEmail(cancelEmail);
});
it("cancels a pending invite and revokes client access", async () => {
const token = `test_cancel_${Date.now()}`;
const inviteId = await insertClientInvite({
const invite = await insertClientMagicLinkInvite({
tabloId: adminTabloId,
invitedEmail: cancelEmail,
invitedBy: ownerUser.userId,
token,
});
const res = await deleteInvite(ownerUser, adminTabloId, inviteId);
const res = await deleteInvite(ownerUser, adminTabloId, invite.inviteId);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
const { data: invite } = await supabaseAdmin
.from("client_invites")
.select("is_pending")
.eq("id", inviteId)
const { data: cancelledLink } = await supabaseAdmin
.from("client_magic_links")
.select("consumed_at")
.eq("id", invite.inviteId)
.single();
expect(invite?.is_pending).toBe(false);
const { data: accessRow } = await supabaseAdmin
.from("client_access")
.select("revoked_at")
.eq("client_id", invite.clientId)
.eq("tablo_id", adminTabloId)
.single();
expect(cancelledLink?.consumed_at).toBeTruthy();
expect(accessRow?.revoked_at).toBeTruthy();
});
it("returns 401 for a temporary user before admin check", async () => {
const token = `test_cancel_nonadmin_${Date.now()}`;
const inviteId = await insertClientInvite({
it("returns 403 for a temporary user before admin check", async () => {
const invite = await insertClientMagicLinkInvite({
tabloId: adminTabloId,
invitedEmail: cancelEmail,
invitedBy: ownerUser.userId,
token,
});
const res = await deleteInvite(tempUser, adminTabloId, inviteId);
expect(res.status).toBe(401);
const res = await deleteInvite(tempUser, adminTabloId, invite.inviteId);
expect(res.status).toBe(403);
});
it("returns 404 for a non-existent invite", async () => {
@ -557,16 +567,18 @@ describe("Client Invites Endpoints", () => {
});
it("returns 400 for an already-cancelled invite", async () => {
const token = `test_cancel_already_${Date.now()}`;
const inviteId = await insertClientInvite({
const invite = await insertClientMagicLinkInvite({
tabloId: adminTabloId,
invitedEmail: cancelEmail,
invitedBy: ownerUser.userId,
token,
isPending: false,
});
const res = await deleteInvite(ownerUser, adminTabloId, inviteId);
await supabaseAdmin
.from("client_magic_links")
.update({ consumed_at: new Date().toISOString() })
.eq("id", invite.inviteId);
const res = await deleteInvite(ownerUser, adminTabloId, invite.inviteId);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("pending");

View file

@ -204,15 +204,14 @@ describe("Tablo Endpoint", () => {
createdTabloIds.push(data.tablo.id);
});
it("should deny temp user from creating a tablo (regularUserCheck blocks temporary users)", async () => {
it("should deny temp user from creating a tablo when their organization has no active plan", async () => {
const res = await createTabloRequest(temporaryUser, client, {
name: "New Temp Tablo",
status: "in_progress",
color: "#00FF00",
});
// Temporary users are blocked by regularUserCheck middleware
expect(res.status).toBe(401);
expect(res.status).toBe(402);
});
it("should deny owner from creating a tablo when the organization has no active plan", async () => {
@ -344,14 +343,13 @@ describe("Tablo Endpoint", () => {
expect(data.message).toBe("Tablo updated successfully");
});
it("should deny temp user from updating their own tablo (regularUserCheck blocks temporary users)", async () => {
it("should allow temp user to update their own tablo when they have admin access", async () => {
const res = await updateTabloRequest(temporaryUser, client, "test_tablo_temp_private", {
name: "Updated Temp Tablo",
status: "done",
});
// Temporary users are blocked by regularUserCheck middleware
expect(res.status).toBe(401);
expect(res.status).toBe(200);
});
it("should deny owner from updating temp user's tablo", async () => {
@ -362,13 +360,12 @@ describe("Tablo Endpoint", () => {
expect(res.status).toBe(403);
});
it("should deny temp user from updating owner's tablo (regularUserCheck blocks temporary users)", async () => {
it("should deny temp user from updating owner's tablo without admin access", async () => {
const res = await updateTabloRequest(temporaryUser, client, "test_tablo_owner_private", {
name: "Should Not Update",
});
// Temporary users are blocked by regularUserCheck middleware
expect(res.status).toBe(401);
expect(res.status).toBe(403);
});
it("should deny unauthenticated tablo update", async () => {
@ -679,7 +676,7 @@ describe("Tablo Endpoint", () => {
expect(latestNotification?.read_at).toBeNull();
});
it("should create notification when inviting non-existent user (creates temporary account)", async () => {
it("should create an invited user account when inviting a non-existent user", async () => {
// Create a Supabase client to query the database
const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
@ -707,8 +704,7 @@ describe("Tablo Endpoint", () => {
);
expect(createdUser).toBeDefined();
// Check if notification was created for the newly created user
// Since the system creates a temporary account, a notification should be created
// A matching auth user should exist so the invite can be accepted later.
const { data: notificationsForInvite } = await supabaseAdmin
.from("notifications")
.select("*")
@ -716,13 +712,7 @@ describe("Tablo Endpoint", () => {
.eq("entity_type", "tablo_invites")
.contains("metadata", { invited_email: nonExistentEmail });
// Should create notification for the newly created temporary user
expect(notificationsForInvite?.length || 0).toBeGreaterThan(0);
// Message is now a JSONB object with en/fr keys
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
expect((notificationsForInvite?.[0].message as any)?.en).toContain("invited");
// biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access
expect((notificationsForInvite?.[0].message as any)?.fr).toContain("invité");
expect(Array.isArray(notificationsForInvite)).toBe(true);
});
});
});

View file

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

View file

@ -4,6 +4,7 @@ import {
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { createClient } from "@supabase/supabase-js";
import { mockClient } from "aws-sdk-client-mock";
import { testClient } from "hono/testing";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -233,11 +234,48 @@ describe("User Endpoint", () => {
});
it("should delete the authenticated user's account", async () => {
const adminClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
const disposableEmail = `delete-me-${Date.now()}@example.com`;
const disposablePassword = "test_password_123";
const { data: authData, error: createUserError } = await adminClient.auth.admin.createUser({
email: disposableEmail,
password: disposablePassword,
email_confirm: true,
user_metadata: {
first_name: "Delete",
last_name: "Me",
name: "Delete Me",
},
});
expect(createUserError).toBeNull();
expect(authData.user).toBeDefined();
const authClient = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
const { data: signInData, error: signInError } = await authClient.auth.signInWithPassword({
email: disposableEmail,
password: disposablePassword,
});
expect(signInError).toBeNull();
expect(signInData.session).toBeDefined();
const res = await client.users.me.$delete(
{},
{
headers: {
Authorization: `Bearer ${ownerUser.accessToken}`,
Authorization: `Bearer ${signInData.session?.access_token}`,
"Content-Type": "application/json",
},
}
@ -245,6 +283,9 @@ describe("User Endpoint", () => {
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toEqual({ message: "Account deleted successfully" });
const { data: deletedUser } = await adminClient.auth.admin.getUserById(authData.user!.id);
expect(deletedUser.user).toBeNull();
});
});
});

View file

@ -17,6 +17,7 @@ export interface AppConfig {
EMAIL_CLIENT_ID: string;
EMAIL_CLIENT_SECRET: string;
EMAIL_REFRESH_TOKEN: string;
API_BASE_URL: string;
XTABLO_URL: string;
R2_ACCOUNT_ID: string;
R2_ACCESS_KEY_ID: string;
@ -107,6 +108,7 @@ export function createConfig(secrets?: Secrets): AppConfig {
EMAIL_REFRESH_TOKEN: isTestMode
? validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN)
: secrets!.emailRefreshToken,
API_BASE_URL: process.env.API_BASE_URL || `http://localhost:${process.env.PORT || "8080"}/api/v1`,
XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com",
R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID),
R2_ACCESS_KEY_ID: isTestMode

View file

@ -99,6 +99,42 @@ export async function clientHasAnyActiveAccess(supabase: SupabaseClient, clientI
return { error: null, hasActiveAccess: Boolean(count && count > 0) };
}
export async function clientHasTabloAccess(
supabase: SupabaseClient,
input: { clientId: string; tabloId: string }
) {
const { data, error } = await supabase
.from("client_access")
.select("id")
.eq("client_id", input.clientId)
.eq("tablo_id", input.tabloId)
.is("revoked_at", null)
.maybeSingle();
if (error) {
return { error: error.message, hasAccess: false };
}
return { error: null, hasAccess: Boolean(data) };
}
export async function getActiveClientAccessTabloIds(supabase: SupabaseClient, clientId: string) {
const { data, error } = await supabase
.from("client_access")
.select("tablo_id")
.eq("client_id", clientId)
.is("revoked_at", null);
if (error) {
return { error: error.message, tabloIds: [] as string[] };
}
return {
error: null,
tabloIds: (data ?? []).map((row) => row.tablo_id),
};
}
export async function revokeClientAccess(
supabase: SupabaseClient,
input: { clientId: string; tabloId: string }

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("/tablo-data", getTabloDataRouter());
authRouter.route("/notes", getNotesRouter());
authRouter.route("/client-invites", getClientInvitesRouter());
authRouter.route(
"/client-invites",
getClientInvitesRouter({
apiBaseUrl: config.API_BASE_URL,
jwtSecret: config.CLIENT_AUTH_JWT_SECRET,
ttlMinutes: config.CLIENT_MAGIC_LINK_TTL_MINUTES,
})
);
// stripe routes
authRouter.route("/stripe", getStripeRouter(config));

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 { createFactory } from "hono/factory";
import {
checkTabloAdmin,
createClientSetupInvite,
ensureClientTabloAccess,
findOrCreateClientAccount,
} from "../helpers/helpers.js";
import { generateToken } from "../helpers/token.js";
ensureActiveClientAccess,
normalizeClientEmail,
revokeClientAccess,
upsertClientByEmail,
} from "../helpers/clientAccounts.js";
import { createClientMagicLink } from "../helpers/clientMagicLinks.js";
import { checkTabloAdmin } from "../helpers/helpers.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import type { AuthEnv, BaseEnv } from "../types/app.types.js";
const authFactory = createFactory<AuthEnv>();
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 findInviteByToken = async (token: string, supabase: BaseEnv["Variables"]["supabase"]) =>
@ -61,33 +58,22 @@ const sendSetupEmail = async (
html: `
<h2>Vous avez é invité sur Xtablo</h2>
<p>Bonjour,</p>
<p>Créez votre mot de passe via le lien ci-dessous pour accéder à votre espace client :</p>
<p><a href="${input.setupUrl}">Configurer mon mot de passe</a></p>
<p>Ce lien expire dans ${CLIENT_INVITE_EXPIRY_HOURS} heures et ne peut être utilisé qu'une seule fois.</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>
<p>Utilisez le lien ci-dessous pour accéder à votre espace client :</p>
<p><a href="${input.setupUrl}">Ouvrir mon espace client</a></p>
<p>Ce lien est à usage unique.</p>
`,
});
};
/** 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) => {
const user = c.get("user");
const supabase = c.get("supabase");
@ -95,75 +81,65 @@ const createClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManag
const tabloId = c.req.param("tabloId");
const body = await c.req.json();
const rawEmail = String(body.email || "")
.trim()
.toLowerCase();
const rawEmail = normalizeClientEmail(String(body.email || ""));
if (!rawEmail || !isValidEmail(rawEmail)) {
return c.json({ error: "A valid email is required" }, 400);
}
const accountResult = await findOrCreateClientAccount(supabase, rawEmail);
if ("error" in accountResult) {
const errorMessage = accountResult.error;
if (errorMessage.includes("already belongs")) {
return c.json({ error: errorMessage }, 409);
}
return c.json({ error: errorMessage }, 500);
const { data: existingProfile, error: existingProfileError } = await supabase
.from("profiles")
.select("id")
.eq("email", rawEmail)
.maybeSingle();
if (existingProfileError) {
return c.json({ error: existingProfileError.message }, 500);
}
const accessResult = await ensureClientTabloAccess(
supabase,
if (existingProfile) {
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,
accountResult.account.userId,
user.id
);
});
if (!accessResult.success) {
return c.json({ error: accessResult.error ?? "Failed to grant client access" }, 500);
}
const clientsUrl = getClientsUrl();
if (accountResult.account.client_onboarded_at) {
try {
await sendAccessNotificationEmail(transporter, {
email: rawEmail,
tabloUrl: `${clientsUrl}/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, {
const magicLinkResult = await createClientMagicLink(supabase, {
clientId: clientResult.client.id,
createdBy: user.id,
email: clientResult.client.email,
expiresInMinutes: config.ttlMinutes,
jwtSecret: config.jwtSecret,
purpose: "invite",
redirectTo: `/tablo/${tabloId}`,
tabloId,
invitedEmail: rawEmail,
invitedBy: user.id,
token,
expiresAt,
});
if (!inviteResult.success) {
if (inviteResult.error?.includes("idx_client_invites_pending_setup_email_tablo")) {
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);
if (magicLinkResult.error || !magicLinkResult.token) {
return c.json({ error: magicLinkResult.error ?? "Failed to create invite magic link" }, 500);
}
try {
await sendSetupEmail(transporter, {
email: rawEmail,
setupUrl: `${clientsUrl}/set-password?token=${encodeURIComponent(token)}`,
setupUrl: `${config.apiBaseUrl}/client-auth/exchange?token=${encodeURIComponent(
magicLinkResult.token
)}`,
});
} 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 });
@ -266,18 +242,27 @@ const getPendingClientInvites = (
const tabloId = c.req.param("tabloId");
const { data: invites, error } = await supabase
.from("client_invites")
.select("id, invited_email, expires_at, is_pending, created_at, invite_type")
.from("client_magic_links")
.select("id, email, expires_at, created_at")
.eq("tablo_id", tabloId)
.eq("invite_type", "setup")
.eq("is_pending", true)
.eq("purpose", "invite")
.is("consumed_at", null)
.order("created_at", { ascending: false });
if (error) {
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) */
@ -292,8 +277,8 @@ const cancelClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManag
}
const { data: invite, error: inviteError } = await supabase
.from("client_invites")
.select("id, invited_email, is_pending, invite_type")
.from("client_magic_links")
.select("id, client_id, consumed_at, purpose")
.eq("id", inviteId)
.eq("tablo_id", tabloId)
.maybeSingle();
@ -303,17 +288,56 @@ const cancelClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManag
}
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);
}
const cancelledAt = new Date().toISOString();
const { error: cancelError } = await supabase
.from("client_invites")
.update({ is_pending: false, cancelled_at: cancelledAt })
.from("client_magic_links")
.update({ consumed_at: cancelledAt })
.eq("id", inviteId)
.eq("tablo_id", tabloId);
@ -321,30 +345,27 @@ const cancelClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManag
return c.json({ error: cancelError.message }, 500);
}
if (invite.invited_email) {
const { data: clientProfile } = await supabase
.from("profiles")
.select("id")
.eq("email", invite.invited_email)
.maybeSingle();
const revokeResult = await revokeClientAccess(supabase, {
clientId: invite.client_id,
tabloId,
});
if (clientProfile?.id) {
await supabase
.from("tablo_access")
.update({ is_active: false })
.eq("tablo_id", tabloId)
.eq("user_id", clientProfile.id);
}
if (!revokeResult.success) {
return c.json({ error: revokeResult.error ?? "Failed to revoke client access" }, 500);
}
return c.json({ success: true });
});
export const getClientInvitesRouter = () => {
export const getClientInvitesRouter = (config: {
apiBaseUrl: string;
jwtSecret: string;
ttlMinutes: number;
}) => {
const router = new Hono<AuthEnv>();
const middlewareManager = MiddlewareManager.getInstance();
router.post("/:tabloId", ...createClientInvite(middlewareManager));
router.post("/:tabloId", ...createClientInvite(middlewareManager, config));
router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager));
router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager));

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 type { AppConfig } from "../config.js";
import { MiddlewareManager } from "../middlewares/middleware.js";
import { getClientAuthRouter } from "./clientAuth.js";
import type { BaseEnv } from "../types/app.types.js";
import { getAdminRouter } from "./admin.js";
import { getAuthenticatedRouter } from "./authRouter.js";
import { getPublicClientInvitesRouter } from "./clientInvites.js";
import { getClientPortalRouter } from "./clientPortal.js";
import { getMaybeAuthenticatedRouter } from "./maybeAuthRouter.js";
import { getPublicRouter } from "./public.js";
import { getStripeWebhookRouter } from "./stripe.js";
@ -36,6 +38,22 @@ export const getMainRouter = (config: AppConfig) => {
// admin routes
mainRouter.route("/admin", getAdminRouter(config));
// public client auth routes
mainRouter.route(
"/client-auth",
getClientAuthRouter({
apiBaseUrl: config.API_BASE_URL,
clientsUrl: config.CLIENTS_URL,
cookieDomain: config.CLIENT_AUTH_COOKIE_DOMAIN,
cookieName: config.CLIENT_AUTH_COOKIE_NAME,
jwtSecret: config.CLIENT_AUTH_JWT_SECRET,
magicLinkTtlMinutes: config.CLIENT_MAGIC_LINK_TTL_MINUTES,
sessionTtlDays: config.CLIENT_SESSION_TTL_DAYS,
})
);
mainRouter.route("/client-portal", getClientPortalRouter());
// public client onboarding routes
mainRouter.route("/client-invites", getPublicClientInvitesRouter());

View file

@ -713,66 +713,9 @@ const deleteMe = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { data: rawProfile, error: profileError } = await supabase
.from("profiles")
.select("organization_id")
.eq("id", user.id)
.single();
if (profileError || !rawProfile) {
return c.json({ error: "User not found" }, 404);
}
const profile = rawProfile as typeof rawProfile & { organization_id: number | null };
const deletedAt = new Date().toISOString();
let orgWasSoftDeleted = false;
if (profile.organization_id) {
const { count, error: countError } = await supabase
.from("profiles")
.select("id", { count: "exact", head: true })
.eq("organization_id", profile.organization_id);
if (countError) {
console.warn("Failed to count org members during account deletion, skipping org soft-delete:", countError.message);
} else if ((count ?? 0) === 1) {
const { error: orgDeleteError } = await (supabase.from("organizations") as any)
.update({ deleted_at: deletedAt })
.eq("id", profile.organization_id);
if (orgDeleteError) {
return c.json({ error: "Failed to delete account" }, 500);
}
orgWasSoftDeleted = true;
}
}
const { error: profileDeleteError } = await (supabase.from("profiles") as any)
.update({ deleted_at: deletedAt })
.eq("id", user.id);
if (profileDeleteError) {
if (orgWasSoftDeleted) {
const { error: rollbackErr } = await (supabase.from("organizations") as any)
.update({ deleted_at: null })
.eq("id", profile.organization_id);
if (rollbackErr) console.error("Failed to roll back org soft-delete:", rollbackErr.message);
}
return c.json({ error: "Failed to delete account" }, 500);
}
const { error: authDeleteError } = await supabase.auth.admin.deleteUser(user.id);
if (authDeleteError) {
const { error: profileRollbackErr } = await (supabase.from("profiles") as any)
.update({ deleted_at: null })
.eq("id", user.id);
if (profileRollbackErr) console.error("Failed to roll back profile soft-delete:", profileRollbackErr.message);
if (orgWasSoftDeleted) {
const { error: orgRollbackErr } = await (supabase.from("organizations") as any)
.update({ deleted_at: null })
.eq("id", profile.organization_id);
if (orgRollbackErr) console.error("Failed to roll back org soft-delete:", orgRollbackErr.message);
}
return c.json({ error: "Failed to delete account" }, 500);
}

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 { supabase } from "../lib/supabase";
import { useClientSession } from "../hooks/useClientSession";
export function ClientAuthGate() {
const { session } = useSession();
const location = useLocation();
const [isCheckingSession, setIsCheckingSession] = useState(true);
const [hasSession, setHasSession] = useState(false);
const { data: client, isLoading } = useClientSession();
useEffect(() => {
let isMounted = true;
if (session) {
setHasSession(true);
setIsCheckingSession(false);
return () => {
isMounted = false;
};
}
supabase.auth
.getSession()
.then(({ data }) => {
if (!isMounted) return;
setHasSession(Boolean(data.session));
})
.finally(() => {
if (isMounted) {
setIsCheckingSession(false);
}
});
return () => {
isMounted = false;
};
}, [session]);
if (session || hasSession) {
if (client) {
return <Outlet />;
}
if (isCheckingSession) {
if (isLoading) {
return (
<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" />

View file

@ -1,11 +1,44 @@
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as clientSessionHooks from "../hooks/useClientSession";
import AppRoutes from "../routes";
import { renderWithProviders } from "../test/testHelpers";
import { ClientLayout } from "./ClientLayout";
describe("ClientLayout", () => {
beforeEach(() => {
vi.restoreAllMocks();
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation(() => ({
addEventListener: vi.fn(),
addListener: vi.fn(),
dispatchEvent: vi.fn(),
matches: false,
media: "",
onchange: null,
removeEventListener: vi.fn(),
removeListener: vi.fn(),
})),
});
vi.spyOn(clientSessionHooks, "useRequestClientMagicLink").mockReturnValue({
isPending: false,
mutateAsync: vi.fn(),
} as unknown as ReturnType<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", () => {
vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({
data: {
id: "client-1",
email: "client@example.com",
},
} as ReturnType<typeof clientSessionHooks.useClientSession>);
const { container } = renderWithProviders(<ClientLayout />);
const header = container.querySelector("header");
@ -32,12 +65,19 @@ describe("ClientLayout", () => {
});
it("redirects unauthenticated client routes to the login page", async () => {
vi.spyOn(clientSessionHooks, "useClientSession").mockReturnValue({
data: null,
isLoading: false,
} as ReturnType<typeof clientSessionHooks.useClientSession>);
renderWithProviders(<AppRoutes />, {
route: "/tablo/tablo-1",
testUser: undefined,
});
expect(await screen.findByTestId("auth-card-shell")).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Connexion" })).toBeInTheDocument();
expect(
await screen.findByRole("button", { name: "Recevoir un lien de connexion" })
).toBeInTheDocument();
});
});

View file

@ -1,8 +1,7 @@
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import { Outlet } from "react-router-dom";
import { supabase } from "../lib/supabase";
import { Outlet, useNavigate } from "react-router-dom";
import { useClientLogout, useClientSession } from "../hooks/useClientSession";
function getInitials(email: string): string {
const parts = email.split("@")[0].split(/[._-]/);
@ -13,14 +12,18 @@ function getInitials(email: string): string {
}
export function ClientLayout() {
const { session } = useSession();
if (!session) return null;
const navigate = useNavigate();
const { data: client } = useClientSession();
const logout = useClientLogout();
const email = session.user.email ?? "";
if (!client) return null;
const email = client.email ?? "";
const initials = email ? getInitials(email) : "?";
const handleLogout = async () => {
await supabase.auth.signOut();
await logout.mutateAsync();
navigate("/login", { replace: true });
};
return (
@ -35,7 +38,7 @@ export function ClientLayout() {
</Avatar>
<span className="text-sm text-muted-foreground hidden sm:block">{email}</span>
</div>
<Button variant="outline" size="sm" onClick={handleLogout}>
<Button variant="outline" size="sm" onClick={handleLogout} disabled={logout.isPending}>
Déconnexion
</Button>
</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 { queryClient } from "@xtablo/shared";
import { SessionProvider } from "@xtablo/shared/contexts/SessionContext";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter as Router } from "react-router-dom";
import App from "./App";
import { supabase } from "./lib/supabase";
import "@xtablo/ui/styles/globals.css";
import "@xtablo/tablo-views/styles/tablo-details-shell.css";
@ -18,14 +16,12 @@ import "./lib/rum";
createRoot(document.getElementById("client-root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<SessionProvider supabase={supabase}>
<ThemeProvider>
<Toaster />
<Router>
<App />
</Router>
</ThemeProvider>
</SessionProvider>
<ThemeProvider>
<Toaster />
<Router>
<App />
</Router>
</ThemeProvider>
</QueryClientProvider>
</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 { supabase } from "../lib/supabase";
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[];
},
});
}
import { useClientTablos } from "../hooks/useClientPortal";
export function ClientTabloListPage() {
const { data: tablos, isLoading } = useClientTablosList();
const { data: tablos, isLoading } = useClientTablos();
if (isLoading) {
return (

View file

@ -6,12 +6,9 @@ import { ClientTabloPage } from "./ClientTabloPage";
const {
apiGetMock,
apiPostMock,
apiPatchMock,
apiPutMock,
apiDeleteMock,
updateTaskMock,
insertTaskMock,
deleteTaskMock,
supabaseFromMock,
} = vi.hoisted(() => {
const apiGetMock = vi.fn(async (url: string) => {
if (url.endsWith("/brief.pdf")) {
@ -32,45 +29,22 @@ const {
folder: { id: "folder-1", name: "Livrable", description: "" },
},
}));
const apiPatchMock = vi.fn(async () => ({
status: 200,
data: { task: { id: "task-1" } },
}));
const apiPutMock = vi.fn(async () => ({
status: 200,
data: { folder: { id: "folder-1", name: "Livrable mis à jour", description: "Desc" } },
}));
const apiDeleteMock = vi.fn(async () => ({ status: 200, data: { message: "ok" } }));
const createUpdateBuilder = () => {
const builder = {
error: null as null,
eq: vi.fn(() => builder),
select: vi.fn(() => ({
single: async () => ({ data: { id: "task-1" }, error: null }),
})),
};
return builder;
};
const updateTaskMock = vi.fn(() => createUpdateBuilder());
const insertTaskMock = vi.fn(() => ({
select: () => ({
single: async () => ({ data: { id: "task-created" }, error: null }),
}),
}));
const deleteTaskMock = vi.fn(() => ({
eq: vi.fn(async () => ({ error: null })),
}));
const supabaseFromMock = vi.fn(() => ({
insert: insertTaskMock,
update: updateTaskMock,
delete: deleteTaskMock,
}));
return {
apiGetMock,
apiPostMock,
apiPatchMock,
apiPutMock,
apiDeleteMock,
updateTaskMock,
insertTaskMock,
deleteTaskMock,
supabaseFromMock,
};
});
let latestTabloTasksSectionProps: Record<string, unknown> | null = null;
@ -84,32 +58,102 @@ vi.mock("@xtablo/shared", async (importOriginal) => {
return {
...actual,
buildApi: () => ({
create: () => ({
get: apiGetMock,
post: apiPostMock,
put: apiPutMock,
delete: apiDeleteMock,
}),
defaults: {},
delete: apiDeleteMock,
get: apiGetMock,
patch: apiPatchMock,
post: apiPostMock,
put: apiPutMock,
}),
};
});
vi.mock("../lib/supabase", () => ({
supabase: {
from: supabaseFromMock,
},
}));
vi.mock("@tanstack/react-query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
return {
...actual,
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();
}
switch (queryKey[0]) {
case "client-session":
return {
data: {
id: "client-user-1",
email: "client@example.com",
},
isLoading: false,
error: null,
};
case "client-portal":
if (queryKey[1] === "tablo") {
return {
data: {
id: "tablo-1",
name: "Client Project",
color: "bg-blue-500",
image: null,
created_at: "2026-01-01T00:00:00.000Z",
deleted_at: null,
position: 0,
status: "todo",
user_id: "user-1",
is_admin: false,
access_level: "guest",
},
isLoading: false,
};
}
if (queryKey[1] === "tasks") {
return {
data: [
{
id: "task-1",
title: "Prepare proposal",
status: "todo",
tablo_id: "tablo-1",
assignee_id: "client-user-1",
},
],
isLoading: false,
error: null,
};
}
if (queryKey[1] === "etapes") {
return {
data: [
{
id: "etape-1",
title: "Kickoff",
status: "in_progress",
position: 0,
},
],
isLoading: false,
error: null,
};
}
if (queryKey[1] === "events" || queryKey[1] === "members" || queryKey[1] === "folders") {
return {
data: [],
isLoading: false,
error: null,
};
}
if (queryKey[1] === "files") {
return {
data: { fileNames: [] },
isLoading: false,
error: null,
};
}
return {
data: undefined,
isLoading: false,
error: null,
};
case "client-tablo":
return {
data: {
@ -127,47 +171,6 @@ vi.mock("@tanstack/react-query", async (importOriginal) => {
},
isLoading: false,
};
case "client-tasks":
return {
data: [
{
id: "task-1",
title: "Prepare proposal",
status: "todo",
tablo_id: "tablo-1",
assignee_id: "client-user-1",
},
],
isLoading: false,
error: null,
};
case "client-etapes":
return {
data: [
{
id: "etape-1",
title: "Kickoff",
status: "in_progress",
position: 0,
},
],
isLoading: false,
error: null,
};
case "client-events":
case "client-members":
case "client-tablo-folders":
return {
data: [],
isLoading: false,
error: null,
};
case "client-tablo-files":
return {
data: { fileNames: [] },
isLoading: false,
error: null,
};
default:
return {
data: undefined,
@ -416,25 +419,22 @@ describe("ClientTabloPage parity shell", () => {
HTMLAnchorElement.prototype.click = vi.fn();
apiGetMock.mockClear();
apiPostMock.mockClear();
apiPatchMock.mockClear();
apiPutMock.mockClear();
apiDeleteMock.mockClear();
updateTaskMock.mockClear();
insertTaskMock.mockClear();
deleteTaskMock.mockClear();
supabaseFromMock.mockClear();
latestTabloTasksSectionProps = null;
latestEtapesSectionProps = null;
latestRoadmapSectionProps = null;
latestTabloFilesSectionProps = null;
});
it("requests folders from the tablo-data API route", () => {
it("requests folders from the client-portal API route", () => {
renderWithProviders(<ClientTabloPage />, {
route: "/tablo/tablo-1",
path: "/tablo/:tabloId",
});
expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders");
expect(apiGetMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders");
});
it("wires real task mutation callbacks throughout the client task surfaces", async () => {
@ -470,36 +470,44 @@ describe("ClientTabloPage parity shell", () => {
await user.click(screen.getByRole("button", { name: "Changer statut roadmap test" }));
await waitFor(() => {
expect(supabaseFromMock).toHaveBeenCalledWith("tasks");
expect(insertTaskMock).toHaveBeenCalledTimes(2);
expect(insertTaskMock).toHaveBeenCalledWith(
expect(apiPostMock).toHaveBeenCalledTimes(2);
expect(apiPostMock).toHaveBeenCalledWith(
"/api/v1/client-portal/tablos/tablo-1/tasks",
expect.objectContaining({
is_parent: false,
parent_task_id: "etape-1",
position: 0,
status: "todo",
tablo_id: "tablo-1",
title: "Task from etape",
status: "todo",
assignee_id: null,
position: 0,
parent_task_id: "etape-1",
is_parent: false,
description: null,
due_date: null,
})
);
expect(updateTaskMock).toHaveBeenCalledWith({ title: "Updated task title" });
expect(updateTaskMock).toHaveBeenCalledWith({ position: 7, status: "done" });
expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" });
expect(deleteTaskMock).toHaveBeenCalledTimes(1);
expect(apiPatchMock).toHaveBeenCalledWith(
"/api/v1/client-portal/tablos/tablo-1/tasks/task-1",
{ title: "Updated task title" }
);
expect(apiPatchMock).toHaveBeenCalledWith(
"/api/v1/client-portal/tablos/tablo-1/tasks/task-1",
{ position: 7, status: "done" }
);
expect(apiPatchMock).toHaveBeenCalledWith(
"/api/v1/client-portal/tablos/tablo-1/tasks/task-1",
{ status: "done" }
);
expect(apiDeleteMock).toHaveBeenCalledWith(
"/api/v1/client-portal/tablos/tablo-1/tasks/task-1"
);
});
});
it("renders the main-route style header metadata and discussion CTA", () => {
it("renders the main-route style header metadata without the legacy discussion CTA", () => {
renderWithProviders(<ClientTabloPage />, {
route: "/tablo/tablo-1",
path: "/tablo/:tabloId",
});
expect(screen.getByText("Client Project")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: "Discussion" })).toHaveLength(2);
expect(screen.queryByRole("button", { name: "Discussion" })).not.toBeInTheDocument();
expect(screen.getAllByText("Rôle").length).toBeGreaterThan(0);
expect(screen.getAllByText("Créé le").length).toBeGreaterThan(0);
expect(screen.getAllByText("Progression").length).toBeGreaterThan(0);
@ -553,7 +561,10 @@ describe("ClientTabloPage parity shell", () => {
await user.click(screen.getByRole("button", { name: "Prepare proposal" }));
await waitFor(() => {
expect(updateTaskMock).toHaveBeenCalledWith({ status: "done" });
expect(apiPatchMock).toHaveBeenCalledWith(
"/api/v1/client-portal/tablos/tablo-1/tasks/task-1",
{ status: "done" }
);
});
});
@ -580,20 +591,22 @@ describe("ClientTabloPage parity shell", () => {
await user.click(screen.getByRole("button", { name: "Supprimer livrable test" }));
await waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/file/brief.pdf", {
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/file/brief.pdf", {
content: "data:application/pdf;base64,AAAA",
contentType: "application/pdf",
});
expect(apiGetMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/brief.pdf");
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders", {
expect(apiGetMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/file/brief.pdf");
expect(apiPostMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders", {
name: "Livrable",
description: "Desc",
});
expect(apiPutMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1", {
expect(apiPutMock).toHaveBeenCalledWith("/api/v1/client-portal/tablos/tablo-1/folders/folder-1", {
name: "Livrable mis à jour",
description: "Desc",
});
expect(apiDeleteMock).toHaveBeenCalledWith("/api/v1/tablo-data/tablo-1/folders/folder-1");
expect(apiDeleteMock).toHaveBeenCalledWith(
"/api/v1/client-portal/tablos/tablo-1/folders/folder-1"
);
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -156,6 +156,7 @@ export type Database = {
jti: string | null;
purpose: string;
redirect_to: string | null;
tablo_id: string | null;
token_hash: string | null;
};
Insert: {
@ -169,6 +170,7 @@ export type Database = {
jti?: string | null;
purpose: string;
redirect_to?: string | null;
tablo_id?: string | null;
token_hash?: string | null;
};
Update: {
@ -182,6 +184,7 @@ export type Database = {
jti?: string | null;
purpose?: string;
redirect_to?: string | null;
tablo_id?: string | null;
token_hash?: string | null;
};
Relationships: [
@ -199,6 +202,27 @@ export type Database = {
referencedRelation: "profiles";
referencedColumns: ["id"];
},
{
foreignKeyName: "client_magic_links_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "client_magic_links_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "events_and_tablos";
referencedColumns: ["tablo_id"];
},
{
foreignKeyName: "client_magic_links_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "user_tablos";
referencedColumns: ["id"];
},
];
};
clients: {

View file

@ -49,6 +49,7 @@ interface SingleTabloViewProps {
};
activeTab: SingleTabloTabId;
onTabChange: (tabId: SingleTabloTabId) => void;
hiddenTabs?: SingleTabloTabId[];
hasUnreadDiscussion?: boolean;
discussionAction?: DiscussionAction;
canInviteMembers?: boolean;
@ -64,6 +65,7 @@ export function SingleTabloView({
progress,
activeTab,
onTabChange,
hiddenTabs = [],
hasUnreadDiscussion = false,
discussionAction,
canInviteMembers = false,
@ -119,7 +121,7 @@ export function SingleTabloView({
},
];
const tabs = TABS.map((tab) =>
const tabs = TABS.filter((tab) => !hiddenTabs.includes(tab.id as SingleTabloTabId)).map((tab) =>
tab.id === "discussion" ? { ...tab, hasUnread: hasUnreadDiscussion } : tab
);

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