go-htmx-gsd #1
33 changed files with 2697 additions and 957 deletions
195
apps/api/src/__tests__/routes/clientAuth.test.ts
Normal file
195
apps/api/src/__tests__/routes/clientAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
130
apps/api/src/helpers/clientMagicLinks.ts
Normal file
130
apps/api/src/helpers/clientMagicLinks.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
246
apps/api/src/routers/clientAuth.ts
Normal file
246
apps/api/src/routers/clientAuth.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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 été 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 été 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));
|
||||
|
||||
|
|
|
|||
583
apps/api/src/routers/clientPortal.ts
Normal file
583
apps/api/src/routers/clientPortal.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
323
apps/clients/src/hooks/useClientPortal.ts
Normal file
323
apps/clients/src/hooks/useClientPortal.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
67
apps/clients/src/hooks/useClientSession.ts
Normal file
67
apps/clients/src/hooks/useClientSession.ts
Normal 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
8
apps/clients/src/lib/api.ts
Normal file
8
apps/clients/src/lib/api.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
8
supabase/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
384
supabase/config.toml
Normal file
384
supabase/config.toml
Normal 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)"
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue