diff --git a/apps/api/src/__tests__/helpers/billing.test.ts b/apps/api/src/__tests__/helpers/billing.test.ts index c43a874..62c0d11 100644 --- a/apps/api/src/__tests__/helpers/billing.test.ts +++ b/apps/api/src/__tests__/helpers/billing.test.ts @@ -28,12 +28,14 @@ describe("billing helpers", () => { id: "owner-user", created_at: "2026-01-01T10:00:00.000Z", is_temporary: false, + is_client: false, plan: "annual", }, { id: "late-user", created_at: "2026-01-02T10:00:00.000Z", is_temporary: false, + is_client: false, plan: "solo", }, ]); @@ -47,18 +49,21 @@ describe("billing helpers", () => { id: "user-1", created_at: "2026-01-01T10:00:00.000Z", is_temporary: false, + is_client: false, plan: "solo", }, { id: "temp-1", created_at: "2026-01-02T10:00:00.000Z", is_temporary: true, + is_client: false, plan: "solo", }, { id: "user-2", created_at: "2026-01-03T10:00:00.000Z", is_temporary: null, + is_client: false, plan: "team", }, ]); diff --git a/apps/api/src/__tests__/middlewares/middlewares.test.ts b/apps/api/src/__tests__/middlewares/middlewares.test.ts index 489506f..52a9b20 100644 --- a/apps/api/src/__tests__/middlewares/middlewares.test.ts +++ b/apps/api/src/__tests__/middlewares/middlewares.test.ts @@ -12,7 +12,7 @@ describe("Middleware Tests", () => { const middlewareManager = MiddlewareManager.getInstance(); const createProfilesSupabaseMock = (result: { - data: { is_temporary: boolean } | null; + data: { is_temporary?: boolean; is_client?: boolean } | null; error: { message: string } | null; }) => ({ from: vi.fn().mockReturnValue({ @@ -342,6 +342,33 @@ describe("Middleware Tests", () => { expect(res.status).toBe(401); expect(data.error).toBe("User is read only"); }); + + it("should return 401 for client users", async () => { + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createProfilesSupabaseMock({ + data: { is_temporary: false, is_client: true }, + error: null, + }) as any + ); + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set("user", { id: "client-user" } as any); + await next(); + }); + app.use(middlewareManager.regularUserCheck); + app.get("/test", (c) => c.json({ success: true })); + + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const res = await client.test.$get(); + const data = await res.json(); + + expect(res.status).toBe(401); + expect(data.error).toBe("User is read only"); + }); }); describe("Billing Checkout Access Middleware", () => { diff --git a/apps/api/src/__tests__/routes/clientInvites.test.ts b/apps/api/src/__tests__/routes/clientInvites.test.ts new file mode 100644 index 0000000..6b7cb25 --- /dev/null +++ b/apps/api/src/__tests__/routes/clientInvites.test.ts @@ -0,0 +1,386 @@ +import { createClient } from "@supabase/supabase-js"; +import { testClient } from "hono/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createConfig } from "../../config.js"; +import { MiddlewareManager } from "../../middlewares/middleware.js"; +import { getMainRouter } from "../../routers/index.js"; +import type { TestUserData } from "../helpers/dbSetup.js"; +import { getTestUser } from "../helpers/dbSetup.js"; + +// Mock nodemailer +const mockSendMail = vi.fn(); +vi.mock("nodemailer", () => ({ + default: { + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), + }, + createTransport: vi.fn(() => ({ + sendMail: mockSendMail, + })), +})); + +describe("Client Invites Endpoints", () => { + const config = createConfig(); + MiddlewareManager.initialize(config); + const app = getMainRouter(config); + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + + const ownerUser = getTestUser("owner"); + const tempUser = getTestUser("temp"); + + const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, + }); + + // The owner has admin access to this tablo (created via TEST_TABLOS with owner_key: "owner") + const adminTabloId = "test_tablo_owner_private"; + + beforeEach(() => { + vi.clearAllMocks(); + mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); + }); + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + const postInvite = (user: TestUserData, tabloId: string, email: string) => + client["client-invites"][":tabloId"].$post( + { param: { tabloId }, json: { email } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + const getPending = (user: TestUserData, tabloId: string) => + client["client-invites"][":tabloId"].pending.$get( + { param: { tabloId } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + const deleteInvite = (user: TestUserData, tabloId: string, inviteId: number) => + client["client-invites"][":tabloId"][":inviteId"].$delete( + { param: { tabloId, inviteId: String(inviteId) } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + const acceptInvite = (user: TestUserData, token: string) => + client["client-invites"].accept[":token"].$post( + { param: { token } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + + // ─── Helper: insert a client_invite row directly via admin ────────────────── + + const insertClientInvite = async (opts: { + tabloId: string; + invitedEmail: string; + invitedBy: string; + token: string; + isPending?: boolean; + expiresAt?: string; + }) => { + const expiresAt = opts.expiresAt ?? new Date(Date.now() + 72 * 60 * 60 * 1000).toISOString(); + + const { data, error } = await supabaseAdmin + .from("client_invites") + .insert({ + tablo_id: opts.tabloId, + invited_email: opts.invitedEmail, + invited_by: opts.invitedBy, + invite_token: opts.token, + is_pending: opts.isPending ?? true, + expires_at: expiresAt, + }) + .select("id") + .single(); + + if (error) throw new Error(`Failed to insert client_invite: ${error.message}`); + return data.id as number; + }; + + // ─── Cleanup helper ────────────────────────────────────────────────────────── + + const cleanupInvitesByEmail = async (email: string) => { + await supabaseAdmin.from("client_invites").delete().eq("invited_email", email); + // Also clean up any client user that may have been created + const { data: usersData } = await supabaseAdmin.auth.admin.listUsers(); + // biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime + const users = usersData as any; + // biome-ignore lint/suspicious/noExplicitAny: admin user type + const clientUser = users?.users?.find((u: any) => u.email === email); + if (clientUser) { + await supabaseAdmin.from("tablo_access").delete().eq("user_id", clientUser.id); + await supabaseAdmin.auth.admin.deleteUser(clientUser.id); + } + }; + + // ════════════════════════════════════════════════════════════════════════════ + // POST /:tabloId — Create client invite + // ════════════════════════════════════════════════════════════════════════════ + + describe("POST /client-invites/:tabloId", () => { + const testEmail = "test_client_invite_new@example.com"; + + beforeEach(async () => { + await cleanupInvitesByEmail(testEmail); + }); + + it("should create a client invite for a valid email (admin)", async () => { + const res = await postInvite(ownerUser, adminTabloId, testEmail); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + + // Verify row was inserted + const { data: invite } = await supabaseAdmin + .from("client_invites") + .select("id, invited_email, is_pending") + .eq("tablo_id", adminTabloId) + .eq("invited_email", testEmail) + .single(); + + expect(invite).toBeDefined(); + expect(invite?.is_pending).toBe(true); + }); + + it("should reject non-admin users with 403", async () => { + // tempUser is NOT admin of adminTabloId (owner user owns it) + const res = await postInvite(tempUser, adminTabloId, testEmail); + expect(res.status).toBe(403); + }); + + it("should return 400 for an invalid email", async () => { + const res = await postInvite(ownerUser, adminTabloId, "not-an-email"); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("valid email"); + }); + + it("should return 400 for a missing email", async () => { + const res = client["client-invites"][":tabloId"].$post( + { param: { tabloId: adminTabloId }, json: {} }, + { headers: { Authorization: `Bearer ${ownerUser.accessToken}` } } + ); + expect((await res).status).toBe(400); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"][":tabloId"].$post({ + param: { tabloId: adminTabloId }, + json: { email: testEmail }, + }); + expect(res.status).toBe(401); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // POST /accept/:token — Accept a client invite + // ════════════════════════════════════════════════════════════════════════════ + + describe("POST /client-invites/accept/:token", () => { + it("should accept an invite and return tabloId", async () => { + const token = `test_accept_valid_${Date.now()}`; + + // Insert invite for the owner user's email + await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: ownerUser.email, + invitedBy: ownerUser.userId, + token, + }); + + try { + const res = await acceptInvite(ownerUser, token); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.tabloId).toBe(adminTabloId); + + // Verify invite is now not pending + const { data: invite } = await supabaseAdmin + .from("client_invites") + .select("is_pending") + .eq("invite_token", token) + .single(); + expect(invite?.is_pending).toBe(false); + } finally { + await supabaseAdmin.from("client_invites").delete().eq("invite_token", token); + } + }); + + it("should return 410 for an expired invite", async () => { + const token = `test_expired_${Date.now()}`; + const pastDate = new Date(Date.now() - 1000).toISOString(); // already expired + + await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: ownerUser.email, + invitedBy: ownerUser.userId, + token, + expiresAt: pastDate, + }); + + try { + const res = await acceptInvite(ownerUser, token); + expect(res.status).toBe(410); + const data = await res.json(); + expect(data.error).toContain("expired"); + } finally { + await supabaseAdmin.from("client_invites").delete().eq("invite_token", token); + } + }); + + it("should return 403 when email does not match the authenticated user", async () => { + const token = `test_email_mismatch_${Date.now()}`; + + // Invite is for tempUser's email but we authenticate as ownerUser + await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: tempUser.email, + invitedBy: ownerUser.userId, + token, + }); + + try { + const res = await acceptInvite(ownerUser, token); // wrong user + expect(res.status).toBe(403); + } finally { + await supabaseAdmin.from("client_invites").delete().eq("invite_token", token); + } + }); + + it("should return 404 for a non-existent token", async () => { + const res = await acceptInvite(ownerUser, "nonexistent_token_xyz"); + expect(res.status).toBe(404); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"].accept[":token"].$post({ + param: { token: "some_token" }, + }); + expect(res.status).toBe(401); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // GET /:tabloId/pending — List pending client invites + // ════════════════════════════════════════════════════════════════════════════ + + describe("GET /client-invites/:tabloId/pending", () => { + const pendingEmail = "test_client_pending_list@example.com"; + let insertedId: number; + + beforeEach(async () => { + await cleanupInvitesByEmail(pendingEmail); + insertedId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: pendingEmail, + invitedBy: ownerUser.userId, + token: `test_pending_${Date.now()}`, + }); + }); + + it("should return pending invites for an admin", async () => { + const res = await getPending(ownerUser, adminTabloId); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.invites)).toBe(true); + + const found = data.invites.find((inv: { id: number }) => inv.id === insertedId); + expect(found).toBeDefined(); + expect(found.invited_email).toBe(pendingEmail); + expect(found.is_pending).toBe(true); + }); + + it("should return 403 for a non-admin user", async () => { + const res = await getPending(tempUser, adminTabloId); + expect(res.status).toBe(403); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"][":tabloId"].pending.$get({ + param: { tabloId: adminTabloId }, + }); + expect(res.status).toBe(401); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // DELETE /:tabloId/:inviteId — Cancel a client invite + // ════════════════════════════════════════════════════════════════════════════ + + describe("DELETE /client-invites/:tabloId/:inviteId", () => { + const cancelEmail = "test_client_cancel@example.com"; + + beforeEach(async () => { + await cleanupInvitesByEmail(cancelEmail); + }); + + it("should cancel a pending invite and revoke client access", async () => { + // First create a client user and tablo_access entry via the API + const token = `test_cancel_${Date.now()}`; + const inviteId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: cancelEmail, + invitedBy: ownerUser.userId, + token, + }); + + // Create a mock profile to revoke (uses admin client to simulate client user existing) + // We'll skip verifying the user's actual auth account since we just need to test cancellation + const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + + // Verify invite is now not pending + const { data: invite } = await supabaseAdmin + .from("client_invites") + .select("is_pending") + .eq("id", inviteId) + .single(); + expect(invite?.is_pending).toBe(false); + }); + + it("should return 403 for a non-admin user", async () => { + const token = `test_cancel_nonadmin_${Date.now()}`; + const inviteId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: cancelEmail, + invitedBy: ownerUser.userId, + token, + }); + + const res = await deleteInvite(tempUser, adminTabloId, inviteId); + expect(res.status).toBe(403); + }); + + it("should return 404 for a non-existent invite", async () => { + const res = await deleteInvite(ownerUser, adminTabloId, 999999); + expect(res.status).toBe(404); + }); + + it("should return 400 for an already-cancelled invite", async () => { + const token = `test_cancel_already_${Date.now()}`; + const inviteId = await insertClientInvite({ + tabloId: adminTabloId, + invitedEmail: cancelEmail, + invitedBy: ownerUser.userId, + token, + isPending: false, // already cancelled + }); + + const res = await deleteInvite(ownerUser, adminTabloId, inviteId); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("pending"); + }); + + it("should return 401 for unauthenticated requests", async () => { + const res = await client["client-invites"][":tabloId"][":inviteId"].$delete({ + param: { tabloId: adminTabloId, inviteId: "1" }, + }); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/apps/api/src/helpers/billing.ts b/apps/api/src/helpers/billing.ts index 0e043d4..e0e10da 100644 --- a/apps/api/src/helpers/billing.ts +++ b/apps/api/src/helpers/billing.ts @@ -7,6 +7,7 @@ type BillingProfileRow = { id: string; created_at: string | null; is_temporary: boolean | null; + is_client: boolean | null; plan: string | null; }; @@ -87,7 +88,7 @@ export const parseTrialRolloutDate = ( export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null; export const getBillableMemberCount = (profiles: BillingProfileRow[]) => - profiles.filter((profile) => profile.is_temporary !== true).length; + profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length; export const getTrialWindow = (input: { ownerCreatedAt: Date; @@ -179,7 +180,7 @@ const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRo const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => { const { data, error } = await supabase .from("profiles") - .select("id, created_at, is_temporary, plan") + .select("id, created_at, is_temporary, is_client, plan") .eq("organization_id", organizationId) .order("created_at", { ascending: true }); diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 25ec729..f559288 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -363,3 +363,68 @@ export const createInvitedUser = async ( return { success: true, userId: newUser.user.id }; }; + +/** + * Creates or finds a client user, marks them as is_client, and grants tablo access. + */ +export async function createClientUser( + supabase: SupabaseClient, + recipientEmail: string, + tabloId: string, + grantedBy: string +): Promise<{ success: boolean; error?: string; userId?: string }> { + // Check if user already exists + const { data: existingUsersData } = await supabase.auth.admin.listUsers(); + // biome-ignore lint/suspicious/noExplicitAny: admin.listUsers returns typed data at runtime + const existingUsers = existingUsersData as any; + const existingUser = existingUsers?.users?.find( + // biome-ignore lint/suspicious/noExplicitAny: admin user type + (u: any) => u.email?.toLowerCase() === recipientEmail.toLowerCase() + ); + + let userId: string; + + if (existingUser) { + userId = existingUser.id; + // Mark as client if not already + await supabase + .from("profiles") + .update({ is_client: true }) + .eq("id", userId) + .eq("is_client", false); + } else { + // Create new auth user (no password — magic link only) + const { data: authData, error: authError } = await supabase.auth.admin.createUser({ + email: recipientEmail, + email_confirm: true, + user_metadata: { role: "client" }, + }); + if (authError || !authData?.user) { + return { success: false, error: authError?.message ?? "Failed to create user" }; + } + userId = authData.user.id; + await supabase.from("profiles").update({ is_client: true }).eq("id", userId); + } + + // Grant tablo access if not already granted + const { data: existingAccess } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", tabloId) + .eq("user_id", userId) + .single(); + + if (!existingAccess) { + await supabase.from("tablo_access").insert({ + tablo_id: tabloId, + user_id: userId, + granted_by: grantedBy, + is_admin: false, + is_active: true, + }); + } else if (!existingAccess.is_active) { + await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id); + } + + return { success: true, userId }; +} diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts index 773a20b..7c153e6 100644 --- a/apps/api/src/middlewares/middleware.ts +++ b/apps/api/src/middlewares/middleware.ts @@ -84,7 +84,7 @@ export class MiddlewareManager { const { data: profile, error } = await supabase .from("profiles") - .select("is_temporary") + .select("is_temporary, is_client") .eq("id", user.id) .single(); @@ -92,7 +92,7 @@ export class MiddlewareManager { return c.json({ error: error?.message ?? "Profile not found" }, 500); } - if (!allowTemporaryUsers && profile.is_temporary) { + if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) { return c.json({ error: "User is read only" }, 401); } diff --git a/apps/api/src/routers/authRouter.ts b/apps/api/src/routers/authRouter.ts index 41f2c53..4c308b8 100644 --- a/apps/api/src/routers/authRouter.ts +++ b/apps/api/src/routers/authRouter.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { AppConfig } from "../config.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; +import { getClientInvitesRouter } from "./clientInvites.js"; import { getNotesRouter } from "./notes.js"; import { getStripeRouter } from "./stripe.js"; import { getTabloRouter } from "./tablo.js"; @@ -19,6 +20,7 @@ export const getAuthenticatedRouter = (config: AppConfig) => { authRouter.route("/tablos", getTabloRouter(config)); authRouter.route("/tablo-data", getTabloDataRouter()); authRouter.route("/notes", getNotesRouter()); + authRouter.route("/client-invites", getClientInvitesRouter()); // stripe routes authRouter.route("/stripe", getStripeRouter(config)); diff --git a/apps/api/src/routers/clientInvites.ts b/apps/api/src/routers/clientInvites.ts new file mode 100644 index 0000000..c93f7c0 --- /dev/null +++ b/apps/api/src/routers/clientInvites.ts @@ -0,0 +1,223 @@ +import { Hono } from "hono"; +import { createFactory } from "hono/factory"; +import { checkTabloAdmin, createClientUser } from "../helpers/helpers.js"; +import { generateToken } from "../helpers/token.js"; +import { MiddlewareManager } from "../middlewares/middleware.js"; +import type { AuthEnv } from "../types/app.types.js"; + +const factory = createFactory(); + +const CLIENT_INVITE_EXPIRY_HOURS = 72; + +/** POST /:tabloId — Create a client invite (admin only) */ +const createClientInvite = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const body = await c.req.json(); + const rawEmail = String(body.email || "") + .trim() + .toLowerCase(); + + if (!rawEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail)) { + return c.json({ error: "A valid email is required" }, 400); + } + + // Create / find the client user and grant tablo access + const result = await createClientUser(supabase, rawEmail, tabloId, user.id); + if (!result.success || !result.userId) { + return c.json({ error: result.error ?? "Failed to create client user" }, 500); + } + + const token = generateToken(); + const expiresAt = new Date( + Date.now() + CLIENT_INVITE_EXPIRY_HOURS * 60 * 60 * 1000 + ).toISOString(); + + const { error: insertError } = await supabase.from("client_invites").insert({ + tablo_id: tabloId, + invited_email: rawEmail, + invited_by: user.id, + invite_token: token, + is_pending: true, + expires_at: expiresAt, + }); + + if (insertError) { + if (insertError.code === "23505") { + return c.json({ error: "A pending invite already exists for this email and tablo" }, 409); + } + return c.json({ error: insertError.message }, 500); + } + + // Generate a Supabase magic link that redirects to the client portal callback + const clientsUrl = process.env.CLIENTS_URL || "https://clients.xtablo.com"; + const redirectTo = `${clientsUrl}/auth/callback?token=${encodeURIComponent(token)}`; + + const { error: magicLinkError } = await supabase.auth.admin.generateLink({ + type: "magiclink", + email: rawEmail, + options: { redirectTo }, + }); + + if (magicLinkError) { + console.error("Failed to generate magic link:", magicLinkError); + // Non-fatal: invite record is already created + } + + return c.json({ success: true }); + }); + +/** POST /accept/:token — Accept a client invite */ +const acceptClientInvite = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const token = c.req.param("token"); + + const { data: invite, error: inviteError } = await supabase + .from("client_invites") + .select("id, tablo_id, invited_email, invited_by, is_pending, expires_at") + .eq("invite_token", token) + .maybeSingle(); + + if (inviteError) { + return c.json({ error: inviteError.message }, 500); + } + + if (!invite || !invite.is_pending) { + return c.json({ error: "Invite not found or already used" }, 404); + } + + // Check expiration + if (invite.expires_at && new Date(invite.expires_at) < new Date()) { + return c.json({ error: "This invite has expired" }, 410); + } + + // Email must match the authenticated user + if (invite.invited_email?.toLowerCase() !== user.email?.toLowerCase()) { + return c.json({ error: "This invite was not issued to your account" }, 403); + } + + // Mark invite as accepted + await supabase.from("client_invites").update({ is_pending: false }).eq("id", invite.id); + + // Ensure tablo access is active + const { data: existingAccess } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", invite.tablo_id) + .eq("user_id", user.id) + .maybeSingle(); + + if (!existingAccess) { + await supabase.from("tablo_access").insert({ + tablo_id: invite.tablo_id, + user_id: user.id, + granted_by: invite.invited_by, + is_admin: false, + is_active: true, + }); + } else if (!existingAccess.is_active) { + await supabase.from("tablo_access").update({ is_active: true }).eq("id", existingAccess.id); + } + + return c.json({ success: true, tabloId: invite.tablo_id }); + }); + +/** GET /:tabloId/pending — List pending client invites (admin only) */ +const getPendingClientInvites = ( + middlewareManager: ReturnType +) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data: invites, error } = await supabase + .from("client_invites") + .select("id, invited_email, expires_at, is_pending, created_at") + .eq("tablo_id", tabloId) + .eq("is_pending", true) + .order("created_at", { ascending: false }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ invites: invites ?? [] }); + }); + +/** DELETE /:tabloId/:inviteId — Cancel a client invite (admin only) */ +const cancelClientInvite = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + const inviteId = Number(c.req.param("inviteId")); + + if (!Number.isInteger(inviteId) || inviteId <= 0) { + return c.json({ error: "Invalid invite id" }, 400); + } + + const { data: invite, error: inviteError } = await supabase + .from("client_invites") + .select("id, invited_email, is_pending") + .eq("id", inviteId) + .eq("tablo_id", tabloId) + .maybeSingle(); + + if (inviteError) { + return c.json({ error: inviteError.message }, 500); + } + + if (!invite) { + return c.json({ error: "Invite not found" }, 404); + } + + if (!invite.is_pending) { + return c.json({ error: "Invite is no longer pending" }, 400); + } + + // Mark invite as cancelled + const { error: cancelError } = await supabase + .from("client_invites") + .update({ is_pending: false }) + .eq("id", inviteId) + .eq("tablo_id", tabloId); + + if (cancelError) { + return c.json({ error: cancelError.message }, 500); + } + + // Revoke tablo access for the client user + if (invite.invited_email) { + const { data: clientProfile } = await supabase + .from("profiles") + .select("id") + .eq("email", invite.invited_email) + .maybeSingle(); + + if (clientProfile?.id) { + await supabase + .from("tablo_access") + .update({ is_active: false }) + .eq("tablo_id", tabloId) + .eq("user_id", clientProfile.id); + } + } + + return c.json({ success: true }); + }); + +export const getClientInvitesRouter = () => { + const router = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + router.post("/:tabloId", ...createClientInvite(middlewareManager)); + router.post("/accept/:token", ...acceptClientInvite(middlewareManager)); + router.get("/:tabloId/pending", ...getPendingClientInvites(middlewareManager)); + router.delete("/:tabloId/:inviteId", ...cancelClientInvite(middlewareManager)); + + return router; +}; diff --git a/apps/clients/biome.json b/apps/clients/biome.json new file mode 100644 index 0000000..fa8b1ab --- /dev/null +++ b/apps/clients/biome.json @@ -0,0 +1,299 @@ +{ + "root": false, + "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", + "files": { + "ignoreUnknown": true, + "includes": ["src/**/*", "*.{tsx,js,jsx,json}", "vite.config.ts"] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100, + "attributePosition": "auto" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "complexity": { + "noAdjacentSpacesInRegex": "error", + "noBannedTypes": "error", + "noExtraBooleanCast": "error", + "noUselessCatch": "error", + "noUselessEscapeInRegex": "error", + "noUselessTypeConstraint": "error" + }, + "correctness": { + "noChildrenProp": "error", + "noConstAssign": "error", + "noConstantCondition": "error", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "error", + "noGlobalObjectCalls": "error", + "noInvalidBuiltinInstantiation": "error", + "noInvalidConstructorSuper": "error", + "noNonoctalDecimalEscape": "error", + "noPrecisionLoss": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedLabels": "error", + "noUnusedPrivateClassMembers": "error", + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useIsNan": "error", + "useJsxKeyInIterable": "error", + "useValidForDirection": "error", + "useValidTypeof": "error", + "useYield": "error" + }, + "nursery": {}, + "security": { "noDangerouslySetInnerHtmlWithChildren": "error" }, + "style": { + "noCommonJs": "error", + "noNamespace": "error", + "useArrayLiterals": "error", + "useAsConstAssertion": "error", + "useConst": "error", + "useTemplate": "error" + }, + "suspicious": { + "noAsyncPromiseExecutor": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCommentText": "error", + "noCompareNegZero": "error", + "noConstantBinaryExpressions": "error", + "noControlCharactersInRegex": "error", + "noDebugger": "error", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateElseIf": "error", + "noDuplicateJsxProps": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "error", + "noExplicitAny": "error", + "noExtraNonNullAssertion": "error", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noImportAssign": "error", + "noIrregularWhitespace": "error", + "noMisleadingCharacterClass": "error", + "noMisleadingInstantiator": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noShadowRestrictedNames": "error", + "noSparseArray": "error", + "noUnsafeDeclarationMerging": "error", + "noUnsafeNegation": "error", + "noUselessRegexBackrefs": "error", + "noWith": "error", + "useGetterReturn": "error", + "useNamespaceKeyword": "error" + } + } + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "es5", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSameLine": false, + "quoteStyle": "double", + "attributePosition": "auto", + "bracketSpacing": true + }, + "globals": [ + "onanimationend", + "ongamepadconnected", + "onlostpointercapture", + "onanimationiteration", + "onkeyup", + "onmousedown", + "onanimationstart", + "onslotchange", + "onprogress", + "ontransitionstart", + "onpause", + "onended", + "onpointerover", + "onscrollend", + "onformdata", + "ontransitionrun", + "onanimationcancel", + "ondrag", + "onchange", + "onbeforeinstallprompt", + "onbeforexrselect", + "onmessage", + "ontransitioncancel", + "onpointerdown", + "onabort", + "onpointerout", + "oncuechange", + "ongotpointercapture", + "onscrollsnapchanging", + "onsearch", + "onsubmit", + "onstalled", + "onsuspend", + "onreset", + "onerror", + "onresize", + "onmouseenter", + "ongamepaddisconnected", + "ondragover", + "onbeforetoggle", + "onmouseover", + "onpagehide", + "onmousemove", + "onratechange", + "onmessageerror", + "onwheel", + "ondevicemotion", + "onauxclick", + "ontransitionend", + "onpaste", + "onpageswap", + "ononline", + "ondeviceorientationabsolute", + "onkeydown", + "onclose", + "onselect", + "onpageshow", + "onpointercancel", + "onbeforematch", + "onpointerrawupdate", + "ondragleave", + "onscrollsnapchange", + "onseeked", + "onwaiting", + "onbeforeunload", + "onplaying", + "onvolumechange", + "ondragend", + "onstorage", + "onloadeddata", + "onfocus", + "onoffline", + "onplay", + "onafterprint", + "onclick", + "oncut", + "onmouseout", + "ondblclick", + "oncanplay", + "onloadstart", + "onappinstalled", + "onpointermove", + "ontoggle", + "oncontextmenu", + "onblur", + "oncancel", + "onbeforeprint", + "oncontextrestored", + "onloadedmetadata", + "onpointerup", + "onlanguagechange", + "oncopy", + "onselectstart", + "onscroll", + "onload", + "ondragstart", + "onbeforeinput", + "oncanplaythrough", + "oninput", + "oninvalid", + "ontimeupdate", + "ondurationchange", + "onselectionchange", + "onmouseup", + "location", + "onkeypress", + "onpointerleave", + "oncontextlost", + "ondrop", + "onsecuritypolicyviolation", + "oncontentvisibilityautostatechange", + "ondeviceorientation", + "onseeking", + "onrejectionhandled", + "onunload", + "onmouseleave", + "onhashchange", + "onpointerenter", + "onmousewheel", + "onunhandledrejection", + "ondragenter", + "onpopstate", + "onpagereveal", + "onemptied" + ] + }, + "json": { + "parser": { "allowComments": true, "allowTrailingCommas": false }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100, + "trailingCommas": "none" + } + }, + "overrides": [ + { "linter": { "rules": { "suspicious": { "noExplicitAny": "off" } } } }, + { "linter": { "rules": { "style": { "useNodejsImportProtocol": "off" } } } }, + { + "linter": { + "rules": { + "style": { "useNodejsImportProtocol": "off" }, + "suspicious": { "noExplicitAny": "off" } + } + } + }, + { + "includes": ["src/**/*.{ts,tsx}", "*.{ts,tsx}"], + "linter": { + "rules": { + "complexity": { "noArguments": "error" }, + "correctness": { + "noConstAssign": "off", + "noGlobalObjectCalls": "off", + "noInvalidBuiltinInstantiation": "off", + "noInvalidConstructorSuper": "off", + "noSetterReturn": "off", + "noUndeclaredVariables": "off", + "noUnreachable": "off", + "noUnreachableSuper": "off" + }, + "style": { "useConst": "error" }, + "suspicious": { + "noClassAssign": "off", + "noDuplicateClassMembers": "off", + "noDuplicateObjectKeys": "off", + "noDuplicateParameters": "off", + "noFunctionAssign": "off", + "noImportAssign": "off", + "noRedeclare": "off", + "noUnsafeNegation": "off", + "noVar": "error", + "useGetterReturn": "off" + } + } + } + } + ] +} diff --git a/apps/clients/index.html b/apps/clients/index.html new file mode 100644 index 0000000..9fb3e81 --- /dev/null +++ b/apps/clients/index.html @@ -0,0 +1,12 @@ + + + + + + Xtablo — Client Portal + + +
+ + + diff --git a/apps/clients/package.json b/apps/clients/package.json new file mode 100644 index 0000000..088606a --- /dev/null +++ b/apps/clients/package.json @@ -0,0 +1,50 @@ +{ + "name": "@xtablo/clients", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev --port 5175", + "build": "tsc -b && vite build --mode production", + "build:staging": "tsc -b && vite build --mode staging", + "build:prod": "tsc -b && vite build --mode production", + "deploy": "wrangler deploy", + "typecheck": "tsc -b", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "preview": "vite preview", + "clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@cloudflare/vite-plugin": "^1.9.4", + "@tailwindcss/vite": "^4.0.14", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.14", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.0", + "vite": "^6.2.2", + "vite-tsconfig-paths": "^5.1.4", + "wrangler": "^4.24.3" + }, + "dependencies": { + "@tanstack/react-query": "^5.69.0", + "@xtablo/shared": "workspace:*", + "@xtablo/shared-types": "workspace:*", + "@xtablo/tablo-views": "workspace:*", + "@xtablo/ui": "workspace:*", + "@xtablo/chat-ui": "workspace:*", + "i18next": "^25.6.0", + "i18next-browser-languagedetector": "^8.2.0", + "lucide-react": "^0.460.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-i18next": "^16.2.0", + "react-router-dom": "^7.9.4", + "tailwind-merge": "^3.0.2", + "zustand": "^5.0.5" + } +} diff --git a/apps/clients/src/App.tsx b/apps/clients/src/App.tsx new file mode 100644 index 0000000..3683784 --- /dev/null +++ b/apps/clients/src/App.tsx @@ -0,0 +1,9 @@ +import AppRoutes from "./routes"; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/clients/src/components/ClientLayout.tsx b/apps/clients/src/components/ClientLayout.tsx new file mode 100644 index 0000000..20719a0 --- /dev/null +++ b/apps/clients/src/components/ClientLayout.tsx @@ -0,0 +1,67 @@ +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"; + +function getInitials(email: string): string { + const parts = email.split("@")[0].split(/[._-]/); + return parts + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? "") + .join(""); +} + +export function ClientLayout() { + const { session } = useSession(); + + if (!session) { + return ( +
+
+

Accès non autorisé

+

+ Veuillez utiliser le lien reçu dans votre email pour accéder à cette page. +

+
+
+ ); + } + + const email = session.user.email ?? ""; + const initials = email ? getInitials(email) : "?"; + + const handleLogout = async () => { + await supabase.auth.signOut(); + }; + + return ( +
+ {/* Top bar */} +
+
+ {/* Brand */} + Xtablo + + {/* User info + logout */} +
+
+ + {initials} + + {email} +
+ +
+
+
+ + {/* Page content */} +
+ +
+
+ ); +} diff --git a/apps/clients/src/i18n.ts b/apps/clients/src/i18n.ts new file mode 100644 index 0000000..334b18e --- /dev/null +++ b/apps/clients/src/i18n.ts @@ -0,0 +1,31 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; +import bookingEn from "./locales/en/booking.json"; +// Import translation files +import bookingFr from "./locales/fr/booking.json"; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + fr: { + booking: bookingFr, + }, + en: { + booking: bookingEn, + }, + }, + fallbackLng: "fr", + defaultNS: "booking", + interpolation: { + escapeValue: false, + }, + detection: { + order: ["localStorage", "navigator"], + caches: ["localStorage"], + }, + }); + +export default i18n; diff --git a/apps/clients/src/lib/supabase.ts b/apps/clients/src/lib/supabase.ts new file mode 100644 index 0000000..99c2e17 --- /dev/null +++ b/apps/clients/src/lib/supabase.ts @@ -0,0 +1,10 @@ +import { createSupabaseClient } from "@xtablo/shared"; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("Missing Supabase environment variables"); +} + +export const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey); diff --git a/apps/clients/src/locales/en/booking.json b/apps/clients/src/locales/en/booking.json new file mode 100644 index 0000000..5c560df --- /dev/null +++ b/apps/clients/src/locales/en/booking.json @@ -0,0 +1,3 @@ +{ + "welcome": "Welcome" +} diff --git a/apps/clients/src/locales/fr/booking.json b/apps/clients/src/locales/fr/booking.json new file mode 100644 index 0000000..ead2829 --- /dev/null +++ b/apps/clients/src/locales/fr/booking.json @@ -0,0 +1,3 @@ +{ + "welcome": "Bienvenue" +} diff --git a/apps/clients/src/main.css b/apps/clients/src/main.css new file mode 100644 index 0000000..a896ff7 --- /dev/null +++ b/apps/clients/src/main.css @@ -0,0 +1,1266 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-navbar-background: #292e39; + --color-navbar-darker: #171920; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +.str-chat { + --str-chat__primary-color: #8b7396; + --str-chat__active-primary-color: #6e5c7d; + --str-chat__surface-color: #f5f3f7; + --str-chat__secondary-surface-color: #e8e4ec; + --str-chat__primary-surface-color: #ebe7f0; + --str-chat__primary-surface-color-low-emphasis: #f2f0f5; + --str-chat__border-radius-circle: 6px; +} + +.dark .str-chat { + --str-chat__primary-color: #a68bb5; + --str-chat__active-primary-color: #8b7396; + --str-chat__surface-color: rgba(120, 107, 130, 0.25); + --str-chat__secondary-surface-color: rgba(140, 130, 150, 0.18); + --str-chat__primary-surface-color: rgba(166, 139, 181, 0.12); + --str-chat__primary-surface-color-low-emphasis: rgba(166, 139, 181, 0.06); + --str-chat__background-color: rgba(110, 100, 120, 0.2); + --str-chat__secondary-background-color: rgba(80, 72, 88, 0.35); + --str-chat__border-color: rgba(120, 107, 130, 0.28); + --str-chat__text-color: #f5f3f7; + --str-chat__text-low-emphasis-color: #b8b0c0; + --str-chat__disabled-color: rgba(155, 143, 165, 0.35); +} + +@keyframes gradient-x { + 0%, + 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +.animate-gradient-x { + animation: gradient-x 15s ease infinite; +} + +@keyframes wave-float { + 0%, + 100% { + transform: translateY(0px) rotate(0deg); + } + 25% { + transform: translateY(-20px) rotate(1deg); + } + 50% { + transform: translateY(-10px) rotate(-1deg); + } + 75% { + transform: translateY(-15px) rotate(0.5deg); + } +} + +@keyframes wave-pulse { + 0%, + 100% { + transform: scale(1) rotateZ(0deg); + opacity: 0.3; + } + 25% { + transform: scale(1.05) rotateZ(1deg); + opacity: 0.4; + } + 50% { + transform: scale(0.95) rotateZ(-1deg); + opacity: 0.5; + } + 75% { + transform: scale(1.02) rotateZ(0.5deg); + opacity: 0.35; + } +} + +.animate-wave-float { + animation: wave-float 8s ease-in-out infinite; +} + +.animate-wave-float-delayed { + animation: wave-float 10s ease-in-out infinite 2s; +} + +.animate-wave-float-slow { + animation: wave-float 12s ease-in-out infinite 4s; +} + +.animate-wave-pulse { + animation: wave-pulse 6s ease-in-out infinite; +} + +.animate-wave-pulse-delayed { + animation: wave-pulse 8s ease-in-out infinite 3s; +} + +.animate-wave-pulse-slow { + animation: wave-pulse 10s ease-in-out infinite 1s; +} + +/* Moving Animations */ +@keyframes move-right-slow { + 0% { + transform: translateX(-100px); + } + 100% { + transform: translateX(calc(100vw + 100px)); + } +} + +@keyframes move-right-medium { + 0% { + transform: translateX(-80px); + } + 100% { + transform: translateX(calc(100vw + 80px)); + } +} + +@keyframes move-right-fast { + 0% { + transform: translateX(-120px); + } + 100% { + transform: translateX(calc(100vw + 120px)); + } +} + +@keyframes move-down-slow { + 0% { + transform: translateY(-100px); + } + 100% { + transform: translateY(calc(100vh + 100px)); + } +} + +@keyframes move-down-medium { + 0% { + transform: translateY(-80px); + } + 100% { + transform: translateY(calc(100vh + 80px)); + } +} + +@keyframes move-diagonal-1 { + 0% { + transform: translate(-100px, -100px); + } + 100% { + transform: translate(calc(100vw + 100px), calc(100vh + 100px)); + } +} + +@keyframes move-diagonal-2 { + 0% { + transform: translate(-80px, -50px); + } + 100% { + transform: translate(calc(100vw + 80px), calc(100vh + 50px)); + } +} + +@keyframes move-diagonal-3 { + 0% { + transform: translate(-60px, -80px); + } + 100% { + transform: translate(calc(100vw + 60px), calc(100vh + 80px)); + } +} + +@keyframes orbit-1 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(150px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(360deg) translateX(150px) rotate(-360deg); + } +} + +@keyframes orbit-2 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(200px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(-360deg) translateX(200px) rotate(360deg); + } +} + +@keyframes orbit-3 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(100px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(360deg) translateX(100px) rotate(-360deg); + } +} + +/* Gentle Animations */ +@keyframes spin-slow { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes spin-reverse { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(-360deg); + } +} + +@keyframes bounce-gentle { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes bounce-soft { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-8px); + } +} + +@keyframes pulse-gentle { + 0%, + 100% { + transform: scale(1); + opacity: 0.4; + } + 50% { + transform: scale(1.1); + opacity: 0.6; + } +} + +@keyframes wiggle { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(3deg); + } + 75% { + transform: rotate(-3deg); + } +} + +@keyframes float-gentle { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-5px); + } +} + +@keyframes scale-gentle { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} + +@keyframes rotate-gentle { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(180deg); + } +} + +@keyframes sway { + 0%, + 100% { + transform: translateX(0px); + } + 50% { + transform: translateX(10px); + } +} + +/* Animation Classes */ +.animate-move-right-slow { + animation: move-right-slow 25s linear infinite; +} +.animate-move-right-medium { + animation: move-right-medium 20s linear infinite; +} +.animate-move-right-fast { + animation: move-right-fast 15s linear infinite; +} +.animate-move-down-slow { + animation: move-down-slow 30s linear infinite; +} +.animate-move-down-medium { + animation: move-down-medium 25s linear infinite; +} +.animate-move-diagonal-1 { + animation: move-diagonal-1 35s linear infinite; +} +.animate-move-diagonal-2 { + animation: move-diagonal-2 28s linear infinite; +} +.animate-move-diagonal-3 { + animation: move-diagonal-3 32s linear infinite; +} +.animate-orbit-1 { + animation: orbit-1 20s linear infinite; +} +.animate-orbit-2 { + animation: orbit-2 25s linear infinite reverse; +} +.animate-orbit-3 { + animation: orbit-3 15s linear infinite; +} +.animate-spin-slow { + animation: spin-slow 8s linear infinite; +} +.animate-spin-reverse { + animation: spin-reverse 6s linear infinite; +} +.animate-bounce-gentle { + animation: bounce-gentle 3s ease-in-out infinite; +} +.animate-bounce-soft { + animation: bounce-soft 4s ease-in-out infinite; +} +.animate-pulse-gentle { + animation: pulse-gentle 4s ease-in-out infinite; +} +.animate-wiggle { + animation: wiggle 2s ease-in-out infinite; +} +.animate-float-gentle { + animation: float-gentle 5s ease-in-out infinite; +} +.animate-scale-gentle { + animation: scale-gentle 6s ease-in-out infinite; +} +.animate-rotate-gentle { + animation: rotate-gentle 8s ease-in-out infinite; +} +.animate-sway { + animation: sway 3s ease-in-out infinite; +} + +/* Enhanced Animations */ +@keyframes orbit-4 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(250px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(360deg) translateX(250px) rotate(-360deg); + } +} + +@keyframes orbit-5 { + 0% { + transform: translate(-50%, -50%) rotate(0deg) translateX(120px) rotate(0deg); + } + 100% { + transform: translate(-50%, -50%) rotate(-360deg) translateX(120px) rotate(360deg); + } +} + +@keyframes zigzag-1 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 25% { + transform: translateX(25vw) translateY(-50px); + } + 50% { + transform: translateX(50vw) translateY(50px); + } + 75% { + transform: translateX(75vw) translateY(-30px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(20px); + } +} + +@keyframes zigzag-2 { + 0% { + transform: translateX(-80px) translateY(0px); + } + 20% { + transform: translateX(20vw) translateY(40px); + } + 40% { + transform: translateX(40vw) translateY(-60px); + } + 60% { + transform: translateX(60vw) translateY(30px); + } + 80% { + transform: translateX(80vw) translateY(-40px); + } + 100% { + transform: translateX(calc(100vw + 80px)) translateY(0px); + } +} + +@keyframes zigzag-3 { + 0% { + transform: translateX(-120px) translateY(0px); + } + 16% { + transform: translateX(16vw) translateY(-70px); + } + 33% { + transform: translateX(33vw) translateY(80px); + } + 50% { + transform: translateX(50vw) translateY(-50px); + } + 66% { + transform: translateX(66vw) translateY(60px); + } + 83% { + transform: translateX(83vw) translateY(-40px); + } + 100% { + transform: translateX(calc(100vw + 120px)) translateY(0px); + } +} + +@keyframes spiral-1 { + 0% { + transform: translate(0px, 0px) rotate(0deg) scale(0.5); + } + 25% { + transform: translate(25vw, 25vh) rotate(90deg) scale(1); + } + 50% { + transform: translate(50vw, 50vh) rotate(180deg) scale(1.5); + } + 75% { + transform: translate(75vw, 75vh) rotate(270deg) scale(1); + } + 100% { + transform: translate(100vw, 100vh) rotate(360deg) scale(0.5); + } +} + +@keyframes spiral-2 { + 0% { + transform: translate(0px, 0px) rotate(0deg) scale(1.5); + } + 25% { + transform: translate(-25vw, 25vh) rotate(-90deg) scale(0.8); + } + 50% { + transform: translate(-50vw, 50vh) rotate(-180deg) scale(0.5); + } + 75% { + transform: translate(-75vw, 75vh) rotate(-270deg) scale(1.2); + } + 100% { + transform: translate(-100vw, 100vh) rotate(-360deg) scale(1.5); + } +} + +@keyframes float-random-1 { + 0%, + 100% { + transform: translate(0px, 0px) rotate(0deg); + } + 25% { + transform: translate(50px, -30px) rotate(45deg); + } + 50% { + transform: translate(-30px, 40px) rotate(-30deg); + } + 75% { + transform: translate(40px, 20px) rotate(60deg); + } +} + +@keyframes float-random-2 { + 0%, + 100% { + transform: translate(0px, 0px) rotate(0deg); + } + 20% { + transform: translate(-40px, -50px) rotate(-45deg); + } + 40% { + transform: translate(60px, -20px) rotate(90deg); + } + 60% { + transform: translate(-20px, 60px) rotate(-60deg); + } + 80% { + transform: translate(30px, -40px) rotate(120deg); + } +} + +@keyframes float-random-3 { + 0%, + 100% { + transform: translate(0px, 0px) rotate(0deg); + } + 33% { + transform: translate(70px, 30px) rotate(180deg); + } + 66% { + transform: translate(-50px, -40px) rotate(-90deg); + } +} + +@keyframes float-random-4 { + 0%, + 100% { + transform: translate(0px, 0px) rotate(0deg); + } + 25% { + transform: translate(-60px, 50px) rotate(270deg); + } + 50% { + transform: translate(80px, -30px) rotate(180deg); + } + 75% { + transform: translate(-40px, -60px) rotate(90deg); + } +} + +@keyframes wave-1 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 25% { + transform: translateX(25vw) translateY(-80px); + } + 50% { + transform: translateX(50vw) translateY(0px); + } + 75% { + transform: translateX(75vw) translateY(80px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(0px); + } +} + +@keyframes wave-2 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 20% { + transform: translateX(20vw) translateY(60px); + } + 40% { + transform: translateX(40vw) translateY(-60px); + } + 60% { + transform: translateX(60vw) translateY(60px); + } + 80% { + transform: translateX(80vw) translateY(-60px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(0px); + } +} + +@keyframes wave-3 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 33% { + transform: translateX(33vw) translateY(-100px); + } + 66% { + transform: translateX(66vw) translateY(100px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(0px); + } +} + +@keyframes wave-4 { + 0% { + transform: translateX(-100px) translateY(0px); + } + 16% { + transform: translateX(16vw) translateY(40px); + } + 33% { + transform: translateX(33vw) translateY(-80px); + } + 50% { + transform: translateX(50vw) translateY(40px); + } + 66% { + transform: translateX(66vw) translateY(-80px); + } + 83% { + transform: translateX(83vw) translateY(40px); + } + 100% { + transform: translateX(calc(100vw + 100px)) translateY(0px); + } +} + +@keyframes corner-shoot-1 { + 0% { + transform: translate(0px, 0px) rotate(0deg); + } + 100% { + transform: translate(100vw, 100vh) rotate(720deg); + } +} + +@keyframes corner-shoot-2 { + 0% { + transform: translate(0px, 0px) rotate(0deg); + } + 100% { + transform: translate(-100vw, 100vh) rotate(-720deg); + } +} + +@keyframes corner-shoot-3 { + 0% { + transform: translate(0px, 0px) rotate(0deg); + } + 100% { + transform: translate(100vw, -100vh) rotate(720deg); + } +} + +@keyframes corner-shoot-4 { + 0% { + transform: translate(0px, 0px) rotate(0deg); + } + 100% { + transform: translate(-100vw, -100vh) rotate(-720deg); + } +} + +@keyframes bounce-ball-1 { + 0%, + 100% { + transform: translate(0px, 0px); + } + 25% { + transform: translate(200px, -150px); + } + 50% { + transform: translate(400px, 0px); + } + 75% { + transform: translate(600px, -100px); + } +} + +@keyframes bounce-ball-2 { + 0%, + 100% { + transform: translate(0px, 0px); + } + 33% { + transform: translate(-300px, -200px); + } + 66% { + transform: translate(-600px, 0px); + } +} + +@keyframes bounce-ball-3 { + 0%, + 100% { + transform: translate(0px, 0px); + } + 20% { + transform: translate(150px, -100px); + } + 40% { + transform: translate(300px, 50px); + } + 60% { + transform: translate(150px, -80px); + } + 80% { + transform: translate(-150px, 30px); + } +} + +/* Crazy Animations */ +@keyframes spin-fast { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(720deg); + } +} +@keyframes pulse-fast { + 0%, + 100% { + transform: scale(0.8); + opacity: 0.3; + } + 50% { + transform: scale(1.3); + opacity: 0.8; + } +} +@keyframes wobble { + 0%, + 100% { + transform: rotate(0deg) scale(1); + } + 25% { + transform: rotate(5deg) scale(1.1); + } + 50% { + transform: rotate(-5deg) scale(0.9); + } + 75% { + transform: rotate(3deg) scale(1.05); + } +} +@keyframes shake { + 0%, + 100% { + transform: translateX(0px); + } + 25% { + transform: translateX(-10px); + } + 75% { + transform: translateX(10px); + } +} +@keyframes bounce-crazy { + 0%, + 100% { + transform: translateY(0px) scale(1); + } + 50% { + transform: translateY(-50px) scale(1.2); + } +} +@keyframes spin-wobble { + 0% { + transform: rotate(0deg) scale(1); + } + 25% { + transform: rotate(90deg) scale(1.1); + } + 50% { + transform: rotate(180deg) scale(0.9); + } + 75% { + transform: rotate(270deg) scale(1.05); + } + 100% { + transform: rotate(360deg) scale(1); + } +} +@keyframes flip { + 0% { + transform: rotateY(0deg); + } + 50% { + transform: rotateY(180deg); + } + 100% { + transform: rotateY(360deg); + } +} +@keyframes twirl { + 0% { + transform: rotate(0deg) translateX(0px); + } + 25% { + transform: rotate(90deg) translateX(20px); + } + 50% { + transform: rotate(180deg) translateX(0px); + } + 75% { + transform: rotate(270deg) translateX(-20px); + } + 100% { + transform: rotate(360deg) translateX(0px); + } +} +@keyframes dance { + 0%, + 100% { + transform: translateY(0px) rotate(0deg); + } + 25% { + transform: translateY(-20px) rotate(10deg); + } + 50% { + transform: translateY(10px) rotate(-5deg); + } + 75% { + transform: translateY(-15px) rotate(8deg); + } +} +@keyframes jiggle { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(2deg) translateX(2px); + } + 50% { + transform: rotate(-2deg) translateX(-2px); + } + 75% { + transform: rotate(1deg) translateX(1px); + } +} +@keyframes vibrate { + 0%, + 100% { + transform: translate(0px, 0px); + } + 25% { + transform: translate(2px, -2px); + } + 50% { + transform: translate(-2px, 2px); + } + 75% { + transform: translate(2px, 2px); + } +} +@keyframes swing { + 0%, + 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(15deg); + } + 75% { + transform: rotate(-15deg); + } +} +@keyframes pendulum { + 0%, + 100% { + transform: rotate(0deg); + } + 50% { + transform: rotate(30deg); + } +} +@keyframes elastic { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.3) rotate(180deg); + } +} +@keyframes rubber { + 0%, + 100% { + transform: scaleX(1) scaleY(1); + } + 25% { + transform: scaleX(1.2) scaleY(0.8); + } + 75% { + transform: scaleX(0.8) scaleY(1.2); + } +} +@keyframes rocket { + 0% { + transform: scale(0.5) rotate(0deg); + } + 100% { + transform: scale(2) rotate(360deg); + } +} +@keyframes comet { + 0% { + transform: scale(1) rotate(0deg); + opacity: 1; + } + 100% { + transform: scale(0.2) rotate(720deg); + opacity: 0.2; + } +} +@keyframes meteor { + 0% { + transform: scale(0.2) rotate(0deg); + opacity: 0.2; + } + 100% { + transform: scale(1.5) rotate(-720deg); + opacity: 1; + } +} +@keyframes blast { + 0%, + 100% { + transform: scale(1) rotate(0deg); + } + 50% { + transform: scale(2) rotate(180deg); + } +} +@keyframes spin-bounce { + 0%, + 100% { + transform: rotate(0deg) translateY(0px); + } + 50% { + transform: rotate(180deg) translateY(-30px); + } +} +@keyframes flip-bounce { + 0%, + 100% { + transform: rotateX(0deg) translateY(0px); + } + 50% { + transform: rotateX(180deg) translateY(-25px); + } +} +@keyframes scale-bounce { + 0%, + 100% { + transform: scale(1) translateY(0px); + } + 50% { + transform: scale(1.5) translateY(-40px); + } +} + +/* New Animation Classes */ +.animate-orbit-4 { + animation: orbit-4 18s linear infinite; +} +.animate-orbit-5 { + animation: orbit-5 22s linear infinite reverse; +} +.animate-zigzag-1 { + animation: zigzag-1 18s linear infinite; +} +.animate-zigzag-2 { + animation: zigzag-2 22s linear infinite; +} +.animate-zigzag-3 { + animation: zigzag-3 16s linear infinite; +} +.animate-spiral-1 { + animation: spiral-1 30s linear infinite; +} +.animate-spiral-2 { + animation: spiral-2 25s linear infinite; +} +.animate-float-random-1 { + animation: float-random-1 8s ease-in-out infinite; +} +.animate-float-random-2 { + animation: float-random-2 10s ease-in-out infinite; +} +.animate-float-random-3 { + animation: float-random-3 12s ease-in-out infinite; +} +.animate-float-random-4 { + animation: float-random-4 9s ease-in-out infinite; +} +.animate-wave-1 { + animation: wave-1 20s linear infinite; +} +.animate-wave-2 { + animation: wave-2 24s linear infinite; +} +.animate-wave-3 { + animation: wave-3 18s linear infinite; +} +.animate-wave-4 { + animation: wave-4 26s linear infinite; +} +.animate-corner-shoot-1 { + animation: corner-shoot-1 15s linear infinite; +} +.animate-corner-shoot-2 { + animation: corner-shoot-2 18s linear infinite; +} +.animate-corner-shoot-3 { + animation: corner-shoot-3 20s linear infinite; +} +.animate-corner-shoot-4 { + animation: corner-shoot-4 16s linear infinite; +} +.animate-bounce-ball-1 { + animation: bounce-ball-1 12s ease-in-out infinite; +} +.animate-bounce-ball-2 { + animation: bounce-ball-2 14s ease-in-out infinite; +} +.animate-bounce-ball-3 { + animation: bounce-ball-3 10s ease-in-out infinite; +} +.animate-spin-fast { + animation: spin-fast 2s linear infinite; +} +.animate-pulse-fast { + animation: pulse-fast 1.5s ease-in-out infinite; +} +.animate-wobble { + animation: wobble 2s ease-in-out infinite; +} +.animate-shake { + animation: shake 0.5s ease-in-out infinite; +} +.animate-bounce-crazy { + animation: bounce-crazy 1s ease-in-out infinite; +} +.animate-spin-wobble { + animation: spin-wobble 4s ease-in-out infinite; +} +.animate-flip { + animation: flip 3s ease-in-out infinite; +} +.animate-twirl { + animation: twirl 5s ease-in-out infinite; +} +.animate-dance { + animation: dance 3s ease-in-out infinite; +} +.animate-jiggle { + animation: jiggle 1s ease-in-out infinite; +} +.animate-vibrate { + animation: vibrate 0.3s ease-in-out infinite; +} +.animate-swing { + animation: swing 4s ease-in-out infinite; +} +.animate-pendulum { + animation: pendulum 6s ease-in-out infinite; +} +.animate-elastic { + animation: elastic 4s ease-in-out infinite; +} +.animate-rubber { + animation: rubber 2s ease-in-out infinite; +} +.animate-rocket { + animation: rocket 8s ease-in-out infinite; +} +.animate-comet { + animation: comet 12s ease-in-out infinite; +} +.animate-meteor { + animation: meteor 10s ease-in-out infinite; +} +.animate-blast { + animation: blast 3s ease-in-out infinite; +} +.animate-spin-bounce { + animation: spin-bounce 4s ease-in-out infinite; +} +.animate-flip-bounce { + animation: flip-bounce 5s ease-in-out infinite; +} +.animate-scale-bounce { + animation: scale-bounce 3s ease-in-out infinite; +} + +/* Animated Border Light */ +@keyframes border-light { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.animate-border-light { + position: relative; + border-radius: 1rem; +} + +.animate-border-light::before { + content: ""; + position: absolute; + inset: -2px; + background: conic-gradient( + from 0deg, + transparent 0deg, + transparent 270deg, + rgba(168, 85, 247, 0.8) 300deg, + rgba(147, 51, 234, 1) 330deg, + rgba(168, 85, 247, 0.8) 360deg, + transparent 30deg, + transparent 360deg + ); + border-radius: inherit; + animation: border-light 3s linear infinite; + z-index: -1; +} + +.animate-border-light::after { + content: ""; + position: absolute; + inset: 2px; + background: inherit; + border-radius: inherit; + z-index: -1; +} diff --git a/apps/clients/src/main.tsx b/apps/clients/src/main.tsx new file mode 100644 index 0000000..d158df1 --- /dev/null +++ b/apps/clients/src/main.tsx @@ -0,0 +1,29 @@ +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 "./main.css"; +import "./i18n"; + +createRoot(document.getElementById("client-root")!).render( + + + + + + + + + + + + +); diff --git a/apps/clients/src/pages/AuthCallback.tsx b/apps/clients/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..b34427b --- /dev/null +++ b/apps/clients/src/pages/AuthCallback.tsx @@ -0,0 +1,66 @@ +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +export function AuthCallback() { + const [searchParams] = useSearchParams(); + const token = searchParams.get("token"); + const { session } = useSession(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + const hasAccepted = useRef(false); + + useEffect(() => { + if (!session || !token || hasAccepted.current) { + return; + } + + hasAccepted.current = true; + + const apiUrl = import.meta.env.VITE_API_URL as string; + + fetch(`${apiUrl}/api/v1/client-invites/accept/${token}`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.access_token}`, + "Content-Type": "application/json", + }, + }) + .then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { message?: string }).message ?? "Erreur lors de l'acceptation de l'invitation"); + } + return res.json() as Promise<{ tabloId: string }>; + }) + .then((data) => { + navigate(`/tablo/${data.tabloId}`, { replace: true }); + }) + .catch((err: unknown) => { + console.error("Accept invite error:", err); + setError( + "Une erreur est survenue lors de l'acceptation de l'invitation. Veuillez contacter la personne qui vous a invité." + ); + }); + }, [session, token, navigate]); + + if (error) { + return ( +
+
+

Erreur

+

{error}

+
+
+ ); + } + + return ( +
+
+
+

Authentification en cours...

+
+
+ ); +} diff --git a/apps/clients/src/pages/ClientTabloListPage.tsx b/apps/clients/src/pages/ClientTabloListPage.tsx new file mode 100644 index 0000000..e3ce7c6 --- /dev/null +++ b/apps/clients/src/pages/ClientTabloListPage.tsx @@ -0,0 +1,63 @@ +import { useQuery } from "@tanstack/react-query"; +import type { UserTablo } from "@xtablo/shared-types"; +import { Navigate, Link } from "react-router-dom"; +import { supabase } from "../lib/supabase"; + +function useClientTablosList() { + return useQuery({ + queryKey: ["client-tablos-list"], + queryFn: async () => { + const { data, error } = await supabase.from("user_tablos").select("*"); + if (error) throw error; + return (data ?? []) as UserTablo[]; + }, + }); +} + +export function ClientTabloListPage() { + const { data: tablos, isLoading } = useClientTablosList(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!tablos || tablos.length === 0) { + return ( +
+

Aucun projet disponible.

+
+ ); + } + + if (tablos.length === 1) { + return ; + } + + return ( +
+
+

Mes projets

+

Sélectionnez un projet pour y accéder.

+
+ +
+ {tablos.map((tablo) => ( + + {tablo.color && ( +
+ )} +

{tablo.name}

+ + ))} +
+
+ ); +} diff --git a/apps/clients/src/pages/ClientTabloPage.tsx b/apps/clients/src/pages/ClientTabloPage.tsx new file mode 100644 index 0000000..3da9be0 --- /dev/null +++ b/apps/clients/src/pages/ClientTabloPage.tsx @@ -0,0 +1,310 @@ +import { useQuery } from "@tanstack/react-query"; +import { buildApi } from "@xtablo/shared"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import type { Etape, KanbanTask, TabloFolder, UserTablo } from "@xtablo/shared-types"; +import { CalendarIcon, FolderIcon, KanbanIcon, ListChecksIcon, MapIcon, MessageCircleIcon } from "lucide-react"; +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { + EtapesSection, + RoadmapSection, + TabloDiscussionSection, + TabloEventsSection, + TabloFilesSection, + TabloTasksSection, +} from "@xtablo/tablo-views"; +import { supabase } from "../lib/supabase"; + +const API_URL = import.meta.env.VITE_API_URL as string; + +// ─── Local hooks ────────────────────────────────────────────────────────────── + +function useAuthedApi(accessToken: string | undefined) { + return buildApi(API_URL).create({ + headers: { + Authorization: `Bearer ${accessToken ?? ""}`, + }, + }); +} + +function useClientTablo(tabloId: string) { + return useQuery({ + queryKey: ["client-tablo", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("user_tablos") + .select("*") + .eq("id", tabloId) + .single(); + if (error) throw error; + return data as UserTablo; + }, + enabled: !!tabloId, + }); +} + +function useClientTabloTasks(tabloId: string) { + return useQuery({ + queryKey: ["client-tasks", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("tasks_with_assignee") + .select("*") + .eq("tablo_id", tabloId) + .eq("is_parent", false) + .order("updated_at", { ascending: false }); + if (error) throw error; + return (data ?? []) as KanbanTask[]; + }, + enabled: !!tabloId, + }); +} + +function useClientTabloEtapes(tabloId: string) { + return useQuery({ + queryKey: ["client-etapes", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("tasks") + .select("*") + .eq("tablo_id", tabloId) + .eq("is_parent", true) + .order("position", { ascending: true }); + if (error) throw error; + return (data ?? []) as Etape[]; + }, + enabled: !!tabloId, + }); +} + +function useClientTabloEvents(tabloId: string) { + return useQuery({ + queryKey: ["client-events", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("events_and_tablos") + .select("*") + .eq("tablo_id", tabloId) + .order("start_date", { ascending: false }); + if (error) throw error; + return data ?? []; + }, + enabled: !!tabloId, + }); +} + +function useClientTabloMembers(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + return useQuery({ + queryKey: ["client-members", tabloId], + queryFn: async () => { + const { data } = await api.get<{ + members: { + id: string; + name: string; + is_admin: boolean; + email: string; + avatar_url: string | null; + }[]; + }>(`/api/v1/tablos/members/${tabloId}`); + return data.members; + }, + enabled: !!tabloId && !!accessToken, + }); +} + +function useClientTabloFiles(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + return useQuery<{ fileNames: string[] }>({ + queryKey: ["client-tablo-files", tabloId], + queryFn: async () => { + const { data } = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`); + return data as { fileNames: string[] }; + }, + enabled: !!tabloId && !!accessToken, + }); +} + +function useClientTabloFolders(tabloId: string, accessToken: string | undefined) { + const api = useAuthedApi(accessToken); + return useQuery({ + queryKey: ["client-tablo-folders", tabloId], + queryFn: async () => { + const { data } = await api.get<{ folders: TabloFolder[] }>(`/api/v1/tablo-folders/${tabloId}`); + return data.folders ?? []; + }, + enabled: !!tabloId && !!accessToken, + }); +} + +// ─── Tabs ───────────────────────────────────────────────────────────────────── + +type TabId = "overview" | "etapes" | "tasks" | "files" | "discussion" | "events" | "roadmap"; + +const TABS: { id: TabId; label: string; icon: React.ElementType }[] = [ + { id: "overview", label: "Aperçu", icon: ListChecksIcon }, + { id: "etapes", label: "Étapes", icon: ListChecksIcon }, + { id: "tasks", label: "Tâches", icon: KanbanIcon }, + { id: "files", label: "Fichiers", icon: FolderIcon }, + { id: "discussion", label: "Discussion", icon: MessageCircleIcon }, + { id: "events", label: "Événements", icon: CalendarIcon }, + { id: "roadmap", label: "Roadmap", icon: MapIcon }, +]; + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export function ClientTabloPage() { + const { tabloId } = useParams<{ tabloId: string }>(); + const { session } = useSession(); + const [activeTab, setActiveTab] = useState("overview"); + + const accessToken = session?.access_token; + const currentUserId = session?.user.id ?? ""; + + const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? ""); + const { data: tasks = [] } = useClientTabloTasks(tabloId ?? ""); + const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? ""); + const { data: events, isLoading: eventsLoading, error: eventsError } = useClientTabloEvents(tabloId ?? ""); + const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken); + const { data: filesData, isLoading: filesLoading, error: filesError } = useClientTabloFiles(tabloId ?? "", accessToken); + const { data: folders = [], isLoading: foldersLoading, error: foldersError } = useClientTabloFolders(tabloId ?? "", accessToken); + + const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith(".")); + + const currentUser = { id: currentUserId, avatar_url: null }; + + if (tabloLoading) { + return ( +
+
+
+ ); + } + + if (!tablo) { + return ( +
+

Projet introuvable.

+
+ ); + } + + return ( +
+ {/* Tablo header */} +
+

{tablo.name}

+
+ + {/* Tab bar */} +
+ +
+ + {/* Tab content */} +
+ {activeTab === "overview" && ( +
+ {/* Simple overview: list etapes with progress */} + {}} + onCreateEtape={async () => {}} + /> +
+ )} + + {activeTab === "etapes" && ( + {}} + onCreateEtape={async () => {}} + /> + )} + + {activeTab === "tasks" && ( + + )} + + {activeTab === "files" && ( + + )} + + {activeTab === "discussion" && ( + + )} + + {activeTab === "events" && ( + [0]["events"]} + isLoading={eventsLoading} + error={eventsError instanceof Error ? eventsError : null} + currentUser={currentUser} + members={members} + /> + )} + + {activeTab === "roadmap" && ( + {}} + onTaskStatusChange={() => {}} + /> + )} +
+
+ ); +} diff --git a/apps/clients/src/routes.tsx b/apps/clients/src/routes.tsx new file mode 100644 index 0000000..57a23ce --- /dev/null +++ b/apps/clients/src/routes.tsx @@ -0,0 +1,17 @@ +import { Route, Routes } from "react-router-dom"; +import { ClientLayout } from "./components/ClientLayout"; +import { AuthCallback } from "./pages/AuthCallback"; +import { ClientTabloListPage } from "./pages/ClientTabloListPage"; +import { ClientTabloPage } from "./pages/ClientTabloPage"; + +export default function AppRoutes() { + return ( + + } /> + }> + } /> + } /> + + + ); +} diff --git a/apps/clients/tsconfig.json b/apps/clients/tsconfig.json new file mode 100644 index 0000000..f2fa327 --- /dev/null +++ b/apps/clients/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@xtablo/ui": ["../../packages/ui/src"], + "@xtablo/ui/*": ["../../packages/ui/src/*"], + "@xtablo/shared": ["../../packages/shared/src"], + "@xtablo/shared/*": ["../../packages/shared/src/*"], + "@xtablo/tablo-views": ["../../packages/tablo-views/src"], + "@xtablo/tablo-views/*": ["../../packages/tablo-views/src/*"] + } + }, + "include": ["src"], + "references": [] +} diff --git a/apps/clients/vite.config.ts b/apps/clients/vite.config.ts new file mode 100644 index 0000000..dfed7ff --- /dev/null +++ b/apps/clients/vite.config.ts @@ -0,0 +1,18 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type PluginOption } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(({ mode }) => { + const plugins: PluginOption[] = [react(), tailwindcss(), tsconfigPaths({ ignoreConfigErrors: true })]; + + if (mode !== "test" && process.env.VITEST !== "true") { + plugins.push(cloudflare({ inspectorPort: 9232 })); + } + + return { + plugins, + server: { cors: false }, + }; +}); diff --git a/apps/clients/worker/index.ts b/apps/clients/worker/index.ts new file mode 100644 index 0000000..0dcbb86 --- /dev/null +++ b/apps/clients/worker/index.ts @@ -0,0 +1,9 @@ +export default { + fetch(request: Request) { + const url = new URL(request.url); + if (url.pathname.startsWith("/api/")) { + return Response.json({ name: "Cloudflare" }); + } + return new Response(null, { status: 404 }); + }, +}; diff --git a/apps/clients/wrangler.toml b/apps/clients/wrangler.toml new file mode 100644 index 0000000..13aff9a --- /dev/null +++ b/apps/clients/wrangler.toml @@ -0,0 +1,16 @@ +name = "xtablo-clients" +main = "worker/index.ts" +compatibility_date = "2025-07-09" + +[assets] +directory = "./dist/" +not_found_handling = "single-page-application" + +[observability] +enabled = true + +[env.staging] +route = { pattern = "clients-staging.xtablo.com", custom_domain = true } + +[env.production] +route = { pattern = "clients.xtablo.com", custom_domain = true } diff --git a/apps/external/vite.config.ts b/apps/external/vite.config.ts index 964bddd..8bd72da 100644 --- a/apps/external/vite.config.ts +++ b/apps/external/vite.config.ts @@ -16,12 +16,12 @@ export default defineConfig(({ mode }) => { react(), // visualizer() as PluginOption, tailwindcss(), - tsconfigPaths(), + tsconfigPaths({ ignoreConfigErrors: true }), ]; // Only include cloudflare plugin when not in test mode if (mode !== "test" && process.env.VITEST !== "true") { - plugins.push(cloudflare()); + plugins.push(cloudflare({ inspectorPort: 9231 })); } return { diff --git a/apps/main/package.json b/apps/main/package.json index 94e178a..18574d9 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -102,6 +102,7 @@ "@xtablo/chat-ui": "workspace:*", "@xtablo/shared": "workspace:*", "@xtablo/shared-types": "workspace:*", + "@xtablo/tablo-views": "workspace:*", "@xtablo/ui": "workspace:*", "ag-grid-community": "^33.2.1", "ag-grid-react": "^33.2.1", diff --git a/apps/main/src/components/ChatHeader.tsx b/apps/main/src/components/ChatHeader.tsx index da3664e..76a765f 100644 --- a/apps/main/src/components/ChatHeader.tsx +++ b/apps/main/src/components/ChatHeader.tsx @@ -42,9 +42,7 @@ export function ChatHeader({

{tablo.name}

{memberCount > 0 && ( -

- {memberCount} online -

+

{memberCount} online

)}
diff --git a/apps/main/src/components/DashboardTaskList.tsx b/apps/main/src/components/DashboardTaskList.tsx index bb9a93a..0746bf5 100644 --- a/apps/main/src/components/DashboardTaskList.tsx +++ b/apps/main/src/components/DashboardTaskList.tsx @@ -1,5 +1,6 @@ import { cn } from "@xtablo/shared"; import type { KanbanTask, TaskStatus } from "@xtablo/shared-types"; +import { TaskModal } from "@xtablo/tablo-views"; import { CheckCircle2, Plus } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -7,7 +8,6 @@ import { useNavigate } from "react-router-dom"; import { useTablosList } from "../hooks/tablos"; import { useAllTasks, useUpdateTask } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; -import { TaskModal } from "./kanban/TaskModal"; type TaskWithTablo = KanbanTask & { tablos: { id: string; name: string; color: string | null } | null; diff --git a/apps/main/src/components/Layout.tsx b/apps/main/src/components/Layout.tsx index a844ede..cf96f1b 100644 --- a/apps/main/src/components/Layout.tsx +++ b/apps/main/src/components/Layout.tsx @@ -54,11 +54,7 @@ export function Layout() { aria-label={isMobileMenuOpen ? "Close menu" : "Open menu"} aria-expanded={isMobileMenuOpen} > - {isMobileMenuOpen ? ( - - ) : ( - - )} + {isMobileMenuOpen ? : } {/* Mobile backdrop overlay */} @@ -66,9 +62,7 @@ export function Layout() { className={twMerge( "fixed inset-0 z-40 bg-black/50 md:hidden", "transition-opacity duration-300 ease-in-out", - isMobileMenuOpen - ? "opacity-100 pointer-events-auto" - : "opacity-0 pointer-events-none" + isMobileMenuOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none" )} onClick={closeMobileMenu} aria-hidden="true" diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx index adf6cc6..12ed900 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -301,11 +301,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean className={twMerge( "group isolate flex flex-col overflow-y-auto overflow-x-hidden bg-navbar-background transition-all duration-300", "h-full md:h-screen", - isMobileMenuOpen - ? "w-40" - : effectivelyCollapsed - ? "w-16" - : "w-48", + isMobileMenuOpen ? "w-40" : effectivelyCollapsed ? "w-16" : "w-48", "md:flex", // On mobile in standalone mode, respect safe area insets "pl-[env(safe-area-inset-left,0px)] pt-[env(safe-area-inset-top,0px)] pb-[env(safe-area-inset-bottom,0px)]" @@ -352,7 +348,11 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean "hover:scale-110" )} > - {effectivelyCollapsed ?
diff --git a/apps/main/src/components/ProtectedRoute.test.tsx b/apps/main/src/components/ProtectedRoute.test.tsx index d9979ec..19143ed 100644 --- a/apps/main/src/components/ProtectedRoute.test.tsx +++ b/apps/main/src/components/ProtectedRoute.test.tsx @@ -84,6 +84,7 @@ describe("ProtectedRoute", () => { first_name: "Test", last_name: "User", is_temporary: false, + is_client: false, last_signed_in: null, plan: "none" as const, created_at: new Date().toISOString(), diff --git a/apps/main/src/components/SubscriptionCard.test.tsx b/apps/main/src/components/SubscriptionCard.test.tsx index 5cbc02b..f065c5b 100644 --- a/apps/main/src/components/SubscriptionCard.test.tsx +++ b/apps/main/src/components/SubscriptionCard.test.tsx @@ -26,6 +26,7 @@ vi.mock("../hooks/auth", () => ({ import { useOrganization } from "../hooks/organization"; import { useSubscription } from "../hooks/stripe"; + const mockUseOrganization = vi.mocked(useOrganization); const mockUseSubscription = vi.mocked(useSubscription); @@ -38,6 +39,7 @@ const baseUser: User = { email: "test@example.com", avatar_url: null, is_temporary: false, + is_client: false, last_signed_in: null, plan: "none", created_at: new Date().toISOString(), @@ -50,7 +52,7 @@ const queryClient = new QueryClient({ function renderCard( user: User, orgData: ReturnType["data"], - subscription: ReturnType["data"] = undefined, + subscription: ReturnType["data"] = undefined ) { mockUseOrganization.mockReturnValue({ data: orgData, @@ -74,7 +76,14 @@ function renderCard( } const baseOrg = { - organization: { id: 1, name: "Org", plan: "none", member_count: 1, tablo_count: 0, logo_url: null }, + organization: { + id: 1, + name: "Org", + plan: "none", + member_count: 1, + tablo_count: 0, + logo_url: null, + }, members: [], invites_sent: [], trial_starts_at: "2026-01-01", @@ -122,9 +131,7 @@ describe("SubscriptionCard", () => { it("shows billing owner restriction when user is not billing owner", () => { const nonOwnerOrg = { ...baseOrg, is_billing_owner: false }; renderCard(baseUser, nonOwnerOrg); - expect( - screen.getByText(/Seul le propriétaire de facturation/) - ).toBeInTheDocument(); + expect(screen.getByText(/Seul le propriétaire de facturation/)).toBeInTheDocument(); expect(screen.queryByText(/Passer au plan/)).not.toBeInTheDocument(); }); diff --git a/apps/main/src/components/TabloDiscussionSection.test.tsx b/apps/main/src/components/TabloDiscussionSection.test.tsx index 634ba21..08218e3 100644 --- a/apps/main/src/components/TabloDiscussionSection.test.tsx +++ b/apps/main/src/components/TabloDiscussionSection.test.tsx @@ -1,8 +1,8 @@ +import { TabloDiscussionSection } from "@xtablo/tablo-views"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; -import { TabloDiscussionSection } from "./TabloDiscussionSection"; -vi.mock("../hooks/useChat", () => ({ +vi.mock("@xtablo/tablo-views/hooks/useChat", () => ({ useChat: () => ({ messages: [], sendMessage: vi.fn(), @@ -33,7 +33,7 @@ describe("TabloDiscussionSection", () => { it("renders without crashing", () => { const { container } = renderWithProviders( - + ); expect(container).toBeInTheDocument(); }); diff --git a/apps/main/src/components/TabloEventsSection.test.tsx b/apps/main/src/components/TabloEventsSection.test.tsx index c6cb26f..0fee1a3 100644 --- a/apps/main/src/components/TabloEventsSection.test.tsx +++ b/apps/main/src/components/TabloEventsSection.test.tsx @@ -1,8 +1,8 @@ +import { TabloEventsSection } from "@xtablo/tablo-views"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; -import { TabloEventsSection } from "./TabloEventsSection"; -vi.mock("../hooks/events", () => ({ +vi.mock("@xtablo/tablo-views/hooks/events", () => ({ useEventsByTablo: () => ({ data: [ { @@ -46,14 +46,14 @@ describe("TabloEventsSection", () => { it("renders without crashing", () => { const { container } = renderWithProviders( - + ); expect(container).toBeInTheDocument(); }); it("displays section title", () => { const { container } = renderWithProviders( - + ); // Just check that the component renders expect(container).toBeInTheDocument(); @@ -61,7 +61,7 @@ describe("TabloEventsSection", () => { it("displays events from the tablo", () => { const { container } = renderWithProviders( - + ); // Component should render the events section expect(container).toBeInTheDocument(); @@ -69,7 +69,7 @@ describe("TabloEventsSection", () => { it("shows add event button for admin users", () => { const { container } = renderWithProviders( - + ); // Component should render for admin users expect(container).toBeInTheDocument(); @@ -77,7 +77,7 @@ describe("TabloEventsSection", () => { it("navigates to events page when add button is clicked", () => { const { container } = renderWithProviders( - + ); // Component renders successfully expect(container).toBeInTheDocument(); @@ -85,7 +85,7 @@ describe("TabloEventsSection", () => { it("shows view all events link", () => { const { container } = renderWithProviders( - + ); // Component renders successfully expect(container).toBeInTheDocument(); @@ -93,7 +93,7 @@ describe("TabloEventsSection", () => { it("hides add button for non-admin users", () => { const { container } = renderWithProviders( - + ); // Component renders for non-admin users expect(container).toBeInTheDocument(); diff --git a/apps/main/src/components/TabloFilesSection.test.tsx b/apps/main/src/components/TabloFilesSection.test.tsx index 4abc5d7..5aedd89 100644 --- a/apps/main/src/components/TabloFilesSection.test.tsx +++ b/apps/main/src/components/TabloFilesSection.test.tsx @@ -1,6 +1,6 @@ +import { TabloFilesSection } from "@xtablo/tablo-views"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; -import { TabloFilesSection } from "./TabloFilesSection"; vi.mock("../hooks/files", () => ({ useTabloFileNames: () => ({ @@ -29,7 +29,12 @@ describe("TabloFilesSection", () => { it("renders without crashing", () => { const { container } = renderWithProviders( - + ); expect(container).toBeInTheDocument(); }); diff --git a/apps/main/src/components/TabloOverviewSection.tsx b/apps/main/src/components/TabloOverviewSection.tsx index f9c9e5a..d111405 100644 --- a/apps/main/src/components/TabloOverviewSection.tsx +++ b/apps/main/src/components/TabloOverviewSection.tsx @@ -1,5 +1,6 @@ import { toast } from "@xtablo/shared"; import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { TabloHeaderActions } from "@xtablo/tablo-views"; import { Button } from "@xtablo/ui/components/button"; import { Input } from "@xtablo/ui/components/input"; import { TypographyH3, TypographyMuted, TypographyP } from "@xtablo/ui/components/typography"; @@ -16,7 +17,6 @@ import { } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; import { getEtapeProgressStats } from "../utils/etapeProgress"; -import { TabloHeaderActions } from "./TabloHeaderActions"; interface TabloOverviewSectionProps { tablo: UserTablo; @@ -289,7 +289,7 @@ export const TabloOverviewSection = ({ tablo, isAdmin }: TabloOverviewSectionPro {t("tablo:overview.description")}
- +
{!canManageEtapes && ( diff --git a/apps/main/src/components/UpgradePanel.test.tsx b/apps/main/src/components/UpgradePanel.test.tsx index a91e5de..3b00d34 100644 --- a/apps/main/src/components/UpgradePanel.test.tsx +++ b/apps/main/src/components/UpgradePanel.test.tsx @@ -23,6 +23,7 @@ vi.mock("../hooks/auth", () => ({ })); import { useOrganization } from "../hooks/organization"; + const mockUseOrganization = vi.mocked(useOrganization); const baseUser: User = { @@ -34,6 +35,7 @@ const baseUser: User = { email: "test@example.com", avatar_url: null, is_temporary: false, + is_client: false, last_signed_in: null, plan: "none", created_at: new Date().toISOString(), @@ -62,7 +64,14 @@ function renderPanel(user: User, orgData: ReturnType["da } const noPlanOrg = { - organization: { id: 1, name: "Org", plan: "none", member_count: 1, tablo_count: 0, logo_url: null }, + organization: { + id: 1, + name: "Org", + plan: "none", + member_count: 1, + tablo_count: 0, + logo_url: null, + }, members: [], invites_sent: [], trial_starts_at: "2026-01-01", @@ -128,9 +137,7 @@ describe("UpgradePanel", () => { const soloButton = screen.getByText("Passer au plan Solo").closest("button"); expect(soloButton).toBeDisabled(); - expect( - screen.getByText(/Seul le propriétaire de facturation/) - ).toBeInTheDocument(); + expect(screen.getByText(/Seul le propriétaire de facturation/)).toBeInTheDocument(); }); it("renders nothing when org data is not yet loaded", () => { diff --git a/apps/main/src/components/UpgradePanel.tsx b/apps/main/src/components/UpgradePanel.tsx index aa2cbc9..12db069 100644 --- a/apps/main/src/components/UpgradePanel.tsx +++ b/apps/main/src/components/UpgradePanel.tsx @@ -129,7 +129,8 @@ export function UpgradePanel() { disabled={checkoutPending || !isBillingOwner} className="w-full" > - Passer au plan Teams ({requiredTeamQuantity} siège{requiredTeamQuantity > 1 ? "s" : ""}) + Passer au plan Teams ({requiredTeamQuantity} siège + {requiredTeamQuantity > 1 ? "s" : ""})
{/* ── Tab content ─────────────────────────────────────────────────── */} -
+
{activeSection === "overview" && (() => { const overviewBlocks: Record = { @@ -783,14 +836,94 @@ export const TabloDetailsPage = () => { ); })()} - {activeSection === "tasks" && } - {activeSection === "files" && } + {activeSection === "tasks" && ( + ({ ...inv, id: String(inv.id) }))} + isInvitingUser={isInvitingUser} + isCancellingInvite={isCancellingInvite} + onCreateTask={(task) => createTask(task)} + onUpdateTask={(task) => updateTask(task)} + onUpdateTaskPositions={(updates) => updateTaskPositions(updates)} + onUpdateTablo={(data) => + updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) + } + onInviteUser={inviteUser} + onCancelInvite={(params) => + cancelInvite({ ...params, inviteId: Number(params.inviteId) }) + } + /> + )} + {activeSection === "files" && ( + !f.startsWith("."))} + filesLoading={false} + filesError={null} + folders={foldersData?.folders ?? []} + foldersLoading={foldersLoading} + foldersError={foldersError as Error | null} + currentUser={currentUser} + members={members} + pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} + isInvitingUser={isInvitingUser} + isCancellingInvite={isCancellingInvite} + isCreatingFolder={isCreatingFolder} + isUpdatingFolder={isUpdatingFolder} + onCreateFile={(params) => uploadFile(params).then(() => undefined)} + onDeleteFile={(params) => deleteFile(params).then(() => undefined)} + onDownloadFile={(params) => downloadFile(params).then(() => undefined)} + onCreateFolder={(params) => createFolder(params).then(() => undefined)} + onUpdateFolder={(params) => updateFolder(params).then(() => undefined)} + onDeleteFolder={(params) => deleteFolder(params).then(() => undefined)} + onUpdateTablo={(data) => + updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) + } + onInviteUser={inviteUser} + onCancelInvite={(params) => + cancelInvite({ ...params, inviteId: Number(params.inviteId) }) + } + /> + )} {activeSection === "discussion" && (
- +
)} - {activeSection === "events" && } + {activeSection === "events" && ( + ({ ...inv, id: String(inv.id) }))} + isInvitingUser={isInvitingUser} + isCancellingInvite={isCancellingInvite} + onCreateEvent={() => undefined} + onUpdateTablo={(data) => + updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) + } + onInviteUser={inviteUser} + onCancelInvite={(params) => + cancelInvite({ ...params, inviteId: Number(params.inviteId) }) + } + /> + )} {activeSection === "etapes" && ( { tabloTasks={tabloTasks} tabloId={tabloId ?? ""} isAdmin={isAdmin} + onCreateTask={(task) => + createTask({ + ...task, + status: task.status as "todo" | "in_progress" | "in_review" | "done", + }) + } + onCreateEtape={(params) => createEtape(params).then(() => undefined)} + isCreatingEtape={isCreatingEtape} /> )} {activeSection === "roadmap" && ( - + updateTask({ id: taskId, status })} + /> )}
@@ -826,82 +971,6 @@ export const TabloDetailsPage = () => {
- {/* Invite Input */} -
- setInviteEmail(e.target.value)} - placeholder="Email de l'utilisateur" - className="flex-1 min-h-[44px]" - /> - {isInvitingUser ? ( -
-
-
- ) : ( - - )} -
- - {/* Pending Invites */} - {pendingInvites && pendingInvites.length > 0 && ( -
-

- Invitations en attente ({pendingInvites.length}) -

-
- {pendingInvites.map((invite) => ( -
-
- - - -
-
- - {invite.invited_email} - -
- -
- ))} -
-
- )} - {/* Members List */} {filteredMembers && filteredMembers.length > 0 && (
@@ -938,373 +1007,125 @@ export const TabloDetailsPage = () => {
)} + + {/* Separator */} +
+ {/* Client Access Section */} +
+

Accès client

+

+ Invitez des clients externes via un lien magique +

+
+ + {/* Client Invite Input */} +
+ setClientInviteEmail(e.target.value)} + placeholder="Email du client" + className="flex-1 min-h-[44px]" + /> + {isCreatingClientInvite ? ( +
+
+
+ ) : ( + + )} +
+ + {/* Pending Client Invites */} + {pendingClientInvites && pendingClientInvites.length > 0 && ( +
+

+ Invitations client en attente ({pendingClientInvites.length}) +

+
+ {pendingClientInvites.map((invite) => { + const daysUntilExpiry = Math.ceil( + (new Date(invite.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + const isExpiringSoon = daysUntilExpiry < 5; + return ( +
+
+ + + +
+
+ + {invite.invited_email} + + + {isExpiringSoon && "⚠ "} + Expire dans {daysUntilExpiry} jour{daysUntilExpiry !== 1 ? "s" : ""} + +
+ {isExpiringSoon && ( + + Bientôt expiré + + )} + +
+ ); + })} +
+
+ )} +
); }; - -// ─── Etapes (Steps) section ───────────────────────────────────────────────── - -function EtapesSection({ - etapes, - tabloTasks, - tabloId, - isAdmin, -}: { - etapes: Etape[]; - tabloTasks: KanbanTask[]; - tabloId: string; - isAdmin: boolean; -}) { - const [expandedEtapes, setExpandedEtapes] = useState>( - new Set(etapes.map((e) => e.id)) - ); - const [addingTaskToEtape, setAddingTaskToEtape] = useState(null); - const [newEtapeTitle, setNewEtapeTitle] = useState(""); - const [newTaskTitle, setNewTaskTitle] = useState(""); - const { mutate: createTask } = useCreateTask(); - const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape(); - - const toggleEtape = (id: string) => { - setExpandedEtapes((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const handleAddTask = (etapeId: string) => { - const title = newTaskTitle.trim(); - if (!title || !tabloId) return; - createTask({ - tablo_id: tabloId, - title, - status: "todo", - parent_task_id: etapeId, - is_parent: false, - position: tabloTasks.filter((t) => t.parent_task_id === etapeId).length, - }); - setNewTaskTitle(""); - setAddingTaskToEtape(null); - }; - - const handleAddEtape = async () => { - const title = newEtapeTitle.trim(); - if (!title || !tabloId) { - return; - } - - const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1; - - await createEtape({ - tabloId, - title, - position: nextPosition, - }); - - setNewEtapeTitle(""); - }; - - const statusConfig: Record = { - todo: { - label: "À faire", - color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400", - }, - in_progress: { - label: "En cours", - color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400", - }, - in_review: { - label: "Vérification", - color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400", - }, - done: { - label: "Terminé", - color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400", - }, - }; - - return ( -
- {isAdmin && ( -
- setNewEtapeTitle(event.target.value)} - placeholder="Nom de la nouvelle étape..." - onKeyDown={(event) => { - if (event.key === "Enter") { - void handleAddEtape(); - } - }} - className="h-11 sm:h-9 sm:w-80" - /> - -
- )} - - {etapes.length === 0 ? ( -
- -

Aucune étape

-

- Les étapes permettent de structurer votre projet en grandes phases -

-
- ) : ( - etapes.map((etape, index) => { - const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id); - const doneCount = childTasks.filter((t) => t.status === "done").length; - const totalCount = childTasks.length; - const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0; - const isExpanded = expandedEtapes.has(etape.id); - - // Derive status from child tasks instead of etape.status - const derivedStatus = - totalCount === 0 - ? "todo" - : doneCount === totalCount - ? "done" - : doneCount > 0 - ? "in_progress" - : "todo"; - const status = statusConfig[derivedStatus] ?? statusConfig.todo; - - return ( -
- {/* Etape header */} - - - {/* Child tasks + add task */} - {isExpanded && ( -
- {childTasks.length > 0 && ( -
- {childTasks.map((task) => ( -
- {task.status === "done" ? ( - - ) : ( -
- )} - - {task.title} - - {task.due_date && ( -
- - - {new Intl.DateTimeFormat("fr-FR", { - day: "2-digit", - month: "short", - }).format(new Date(task.due_date))} - -
- )} - {task.status && ( - - {(statusConfig[task.status] ?? statusConfig.todo).label} - - )} -
- ))} -
- )} - - {childTasks.length === 0 && addingTaskToEtape !== etape.id && ( -
- Aucune tâche dans cette étape -
- )} - - {/* Inline add task */} - {addingTaskToEtape === etape.id ? ( -
-
- setNewTaskTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleAddTask(etape.id); - if (e.key === "Escape") { - setAddingTaskToEtape(null); - setNewTaskTitle(""); - } - }} - placeholder="Nom de la tâche..." - className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 min-w-0" - /> - - -
- ) : ( - - )} -
- )} -
- ); - }) - )} -
- ); -} - -// ─── Roadmap Section ───────────────────────────────────────────────────────── - -function RoadmapSection({ - tabloTasks, - onDateClick, -}: { - etapes: Etape[]; - tabloTasks: KanbanTask[]; - onDateClick: (date: Date) => void; -}) { - const { mutate: updateTask } = useUpdateTask(); - - return ( - updateTask({ id: taskId, status })} - /> - ); -} diff --git a/apps/main/src/pages/tablo.tsx b/apps/main/src/pages/tablo.tsx index 6221881..2a1caa3 100644 --- a/apps/main/src/pages/tablo.tsx +++ b/apps/main/src/pages/tablo.tsx @@ -3,6 +3,8 @@ import { DeleteTabloModal } from "@ui/components/DeleteTabloModal"; import { LoadingSpinner } from "@ui/components/LoadingSpinner"; import { toast } from "@xtablo/shared"; import { TabloInsert, UserTablo } from "@xtablo/shared/types/tablos.types"; +import { TaskModal } from "@xtablo/tablo-views"; +import { Badge } from "@xtablo/ui/components/badge"; import { Button } from "@xtablo/ui/components/button"; import { Empty, @@ -40,11 +42,9 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { DashboardActionCards } from "src/components/DashboardActionCards"; import { DashboardTaskList } from "src/components/DashboardTaskList"; import { InviteOrganizationModal } from "src/components/InviteOrganizationModal"; -import { TaskModal } from "src/components/kanban/TaskModal"; import { ProjectCardList } from "src/components/ProjectCardList"; -import { Badge } from "@xtablo/ui/components/badge"; -import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos"; import { useOrganization } from "../hooks/organization"; +import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos"; import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider"; function getTabloIcon(color: string | null | undefined) { @@ -107,7 +107,7 @@ export const TabloPage = () => { const user = useUser(); const { data: organizationData } = useOrganization(); - const isReadOnly = isReadOnlyUser || !canCreateTablo; + const isReadOnly = isReadOnlyUser || canCreateTablo === false; const getGreeting = () => { const hour = new Date().getHours(); diff --git a/apps/main/src/pages/tasks.tsx b/apps/main/src/pages/tasks.tsx index f4fa3f9..40c6568 100644 --- a/apps/main/src/pages/tasks.tsx +++ b/apps/main/src/pages/tasks.tsx @@ -1,5 +1,6 @@ import { LoadingSpinner } from "@ui/components/LoadingSpinner"; import type { KanbanColumn, KanbanTask } from "@xtablo/shared-types"; +import { GanttChart, TaskModal } from "@xtablo/tablo-views"; import { Button } from "@xtablo/ui/components/button"; import { DropdownMenu, @@ -40,8 +41,6 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { twMerge } from "tailwind-merge"; -import { GanttChart } from "../components/gantt/GanttChart"; -import { TaskModal } from "../components/kanban/TaskModal"; import { useTablosList } from "../hooks/tablos"; import { useAllTasks, useUpdateTask } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; diff --git a/apps/main/src/providers/UserStoreProvider.test.tsx b/apps/main/src/providers/UserStoreProvider.test.tsx index d445b17..7f7e908 100644 --- a/apps/main/src/providers/UserStoreProvider.test.tsx +++ b/apps/main/src/providers/UserStoreProvider.test.tsx @@ -62,6 +62,7 @@ describe("TestUserStoreProvider", () => { email: null, first_name: null, is_temporary: false, + is_client: false, last_name: null, short_user_id: "short-id", last_signed_in: null, diff --git a/apps/main/src/utils/testHelpers.tsx b/apps/main/src/utils/testHelpers.tsx index 5a49fb2..f370ba2 100644 --- a/apps/main/src/utils/testHelpers.tsx +++ b/apps/main/src/utils/testHelpers.tsx @@ -18,6 +18,7 @@ const defaultUser = { email: "john@example.com", avatar_url: "https://example.com/avatar.jpg", is_temporary: false, + is_client: false, last_signed_in: null, plan: "none" as const, created_at: new Date().toISOString(), diff --git a/apps/main/vite.config.ts b/apps/main/vite.config.ts index 87270d7..59e1e45 100644 --- a/apps/main/vite.config.ts +++ b/apps/main/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig(({ mode }) => { react(), visualizer() as PluginOption, tailwindcss(), - tsconfigPaths(), + tsconfigPaths({ ignoreConfigErrors: true }), ]; plugins.push( diff --git a/docs/superpowers/plans/2026-04-15-client-magic-links.md b/docs/superpowers/plans/2026-04-15-client-magic-links.md new file mode 100644 index 0000000..5fb702e --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-client-magic-links.md @@ -0,0 +1,1822 @@ +# Client Magic Links Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace temporary user invitations with magic links, served via a new `apps/clients` portal at `clients.xtablo.com`, powered by shared tablo view components extracted into `packages/tablo-views`. + +**Architecture:** Three parallel workstreams — (1) database + API changes for `is_client` users and `client_invites`, (2) extract tablo view components into `packages/tablo-views`, (3) scaffold `apps/clients` portal. The API serves both `apps/main` and `apps/clients` with permission scoping via middleware. + +**Tech Stack:** React 19, Vite, Cloudflare Workers, Hono, Supabase Auth (magic links), TanStack Query, Tailwind CSS v4, pnpm workspaces, Turborepo. + +**Spec:** `docs/superpowers/specs/2026-04-15-client-magic-links-design.md` + +--- + +## File Structure + +### New files + +**Database:** +- `supabase/migrations/20260415120000_add_client_invites.sql` — migration: `is_client` column + `client_invites` table + RLS + +**API:** +- `apps/api/src/routers/clientInvites.ts` — client invite endpoints (create, accept, list, cancel) +- `apps/api/src/__tests__/routes/clientInvites.test.ts` — tests for client invite routes + +**Package: `packages/tablo-views`:** +- `packages/tablo-views/package.json` +- `packages/tablo-views/tsconfig.json` +- `packages/tablo-views/src/index.ts` — barrel export +- `packages/tablo-views/src/TabloTasksSection.tsx` — moved from apps/main +- `packages/tablo-views/src/TabloFilesSection.tsx` — moved from apps/main +- `packages/tablo-views/src/TabloDiscussionSection.tsx` — moved from apps/main +- `packages/tablo-views/src/TabloEventsSection.tsx` — moved from apps/main +- `packages/tablo-views/src/EtapesSection.tsx` — extracted from tablo-details.tsx +- `packages/tablo-views/src/RoadmapSection.tsx` — extracted from tablo-details.tsx +- `packages/tablo-views/src/ChatMessages.tsx` — moved from apps/main +- `packages/tablo-views/src/TabloHeaderActions.tsx` — moved from apps/main +- `packages/tablo-views/src/hooks/useChat.ts` — moved from apps/main +- `packages/tablo-views/src/hooks/useChatUnread.ts` — moved from apps/main +- `packages/tablo-views/src/components/gantt/GanttChart.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/KanbanBoard.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/KanbanColumn.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/KanbanTaskCard.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/InlineTaskCreate.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/TaskModal.tsx` — moved from apps/main +- `packages/tablo-views/src/components/kanban/types.ts` — moved from apps/main + +**App: `apps/clients`:** +- `apps/clients/package.json` +- `apps/clients/vite.config.ts` +- `apps/clients/wrangler.toml` +- `apps/clients/worker/index.ts` +- `apps/clients/index.html` +- `apps/clients/tsconfig.json` +- `apps/clients/src/main.tsx` +- `apps/clients/src/main.css` +- `apps/clients/src/App.tsx` +- `apps/clients/src/routes.tsx` +- `apps/clients/src/i18n.ts` +- `apps/clients/src/pages/AuthCallback.tsx` +- `apps/clients/src/pages/ClientTabloPage.tsx` +- `apps/clients/src/pages/ClientTabloListPage.tsx` +- `apps/clients/src/components/ClientLayout.tsx` + +### Modified files + +- `apps/api/src/middlewares/middleware.ts` — add `is_client` check to `createProfileAccessMiddleware` +- `apps/api/src/routers/authRouter.ts` — mount `clientInvites` router +- `apps/api/src/routers/tablo.ts` — add `checkTabloAdmin` to new client invite endpoint +- `apps/api/src/helpers/helpers.ts` — add `createClientUser()` function +- `apps/api/src/helpers/billing.ts` — exclude `is_client` from `getBillableMemberCount` +- `apps/api/src/__tests__/middlewares/middlewares.test.ts` — add `is_client` middleware tests +- `apps/main/src/pages/tablo-details.tsx` — import sections from `@xtablo/tablo-views` instead of local +- `apps/main/src/components/TabloHeaderActions.tsx` — add client invite UI to share dialog +- `packages/shared-types/src/database.types.ts` — regenerated after migration (or manually add types) +- `package.json` (root) — add `dev:clients` script +- `pnpm-workspace.yaml` — already covers `apps/*` and `packages/*`, no change needed + +--- + +## Task 1: Database Migration — `is_client` Column and `client_invites` Table + +**Files:** +- Create: `supabase/migrations/20260415120000_add_client_invites.sql` +- Modify: `packages/shared-types/src/database.types.ts` + +- [ ] **Step 1: Write the migration SQL** + +```sql +-- Add is_client column to profiles +ALTER TABLE public.profiles + ADD COLUMN is_client boolean NOT NULL DEFAULT false; + +-- Create client_invites table +CREATE TABLE public.client_invites ( + id serial PRIMARY KEY, + tablo_id text NOT NULL REFERENCES public.tablos(id) ON DELETE CASCADE, + invited_email varchar(255) NOT NULL, + invited_by uuid NOT NULL REFERENCES public.profiles(id), + invite_token text NOT NULL, + expires_at timestamptz NOT NULL DEFAULT (now() + interval '30 days'), + is_pending boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Index for token lookups +CREATE UNIQUE INDEX idx_client_invites_token ON public.client_invites(invite_token); + +-- Index for listing invites by tablo +CREATE INDEX idx_client_invites_tablo ON public.client_invites(tablo_id, is_pending); + +-- RLS +ALTER TABLE public.client_invites ENABLE ROW LEVEL SECURITY; + +-- Admins can manage invites they created +CREATE POLICY "Admins can manage their client invites" + ON public.client_invites + FOR ALL + USING (invited_by = auth.uid()); + +-- Client users can read invites sent to their email +CREATE POLICY "Clients can read their own invites" + ON public.client_invites + FOR SELECT + USING ( + invited_email = ( + SELECT email FROM auth.users WHERE id = auth.uid() + ) + ); +``` + +Save to `supabase/migrations/20260415120000_add_client_invites.sql`. + +- [ ] **Step 2: Add TypeScript types for `client_invites`** + +Add the following types to `packages/shared-types/src/database.types.ts` in the `Tables` interface, following the existing pattern used by `tablo_invites`: + +```typescript +client_invites: { + Row: { + id: number; + tablo_id: string; + invited_email: string; + invited_by: string; + invite_token: string; + expires_at: string; + is_pending: boolean; + created_at: string; + }; + Insert: { + id?: number; + tablo_id: string; + invited_email: string; + invited_by: string; + invite_token: string; + expires_at?: string; + is_pending?: boolean; + created_at?: string; + }; + Update: { + id?: number; + tablo_id?: string; + invited_email?: string; + invited_by?: string; + invite_token?: string; + expires_at?: string; + is_pending?: boolean; + created_at?: string; + }; + Relationships: [ + { + foreignKeyName: "client_invites_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_invites_invited_by_fkey"; + columns: ["invited_by"]; + isOneToOne: false; + referencedRelation: "profiles"; + referencedColumns: ["id"]; + } + ]; +}; +``` + +Also add `is_client: boolean` to the `profiles` Row, Insert, and Update types. + +- [ ] **Step 3: Commit** + +```bash +git add supabase/migrations/20260415120000_add_client_invites.sql packages/shared-types/src/database.types.ts +git commit -m "feat(db): add is_client column and client_invites table" +``` + +--- + +## Task 2: API Middleware — Add `is_client` Permission Check + +**Files:** +- Modify: `apps/api/src/middlewares/middleware.ts:77-100` +- Modify: `apps/api/src/helpers/billing.ts:89-90` +- Test: `apps/api/src/__tests__/middlewares/middlewares.test.ts` + +- [ ] **Step 1: Write failing test for `is_client` user blocked by `regularUserCheckMiddleware`** + +In `apps/api/src/__tests__/middlewares/middlewares.test.ts`, add a new test in the "Regular user check middleware" describe block: + +```typescript +it("should return 401 for client users", async () => { + const app = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + app.use(middlewareManager.supabase); + app.use(middlewareManager.auth); + app.use(middlewareManager.regularUserCheck); + app.get("/test", (c) => c.json({ success: true })); + + mockSupabaseFrom.mockImplementation((table: string) => { + if (table === "profiles") { + return { + select: () => ({ + eq: () => ({ + single: () => + Promise.resolve({ + data: { is_temporary: false, is_client: true }, + error: null, + }), + }), + }), + }; + } + return {}; + }); + + const res = await app.request("/test", { + headers: { Authorization: "Bearer valid-token" }, + }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("User is read only"); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/api && pnpm test -- --run src/__tests__/middlewares/middlewares.test.ts` +Expected: FAIL — the current middleware only checks `is_temporary`, not `is_client` + +- [ ] **Step 3: Update middleware to check `is_client`** + +In `apps/api/src/middlewares/middleware.ts`, modify `createProfileAccessMiddleware` (line 77-100): + +Change the select from: +```typescript +.select("is_temporary") +``` +to: +```typescript +.select("is_temporary, is_client") +``` + +Change the check from: +```typescript +if (!allowTemporaryUsers && profile.is_temporary) { +``` +to: +```typescript +if ((!allowTemporaryUsers && profile.is_temporary) || profile.is_client) { +``` + +This blocks `is_client` users from all routes that use `regularUserCheckMiddleware`. Client-accessible routes don't use this middleware — they only require `auth`. + +- [ ] **Step 4: Update billing exclusion** + +In `apps/api/src/helpers/billing.ts`, change `getBillableMemberCount` (line 89-90): + +From: +```typescript +export const getBillableMemberCount = (profiles: BillingProfileRow[]) => + profiles.filter((profile) => profile.is_temporary !== true).length; +``` +To: +```typescript +export const getBillableMemberCount = (profiles: BillingProfileRow[]) => + profiles.filter((profile) => profile.is_temporary !== true && profile.is_client !== true).length; +``` + +Note: The `BillingProfileRow` type (defined earlier in billing.ts) needs `is_client` added. Find the type definition and add `is_client: boolean` alongside the existing `is_temporary: boolean`. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd apps/api && pnpm test -- --run src/__tests__/middlewares/middlewares.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/middlewares/middleware.ts apps/api/src/helpers/billing.ts apps/api/src/__tests__/middlewares/middlewares.test.ts +git commit -m "feat(api): add is_client check to middleware and billing" +``` + +--- + +## Task 3: API — Client Invite Endpoints + +**Files:** +- Create: `apps/api/src/routers/clientInvites.ts` +- Modify: `apps/api/src/routers/authRouter.ts` +- Modify: `apps/api/src/helpers/helpers.ts` +- Create: `apps/api/src/__tests__/routes/clientInvites.test.ts` + +- [ ] **Step 1: Add `createClientUser` helper** + +In `apps/api/src/helpers/helpers.ts`, add a new function after `createInvitedUser`: + +```typescript +export async function createClientUser( + supabase: SupabaseClient, + recipientEmail: string, + tabloId: string, + grantedBy: string +): Promise<{ success: boolean; error?: string; userId?: string }> { + // Check if user already exists + const { data: existingUsers } = await supabase.auth.admin.listUsers(); + const existingUser = existingUsers?.users?.find( + (u) => u.email?.toLowerCase() === recipientEmail.toLowerCase() + ); + + let userId: string; + + if (existingUser) { + userId = existingUser.id; + + // Mark as client if not already + await supabase + .from("profiles") + .update({ is_client: true }) + .eq("id", userId) + .eq("is_client", false); + } else { + // Create new auth user (no password — magic link only) + const { data: authData, error: authError } = await supabase.auth.admin.createUser({ + email: recipientEmail, + email_confirm: true, + user_metadata: { role: "client" }, + }); + + if (authError || !authData?.user) { + return { success: false, error: authError?.message ?? "Failed to create user" }; + } + + userId = authData.user.id; + + // Set is_client on profile + await supabase.from("profiles").update({ is_client: true }).eq("id", userId); + } + + // Grant tablo access if not already granted + const { data: existingAccess } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", tabloId) + .eq("user_id", userId) + .single(); + + if (!existingAccess) { + await supabase.from("tablo_access").insert({ + tablo_id: tabloId, + user_id: userId, + granted_by: grantedBy, + is_admin: false, + is_active: true, + }); + } else if (!existingAccess.is_active) { + await supabase + .from("tablo_access") + .update({ is_active: true }) + .eq("id", existingAccess.id); + } + + return { success: true, userId }; +} +``` + +- [ ] **Step 2: Create client invites router** + +Create `apps/api/src/routers/clientInvites.ts`: + +```typescript +import { Hono } from "hono"; +import { checkTabloAdmin } from "../helpers/helpers.js"; +import { generateToken } from "../helpers/token.js"; +import { MiddlewareManager } from "../middlewares/middleware.js"; +import { createClientUser } from "../helpers/helpers.js"; +import type { SupabaseClient, User } from "@supabase/supabase-js"; + +type Env = { + Variables: { + supabase: SupabaseClient; + user: User; + }; +}; + +export const getClientInvitesRouter = () => { + const router = new Hono(); + const middlewareManager = MiddlewareManager.getInstance(); + + // Create client invite (admin only) + router.post( + "/:tabloId", + middlewareManager.regularUserCheck, + checkTabloAdmin, + async (c) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + const tabloId = c.req.param("tabloId"); + const { email } = await c.req.json<{ email: string }>(); + + if (!email || !email.includes("@")) { + return c.json({ error: "Invalid email" }, 400); + } + + const token = generateToken(); + + // Create client user + tablo access + const result = await createClientUser(supabase, email, tabloId, user.id); + if (!result.success) { + return c.json({ error: result.error }, 500); + } + + // Create client_invites record + const { error: insertError } = await supabase.from("client_invites").insert({ + tablo_id: tabloId, + invited_email: email.toLowerCase(), + invited_by: user.id, + invite_token: token, + }); + + if (insertError) { + return c.json({ error: insertError.message }, 500); + } + + // Generate Supabase magic link + const redirectTo = `${c.req.header("origin")?.replace("app.", "clients.") ?? "https://clients.xtablo.com"}/auth/callback?token=${token}`; + + const { error: linkError } = await supabase.auth.admin.generateLink({ + type: "magiclink", + email, + options: { redirectTo }, + }); + + if (linkError) { + return c.json({ error: "Failed to send magic link" }, 500); + } + + return c.json({ success: true }); + } + ); + + // Accept client invite via token + router.post("/accept/:token", async (c) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + const token = c.req.param("token"); + + const { data: invite, error } = await supabase + .from("client_invites") + .select("*") + .eq("invite_token", token) + .eq("is_pending", true) + .single(); + + if (error || !invite) { + return c.json({ error: "Invalid or expired invite" }, 404); + } + + // Check expiration + if (new Date(invite.expires_at) < new Date()) { + return c.json({ error: "Invite has expired" }, 410); + } + + // Verify email matches + const { data: userProfile } = await supabase + .from("profiles") + .select("email") + .eq("id", user.id) + .single(); + + if (userProfile?.email?.toLowerCase() !== invite.invited_email.toLowerCase()) { + return c.json({ error: "Email mismatch" }, 403); + } + + // Mark invite as accepted + await supabase + .from("client_invites") + .update({ is_pending: false }) + .eq("id", invite.id); + + // Ensure tablo_access is active + const { data: access } = await supabase + .from("tablo_access") + .select("id, is_active") + .eq("tablo_id", invite.tablo_id) + .eq("user_id", user.id) + .single(); + + if (access && !access.is_active) { + await supabase + .from("tablo_access") + .update({ is_active: true }) + .eq("id", access.id); + } + + return c.json({ success: true, tabloId: invite.tablo_id }); + }); + + // List pending client invites for a tablo (admin only) + router.get( + "/:tabloId/pending", + middlewareManager.regularUserCheck, + checkTabloAdmin, + async (c) => { + const supabase = c.get("supabase"); + const tabloId = c.req.param("tabloId"); + + const { data, error } = await supabase + .from("client_invites") + .select("id, invited_email, expires_at, is_pending, created_at") + .eq("tablo_id", tabloId) + .eq("is_pending", true) + .order("created_at", { ascending: false }); + + if (error) { + return c.json({ error: error.message }, 500); + } + + return c.json({ invites: data }); + } + ); + + // Cancel client invite (admin only) + router.delete( + "/:tabloId/:inviteId", + middlewareManager.regularUserCheck, + checkTabloAdmin, + async (c) => { + const supabase = c.get("supabase"); + const inviteId = c.req.param("inviteId"); + const tabloId = c.req.param("tabloId"); + + const { data: invite } = await supabase + .from("client_invites") + .select("invited_email") + .eq("id", Number(inviteId)) + .eq("tablo_id", tabloId) + .single(); + + if (!invite) { + return c.json({ error: "Invite not found" }, 404); + } + + // Mark as not pending + await supabase + .from("client_invites") + .update({ is_pending: false }) + .eq("id", Number(inviteId)); + + // Revoke tablo access for client user + const { data: profile } = await supabase + .from("profiles") + .select("id, is_client") + .eq("email", invite.invited_email) + .single(); + + if (profile?.is_client) { + await supabase + .from("tablo_access") + .update({ is_active: false }) + .eq("tablo_id", tabloId) + .eq("user_id", profile.id); + } + + return c.json({ success: true }); + } + ); + + return router; +}; +``` + +- [ ] **Step 3: Mount the router in authRouter.ts** + +In `apps/api/src/routers/authRouter.ts`, add the import and route: + +```typescript +import { getClientInvitesRouter } from "./clientInvites.js"; +``` + +Add after the existing routes (before `return authRouter`): +```typescript +authRouter.route("/client-invites", getClientInvitesRouter()); +``` + +- [ ] **Step 4: Write tests for client invite endpoints** + +Create `apps/api/src/__tests__/routes/clientInvites.test.ts`. Follow the existing test patterns from `apps/api/src/__tests__/routes/tablo.test.ts` for mocking supabase. Key test cases: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +// ... test setup matching existing patterns + +describe("Client Invites Router", () => { + describe("POST /:tabloId (create invite)", () => { + it("should create client invite and return success", async () => { + // Mock admin access check, user creation, invite insert, magic link generation + // Assert: 200, { success: true } + }); + + it("should reject non-admin users", async () => { + // Mock non-admin tablo_access + // Assert: 403 + }); + + it("should reject invalid email", async () => { + // Send email without @ + // Assert: 400 + }); + }); + + describe("POST /accept/:token", () => { + it("should accept valid invite and return tabloId", async () => { + // Mock valid pending invite, matching email, active tablo_access + // Assert: 200, { success: true, tabloId: "..." } + }); + + it("should reject expired invite", async () => { + // Mock invite with expires_at in the past + // Assert: 410 + }); + + it("should reject email mismatch", async () => { + // Mock invite with different email than authenticated user + // Assert: 403 + }); + }); + + describe("GET /:tabloId/pending", () => { + it("should return pending invites for admin", async () => { + // Mock admin + pending invites + // Assert: 200, { invites: [...] } + }); + }); + + describe("DELETE /:tabloId/:inviteId", () => { + it("should cancel invite and revoke access for client user", async () => { + // Mock invite + client profile + // Assert: 200, tablo_access set to inactive + }); + }); +}); +``` + +Fill in complete mock setup following the patterns in `apps/api/src/__tests__/routes/tablo.test.ts`. + +- [ ] **Step 5: Run tests** + +Run: `cd apps/api && pnpm test -- --run` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/routers/clientInvites.ts apps/api/src/routers/authRouter.ts apps/api/src/helpers/helpers.ts apps/api/src/__tests__/routes/clientInvites.test.ts +git commit -m "feat(api): add client invite endpoints with magic link flow" +``` + +--- + +## Task 4: Scaffold `packages/tablo-views` + +**Files:** +- Create: `packages/tablo-views/package.json` +- Create: `packages/tablo-views/tsconfig.json` +- Create: `packages/tablo-views/src/index.ts` + +- [ ] **Step 1: Create `packages/tablo-views/package.json`** + +```json +{ + "name": "@xtablo/tablo-views", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks/*": "./src/hooks/*.ts", + "./*": "./src/*.tsx" + }, + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.69.0", + "@xtablo/chat-ui": "workspace:*", + "@xtablo/shared": "workspace:*", + "@xtablo/shared-types": "workspace:*", + "@xtablo/ui": "workspace:*", + "date-fns": "^4.1.0", + "lucide-react": "^0.460.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-i18next": "^16.2.0", + "react-router-dom": "^7.9.4", + "tailwind-merge": "^3.0.2" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "typescript": "^5.7.0" + } +} +``` + +- [ ] **Step 2: Create `packages/tablo-views/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@xtablo/ui": ["../ui/src"], + "@xtablo/ui/*": ["../ui/src/*"], + "@xtablo/shared": ["../shared/src"], + "@xtablo/shared/*": ["../shared/src/*"] + } + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create `packages/tablo-views/src/index.ts`** + +```typescript +// Section components +export { TabloTasksSection } from "./TabloTasksSection"; +export { TabloFilesSection } from "./TabloFilesSection"; +export { TabloDiscussionSection } from "./TabloDiscussionSection"; +export { TabloEventsSection } from "./TabloEventsSection"; +export { EtapesSection } from "./EtapesSection"; +export { RoadmapSection } from "./RoadmapSection"; +export { TabloHeaderActions } from "./TabloHeaderActions"; +export { ChatMessages } from "./ChatMessages"; + +// Sub-components +export { GanttChart } from "./components/gantt/GanttChart"; +export { TaskModal } from "./components/kanban/TaskModal"; +export { KanbanBoard } from "./components/kanban/KanbanBoard"; + +// Hooks +export { useChat } from "./hooks/useChat"; +export { useChatUnread } from "./hooks/useChatUnread"; + +// Types +export type { TabloMember } from "./components/kanban/types"; +``` + +- [ ] **Step 4: Install dependencies** + +Run: `pnpm install` + +- [ ] **Step 5: Commit** + +```bash +git add packages/tablo-views/ +git commit -m "feat: scaffold packages/tablo-views package" +``` + +--- + +## Task 5: Move Tablo View Components to `packages/tablo-views` + +This is the largest task. Move each component from `apps/main/src/` to `packages/tablo-views/src/`, updating internal imports. + +**Files to move** (source -> destination): +- `apps/main/src/components/TabloTasksSection.tsx` -> `packages/tablo-views/src/TabloTasksSection.tsx` +- `apps/main/src/components/TabloFilesSection.tsx` -> `packages/tablo-views/src/TabloFilesSection.tsx` +- `apps/main/src/components/TabloDiscussionSection.tsx` -> `packages/tablo-views/src/TabloDiscussionSection.tsx` +- `apps/main/src/components/TabloEventsSection.tsx` -> `packages/tablo-views/src/TabloEventsSection.tsx` +- `apps/main/src/components/TabloHeaderActions.tsx` -> `packages/tablo-views/src/TabloHeaderActions.tsx` +- `apps/main/src/components/ChatMessages.tsx` -> `packages/tablo-views/src/ChatMessages.tsx` +- `apps/main/src/hooks/useChat.ts` -> `packages/tablo-views/src/hooks/useChat.ts` +- `apps/main/src/hooks/useChatUnread.ts` -> `packages/tablo-views/src/hooks/useChatUnread.ts` +- `apps/main/src/components/gantt/GanttChart.tsx` -> `packages/tablo-views/src/components/gantt/GanttChart.tsx` +- `apps/main/src/components/kanban/KanbanBoard.tsx` -> `packages/tablo-views/src/components/kanban/KanbanBoard.tsx` +- `apps/main/src/components/kanban/KanbanColumn.tsx` -> `packages/tablo-views/src/components/kanban/KanbanColumn.tsx` +- `apps/main/src/components/kanban/KanbanTaskCard.tsx` -> `packages/tablo-views/src/components/kanban/KanbanTaskCard.tsx` +- `apps/main/src/components/kanban/InlineTaskCreate.tsx` -> `packages/tablo-views/src/components/kanban/InlineTaskCreate.tsx` +- `apps/main/src/components/kanban/TaskModal.tsx` -> `packages/tablo-views/src/components/kanban/TaskModal.tsx` +- `apps/main/src/components/kanban/types.ts` -> `packages/tablo-views/src/components/kanban/types.ts` + +**Files to extract from `tablo-details.tsx`:** +- `EtapesSection` function (lines 950-1288) -> `packages/tablo-views/src/EtapesSection.tsx` +- `RoadmapSection` function (lines 1292-1309) -> `packages/tablo-views/src/RoadmapSection.tsx` + +- [ ] **Step 1: Move kanban sub-components first (no import changes needed between them)** + +Copy each file from `apps/main/src/components/kanban/` to `packages/tablo-views/src/components/kanban/`. The internal relative imports between kanban files (`./KanbanColumn`, `./KanbanTaskCard`, `./InlineTaskCreate`, `./types`) stay the same. + +For `TaskModal.tsx`, update the hook imports from: +```typescript +import { useTabloMembers } from "../../hooks/tablos"; +import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks"; +``` +to: +```typescript +import { useTabloMembers } from "@xtablo/shared/hooks/tablos"; +import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "@xtablo/shared/hooks/tasks"; +``` + +**Important:** Check if these hooks exist in `@xtablo/shared`. If they are in `apps/main/src/hooks/`, they need to stay as peer dependencies. In that case, `TaskModal` should accept the needed callbacks as props instead of importing hooks directly. Examine the actual hooks to decide. If the hooks are in `apps/main`, accept them as props: + +```typescript +interface TaskModalProps { + isOpen: boolean; + tabloId?: string; + taskId?: string; + onClose: () => void; + members?: TabloMember[]; + initialStatus?: TaskStatus; + etapes?: Etape[]; + tablos?: UserTablo[]; + allowTabloSelection?: boolean; + initialDueDate?: Date; +} +``` + +The hooks are already used within TaskModal, so the simplest approach is to keep `@xtablo/tablo-views` depending on the same hooks the main app uses. Since hooks like `useCreateTask`, `useTabloMembers` etc. are in `apps/main/src/hooks/`, they need to either: +1. Move to `@xtablo/shared/hooks/` (if they're pure React Query wrappers around API calls), OR +2. Stay in `apps/main` and be passed as props/callbacks + +The decision depends on whether these hooks have dependencies on app-specific context (like `UserStoreProvider`). Check each hook — if it only uses `useSession` and API calls, move it to `@xtablo/shared`. If it uses `useUser()` from `UserStoreProvider`, keep it in the app and pass data as props. + +- [ ] **Step 2: Move GanttChart** + +Copy `apps/main/src/components/gantt/GanttChart.tsx` to `packages/tablo-views/src/components/gantt/GanttChart.tsx`. + +Update the `LoadingSpinner` import. If it comes from `@ui/components/LoadingSpinner` (a local alias in apps/main), change to the full path or use a simple inline spinner. + +- [ ] **Step 3: Move section components** + +For each section component (`TabloTasksSection`, `TabloFilesSection`, `TabloDiscussionSection`, `TabloEventsSection`, `TabloHeaderActions`, `ChatMessages`): + +1. Copy the file to `packages/tablo-views/src/` +2. Update local imports to either: + - Use `@xtablo/shared/hooks/*` if the hook exists there + - Use relative imports within `packages/tablo-views/` for co-located files (e.g., `./ChatMessages`, `./components/kanban/KanbanBoard`) +3. Replace `@ui/components/LoadingSpinner` with `@xtablo/ui/components/loading-spinner` or equivalent + +Key import changes per file: + +**TabloTasksSection.tsx:** +- `../hooks/tablos` -> check if available in `@xtablo/shared/hooks/tablos` +- `../hooks/tasks` -> check if available in `@xtablo/shared/hooks/tasks` +- `./kanban/KanbanBoard` -> `./components/kanban/KanbanBoard` +- `./kanban/TaskModal` -> `./components/kanban/TaskModal` +- `./TabloHeaderActions` -> `./TabloHeaderActions` + +**TabloFilesSection.tsx:** +- `../hooks/tablo_data` -> check if in `@xtablo/shared` +- `../hooks/tablo_folders` -> check if in `@xtablo/shared` +- `../providers/UserStoreProvider` -> This is app-specific. The `useIsReadOnlyUser` and `useUser` hooks depend on Zustand store from `apps/main`. Solution: accept `isReadOnly: boolean` and `currentUser` as props instead. + +**TabloDiscussionSection.tsx:** +- `../hooks/useChat` -> `./hooks/useChat` +- `../hooks/tablos` -> check availability +- `../providers/UserStoreProvider` -> accept `currentUser` as prop +- `./ChatMessages` -> `./ChatMessages` + +**TabloEventsSection.tsx:** +- `../hooks/events` -> check availability +- `../providers/UserStoreProvider` -> accept `isReadOnly` as prop +- `./TabloHeaderActions` -> `./TabloHeaderActions` + +- [ ] **Step 4: Extract EtapesSection from tablo-details.tsx** + +Create `packages/tablo-views/src/EtapesSection.tsx` with the content from lines 950-1288 of `tablo-details.tsx`. Add the necessary imports at the top: + +```typescript +import { cn } from "@xtablo/shared"; +import type { Etape, KanbanTask } from "@xtablo/shared-types"; +import { Button } from "@xtablo/ui/components/button"; +import { Input } from "@xtablo/ui/components/input"; +import { + CalendarIcon, + ChevronDownIcon, + ChevronRightIcon, + CircleCheckIcon, + PlusIcon, +} from "lucide-react"; +import { useState } from "react"; +``` + +The hooks `useCreateTask` and `useCreateEtape` need to be available. If they're in `apps/main/src/hooks/tasks.ts`, accept callbacks as props: + +```typescript +interface EtapesSectionProps { + etapes: Etape[]; + tabloTasks: KanbanTask[]; + tabloId: string; + isAdmin: boolean; + onCreateTask: (task: { tablo_id: string; title: string; status: string; parent_task_id: string; is_parent: boolean; position: number }) => void; + onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise; + isCreatingEtape?: boolean; +} +``` + +- [ ] **Step 5: Extract RoadmapSection from tablo-details.tsx** + +Create `packages/tablo-views/src/RoadmapSection.tsx`: + +```typescript +import type { Etape, KanbanTask } from "@xtablo/shared-types"; +import { GanttChart } from "./components/gantt/GanttChart"; + +interface RoadmapSectionProps { + etapes: Etape[]; + tabloTasks: KanbanTask[]; + onDateClick: (date: Date) => void; + onTaskStatusChange: (taskId: string, status: string) => void; +} + +export function RoadmapSection({ + tabloTasks, + onDateClick, + onTaskStatusChange, +}: RoadmapSectionProps) { + return ( + + ); +} +``` + +- [ ] **Step 6: Delete moved files from `apps/main`** + +Remove the original files from `apps/main` that were moved. Do NOT delete files that are still needed by other parts of `apps/main` (e.g., `ClickOutside`, `ImageColorPicker` used by `TabloHeaderActions` — move those too or keep them and import from the new location). + +- [ ] **Step 7: Run typecheck** + +Run: `pnpm typecheck` +Expected: No errors. Fix any broken imports. + +- [ ] **Step 8: Commit** + +```bash +git add packages/tablo-views/src/ apps/main/src/ +git commit -m "refactor: move tablo view components to packages/tablo-views" +``` + +--- + +## Task 6: Update `apps/main` to Import from `packages/tablo-views` + +**Files:** +- Modify: `apps/main/package.json` +- Modify: `apps/main/src/pages/tablo-details.tsx` +- Modify: `apps/main/src/pages/chat.tsx` (if it imports useChat or ChatMessages) + +- [ ] **Step 1: Add `@xtablo/tablo-views` dependency to `apps/main`** + +In `apps/main/package.json`, add to dependencies: +```json +"@xtablo/tablo-views": "workspace:*" +``` + +- [ ] **Step 2: Update imports in `tablo-details.tsx`** + +Replace the local imports with package imports: + +From: +```typescript +import { GanttChart } from "../components/gantt/GanttChart"; +import { TaskModal } from "../components/kanban/TaskModal"; +import { TabloDiscussionSection } from "../components/TabloDiscussionSection"; +import { TabloEventsSection } from "../components/TabloEventsSection"; +import { TabloFilesSection } from "../components/TabloFilesSection"; +import { TabloTasksSection } from "../components/TabloTasksSection"; +import { useChatUnread } from "../hooks/useChatUnread"; +``` + +To: +```typescript +import { + TabloDiscussionSection, + TabloEventsSection, + TabloFilesSection, + TabloTasksSection, + EtapesSection, + RoadmapSection, + TaskModal, + useChatUnread, +} from "@xtablo/tablo-views"; +``` + +Remove the inline `EtapesSection` and `RoadmapSection` function definitions from `tablo-details.tsx` (they now live in the package). + +Update the JSX where `EtapesSection` and `RoadmapSection` are rendered to pass the new callback props (if hooks were replaced with props in Task 5). + +- [ ] **Step 3: Update chat.tsx if needed** + +If `apps/main/src/pages/chat.tsx` imports `useChat` or `ChatMessages` from local paths, update to import from `@xtablo/tablo-views`. + +- [ ] **Step 4: Run pnpm install and typecheck** + +Run: `pnpm install && pnpm typecheck` +Expected: No errors + +- [ ] **Step 5: Run dev server and verify tablo details page works** + +Run: `pnpm dev:main` +Navigate to a tablo details page. Verify all tabs render correctly: overview, etapes, tasks, files, discussion, events, roadmap. + +- [ ] **Step 6: Commit** + +```bash +git add apps/main/package.json apps/main/src/ +git commit -m "refactor: update apps/main to import tablo views from shared package" +``` + +--- + +## Task 7: Scaffold `apps/clients` + +**Files:** +- Create: all files under `apps/clients/` +- Modify: `package.json` (root) — add `dev:clients` script + +- [ ] **Step 1: Create `apps/clients/package.json`** + +```json +{ + "name": "@xtablo/clients", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev --port 5175", + "build": "tsc -b && vite build --mode production", + "build:staging": "tsc -b && vite build --mode staging", + "build:prod": "tsc -b && vite build --mode production", + "deploy": "wrangler deploy", + "typecheck": "tsc -b", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "preview": "vite preview", + "clean": "rm -rf dist .vite tsconfig.tsbuildinfo node_modules/.vite" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@cloudflare/vite-plugin": "^1.9.4", + "@tailwindcss/vite": "^4.0.14", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.14", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.0", + "vite": "^6.2.2", + "vite-tsconfig-paths": "^5.1.4", + "wrangler": "^4.24.3" + }, + "dependencies": { + "@tanstack/react-query": "^5.69.0", + "@xtablo/shared": "workspace:*", + "@xtablo/shared-types": "workspace:*", + "@xtablo/tablo-views": "workspace:*", + "@xtablo/ui": "workspace:*", + "@xtablo/chat-ui": "workspace:*", + "i18next": "^25.6.0", + "i18next-browser-languagedetector": "^8.2.0", + "lucide-react": "^0.460.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-i18next": "^16.2.0", + "react-router-dom": "^7.9.4", + "tailwind-merge": "^3.0.2", + "zustand": "^5.0.5" + } +} +``` + +- [ ] **Step 2: Create `apps/clients/vite.config.ts`** + +```typescript +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type PluginOption } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(({ mode }) => { + const plugins: PluginOption[] = [ + react(), + tailwindcss(), + tsconfigPaths(), + ]; + + if (mode !== "test" && process.env.VITEST !== "true") { + plugins.push(cloudflare()); + } + + return { + plugins, + server: { + cors: false, + }, + }; +}); +``` + +- [ ] **Step 3: Create `apps/clients/wrangler.toml`** + +```toml +name = "xtablo-clients" +main = "worker/index.ts" +compatibility_date = "2025-07-09" + +[assets] +directory = "./dist/" +not_found_handling = "single-page-application" + +[observability] +enabled = true + +[env.staging] +route = { pattern = "clients-staging.xtablo.com", custom_domain = true } + +[env.production] +route = { pattern = "clients.xtablo.com", custom_domain = true } +``` + +- [ ] **Step 4: Create `apps/clients/worker/index.ts`** + +```typescript +export default { + fetch(request: Request) { + const url = new URL(request.url); + + if (url.pathname.startsWith("/api/")) { + return Response.json({ name: "Cloudflare" }); + } + return new Response(null, { status: 404 }); + }, +}; +``` + +- [ ] **Step 5: Create `apps/clients/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@xtablo/ui": ["../../packages/ui/src"], + "@xtablo/ui/*": ["../../packages/ui/src/*"], + "@xtablo/shared": ["../../packages/shared/src"], + "@xtablo/shared/*": ["../../packages/shared/src/*"], + "@xtablo/tablo-views": ["../../packages/tablo-views/src"], + "@xtablo/tablo-views/*": ["../../packages/tablo-views/src/*"] + } + }, + "include": ["src"], + "references": [] +} +``` + +- [ ] **Step 6: Create `apps/clients/index.html`** + +```html + + + + + + Xtablo — Client Portal + + +
+ + + +``` + +- [ ] **Step 7: Create `apps/clients/src/main.css`** + +Copy from `apps/external/src/main.css` (or `apps/main/src/main.css` if it has Tailwind imports). At minimum: + +```css +@import "tailwindcss"; +@import "tw-animate-css"; +@import "@xtablo/ui/styles/globals.css"; +``` + +- [ ] **Step 8: Create `apps/clients/src/i18n.ts`** + +Copy from `apps/external/src/i18n.ts` — same i18next setup with browser language detection. + +- [ ] **Step 9: Create `apps/clients/src/main.tsx`** + +```typescript +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 "@xtablo/ui/styles/globals.css"; +import "./main.css"; +import "./i18n"; + +createRoot(document.getElementById("client-root")!).render( + + + + + + + + + + + + +); +``` + +- [ ] **Step 10: Create `apps/clients/src/App.tsx`** + +```typescript +import AppRoutes from "./routes"; + +export default function App() { + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 11: Create `apps/clients/src/routes.tsx`** + +```typescript +import { Route, Routes } from "react-router-dom"; +import { ClientLayout } from "./components/ClientLayout"; +import { AuthCallback } from "./pages/AuthCallback"; +import { ClientTabloPage } from "./pages/ClientTabloPage"; +import { ClientTabloListPage } from "./pages/ClientTabloListPage"; + +export default function AppRoutes() { + return ( + + } /> + }> + } /> + } /> + + + ); +} +``` + +- [ ] **Step 12: Add `dev:clients` script to root `package.json`** + +Add to the `scripts` section of the root `package.json`: +```json +"dev:clients": "turbo dev --filter=@xtablo/clients" +``` + +- [ ] **Step 13: Run pnpm install** + +Run: `pnpm install` + +- [ ] **Step 14: Commit** + +```bash +git add apps/clients/ package.json +git commit -m "feat: scaffold apps/clients Cloudflare Worker app" +``` + +--- + +## Task 8: Build `apps/clients` Pages and Layout + +**Files:** +- Create: `apps/clients/src/components/ClientLayout.tsx` +- Create: `apps/clients/src/pages/AuthCallback.tsx` +- Create: `apps/clients/src/pages/ClientTabloPage.tsx` +- Create: `apps/clients/src/pages/ClientTabloListPage.tsx` + +- [ ] **Step 1: Create `ClientLayout.tsx`** + +```typescript +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar"; +import { Button } from "@xtablo/ui/components/button"; +import { LogOut } from "lucide-react"; +import { Outlet, useNavigate } from "react-router-dom"; +import { supabase } from "@xtablo/shared/lib/supabase"; + +export function ClientLayout() { + const { session } = useSession(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await supabase.auth.signOut(); + navigate("/auth/callback"); + }; + + if (!session) { + return ( +
+

+ Your session has expired. Please use the link sent to your email to access this portal. +

+
+ ); + } + + const userEmail = session.user.email ?? ""; + const initials = userEmail.substring(0, 2).toUpperCase(); + + return ( +
+
+
+ Xtablo +
+
+ + {initials} + + {userEmail} + +
+
+
+ +
+
+ ); +} +``` + +- [ ] **Step 2: Create `AuthCallback.tsx`** + +```typescript +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { supabase } from "@xtablo/shared/lib/supabase"; + +export function AuthCallback() { + const [searchParams] = useSearchParams(); + const { session } = useSession(); + const navigate = useNavigate(); + const [error, setError] = useState(null); + + const inviteToken = searchParams.get("token"); + + useEffect(() => { + if (!session || !inviteToken) return; + + const acceptInvite = async () => { + const apiBase = import.meta.env.VITE_API_URL as string; + const res = await fetch(`${apiBase}/api/v1/client-invites/accept/${inviteToken}`, { + method: "POST", + headers: { + Authorization: `Bearer ${session.access_token}`, + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + const body = await res.json(); + setError(body.error ?? "Failed to accept invite"); + return; + } + + const { tabloId } = await res.json(); + navigate(`/tablo/${tabloId}`, { replace: true }); + }; + + acceptInvite(); + }, [session, inviteToken, navigate]); + + if (error) { + return ( +
+
+

{error}

+

+ Please contact the person who invited you for a new link. +

+
+
+ ); + } + + return ( +
+

Authenticating...

+
+ ); +} +``` + +- [ ] **Step 3: Create `ClientTabloPage.tsx`** + +```typescript +import { useQuery } from "@tanstack/react-query"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { api } from "@xtablo/shared/lib/api"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { + TabloDiscussionSection, + TabloEventsSection, + TabloFilesSection, + TabloTasksSection, + EtapesSection, + RoadmapSection, +} from "@xtablo/tablo-views"; +import { + CalendarIcon, + FolderIcon, + KanbanIcon, + LayoutDashboardIcon, + ListChecksIcon, + MapIcon, + MessageCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; + +type TabSection = "overview" | "etapes" | "tasks" | "files" | "discussion" | "events" | "roadmap"; + +const TABS: { id: TabSection; label: string; icon: React.ElementType }[] = [ + { id: "overview", label: "Overview", icon: LayoutDashboardIcon }, + { id: "etapes", label: "Stages", icon: ListChecksIcon }, + { id: "tasks", label: "Tasks", icon: KanbanIcon }, + { id: "files", label: "Files", icon: FolderIcon }, + { id: "discussion", label: "Discussion", icon: MessageCircleIcon }, + { id: "events", label: "Events", icon: CalendarIcon }, + { id: "roadmap", label: "Roadmap", icon: MapIcon }, +]; + +export function ClientTabloPage() { + const { tabloId } = useParams<{ tabloId: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + const { session } = useSession(); + + const sectionParam = searchParams.get("section") as TabSection | null; + const activeSection: TabSection = + sectionParam && TABS.some((t) => t.id === sectionParam) ? sectionParam : "overview"; + + // Fetch tablo details via API + const { data: tablo, isLoading } = useQuery({ + queryKey: ["tablos", tabloId], + queryFn: async () => { + const res = await api.get(`/api/v1/tablos/${tabloId}`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + return res.data; + }, + enabled: !!tabloId && !!session, + }); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (!tablo) return null; + + return ( +
+ {/* Tablo header */} +
+

{tablo.name}

+ {tablo.description && ( +

{tablo.description}

+ )} +
+ + {/* Tab navigation */} +
+ {TABS.map((tab) => { + const Icon = tab.icon; + const isActive = activeSection === tab.id; + return ( + + ); + })} +
+ + {/* Tab content */} +
+ {activeSection === "tasks" && ( + + )} + {activeSection === "files" && ( + + )} + {activeSection === "discussion" && ( + + )} + {activeSection === "events" && ( + + )} + {/* etapes, roadmap, overview sections rendered similarly */} +
+
+ ); +} +``` + +Adapt the props to match whatever interface the extracted components expose after Task 5. The key difference from `apps/main` is: `isAdmin` is always `false`, and no share/invite/delete UI is rendered. + +- [ ] **Step 4: Create `ClientTabloListPage.tsx`** + +```typescript +import { useQuery } from "@tanstack/react-query"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { api } from "@xtablo/shared/lib/api"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import { FolderIcon } from "lucide-react"; +import { Link, Navigate } from "react-router-dom"; + +export function ClientTabloListPage() { + const { session } = useSession(); + + const { data: tablos, isLoading } = useQuery({ + queryKey: ["tablos"], + queryFn: async () => { + const res = await api.get("/api/v1/tablos", { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + return res.data; + }, + enabled: !!session, + }); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (!tablos || tablos.length === 0) { + return ( +
+

No projects available.

+
+ ); + } + + // If only one tablo, redirect directly + if (tablos.length === 1) { + return ; + } + + return ( +
+

Your Projects

+
+ {tablos.map((tablo) => ( + + +
+

{tablo.name}

+ {tablo.description && ( +

{tablo.description}

+ )} +
+ + ))} +
+
+ ); +} +``` + +- [ ] **Step 5: Run dev server and verify** + +Run: `pnpm dev:clients` +Expected: App starts on port 5175. The auth callback page shows "Authenticating..." without a session. The list page shows "No projects available" when not authenticated. + +- [ ] **Step 6: Commit** + +```bash +git add apps/clients/src/ +git commit -m "feat(clients): add layout, auth callback, tablo page, and list page" +``` + +--- + +## Task 9: Client Invite UI in `apps/main` Share Dialog + +**Files:** +- Modify: `apps/main/src/components/TabloHeaderActions.tsx` (or wherever the share dialog lives) +- Create: `apps/main/src/hooks/client_invites.ts` + +- [ ] **Step 1: Create client invite hooks** + +Create `apps/main/src/hooks/client_invites.ts`: + +```typescript +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSession } from "@xtablo/shared/contexts/SessionContext"; +import { api } from "@xtablo/shared/lib/api"; +import { toast } from "@xtablo/shared"; + +export function usePendingClientInvites(tabloId: string) { + const { session } = useSession(); + + return useQuery({ + queryKey: ["client-invites", tabloId], + queryFn: async () => { + const res = await api.get(`/api/v1/client-invites/${tabloId}/pending`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + return res.data.invites as { + id: number; + invited_email: string; + expires_at: string; + is_pending: boolean; + created_at: string; + }[]; + }, + enabled: !!tabloId && !!session, + }); +} + +export function useCreateClientInvite() { + const { session } = useSession(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, email }: { tabloId: string; email: string }) => { + const res = await api.post( + `/api/v1/client-invites/${tabloId}`, + { email }, + { headers: { Authorization: `Bearer ${session?.access_token}` } } + ); + return res.data; + }, + onSuccess: (_, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] }); + toast.add({ title: "Client invite sent", type: "success" }, { timeout: 3000 }); + }, + onError: () => { + toast.add({ title: "Failed to send invite", type: "error" }, { timeout: 5000 }); + }, + }); +} + +export function useCancelClientInvite() { + const { session } = useSession(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => { + const res = await api.delete(`/api/v1/client-invites/${tabloId}/${inviteId}`, { + headers: { Authorization: `Bearer ${session?.access_token}` }, + }); + return res.data; + }, + onSuccess: (_, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] }); + }, + }); +} +``` + +- [ ] **Step 2: Add client invite section to the share dialog** + +In the share dialog component (either in `TabloHeaderActions.tsx` or in the share dialog in `tablo-details.tsx`), add a section below the existing invite section for client invites. This should include: + +1. A "Client Access" heading with a description +2. An email input + "Send Magic Link" button +3. A list of pending client invites with expiration dates and cancel buttons +4. An expiration warning badge when `expires_at` is less than 5 days away + +Use the hooks from step 1 (`usePendingClientInvites`, `useCreateClientInvite`, `useCancelClientInvite`). + +The exact JSX depends on the existing share dialog structure. Follow the same patterns used for the existing `pendingInvites` list. + +- [ ] **Step 3: Run typecheck and verify** + +Run: `pnpm typecheck` +Run: `pnpm dev:main` +Navigate to a tablo, open the share dialog. Verify the client invite section appears. + +- [ ] **Step 4: Commit** + +```bash +git add apps/main/src/hooks/client_invites.ts apps/main/src/components/ apps/main/src/pages/ +git commit -m "feat(main): add client invite UI to share dialog" +``` + +--- + +## Task 10: End-to-End Verification + +- [ ] **Step 1: Run full typecheck** + +Run: `pnpm typecheck` +Expected: No errors across all packages + +- [ ] **Step 2: Run all tests** + +Run: `pnpm test` +Expected: All tests pass + +- [ ] **Step 3: Run linter** + +Run: `pnpm lint` +Expected: No errors (run `pnpm lint:fix` if needed) + +- [ ] **Step 4: Verify `apps/main` dev server** + +Run: `pnpm dev:main` +- Navigate to a tablo details page +- Verify all tabs work (overview, etapes, tasks, files, discussion, events, roadmap) +- Open share dialog, verify client invite section + +- [ ] **Step 5: Verify `apps/clients` dev server** + +Run: `pnpm dev:clients` +- Verify app loads on port 5175 +- Verify auth callback page renders +- Verify tablo list page renders + +- [ ] **Step 6: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix: resolve typecheck and lint issues from client magic links implementation" +``` diff --git a/docs/superpowers/specs/2026-04-15-client-magic-links-design.md b/docs/superpowers/specs/2026-04-15-client-magic-links-design.md new file mode 100644 index 0000000..ffabd8c --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-client-magic-links-design.md @@ -0,0 +1,223 @@ +# Client Magic Links — Design Spec + +## Overview + +Replace the temporary user invitation model with a magic link system for external client access. Clients access tablos via a dedicated portal at `clients.xtablo.com` (`apps/clients`), authenticated through Supabase passwordless magic links. Tablo view components are extracted into a shared `packages/tablo-views` package consumed by both `apps/main` and `apps/clients`. + +Temporary users remain untouched during the transition period. + +## Data Model + +### New column: `profiles.is_client` + +- `is_client: boolean NOT NULL DEFAULT false` +- Marks users created via client magic link invites +- Distinct from `is_temporary` — clean separation for the transition +- Excluded from billing (`getBillableMemberCount` filters out `is_client` users) + +### New table: `client_invites` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | serial PK | | +| `tablo_id` | text FK -> tablos | | +| `invited_email` | varchar(255) | | +| `invited_by` | uuid FK -> profiles | | +| `invite_token` | text | URL-safe token for the magic link | +| `expires_at` | timestamptz | Default: `now() + interval '30 days'` | +| `is_pending` | boolean DEFAULT true | Flipped to false on acceptance | +| `created_at` | timestamptz DEFAULT now() | | + +RLS policies: +- Admins (invite senders) can read/manage their invites +- Client users can read their own invites by email match + +### Existing table: `tablo_access` + +No schema changes. Client users get a standard row with `is_admin: false`, `is_active: true`. Access revocation uses the existing `is_active = false` pattern. + +## Magic Link Invitation Flow + +### Sending an invite (admin in `apps/main`) + +1. Admin opens tablo share dialog, enters client email +2. `POST /api/v1/tablos/:tabloId/client-invites` — validates admin access, creates `client_invites` row with generated token and `expires_at = now() + 30 days` +3. If no Supabase account exists for that email, the API creates one via `supabase.auth.admin.createUser({ email })` and sets `is_client: true` on the resulting profile row. A `tablo_access` row is pre-granted (`is_admin: false`, `is_active: true`). +4. API calls `supabase.auth.admin.generateLink({ type: 'magiclink', email, options: { redirectTo: 'https://clients.xtablo.com/auth/callback?token=' } })` to generate the magic link +5. Supabase sends the magic link email to the client + +### Client clicks the link + +1. Supabase verifies the auth token, redirects to `clients.xtablo.com/auth/callback?token=` +2. Callback page exchanges the Supabase auth token for a session +3. The `invite_token` is used to call `POST /api/v1/client-invites/:token/accept` — marks invite as accepted (`is_pending: false`), confirms `tablo_access` is active +4. Client is redirected to `clients.xtablo.com/tablo/:tabloId` + +### Expiration and renewal + +- Expired invites (past `expires_at`) are rejected at acceptance time with a clear error message +- Admins can re-invite the same email, creating a new `client_invites` row with a fresh 30-day window +- Admins are warned in the UI when the expiration is soon (less than 5 days) +- Admin can revoke access by setting `tablo_access.is_active = false` + +### Returning clients + +- Active session + valid `tablo_access` = direct access, no re-invitation needed +- Expired session requires a new magic link from the admin + +## API Permission Scoping + +### Middleware + +New middleware variant: `clientUserCheckMiddleware` — returns `403` for `is_client` users on non-client-accessible routes. + +### Client-accessible endpoints + +- `GET /api/v1/tablos/:tabloId` — view tablo details +- `GET /api/v1/tablo-data/:tabloId/*` — tasks, etapes, events, files metadata +- `GET /api/v1/tablo-files/:tabloId/*` — file downloads +- `POST /api/v1/tablo-files/:tabloId/upload` — file uploads +- Chat endpoints (messages, typing, presence via WebSocket) +- `GET /api/v1/user/me` — own profile + +### Blocked for client users + +- Tablo CRUD (create, update, delete) +- Invite management (sending/cancelling invites) +- Organization endpoints +- Billing/Stripe endpoints +- Settings, user management + +### Billing + +`getBillableMemberCount` updated to exclude `is_client` users (same pattern as `is_temporary`). + +### RLS policies + +New row-level policies on `client_invites`: +- Admins can manage invites they created +- Clients can read their own invites (by email match) + +## `packages/tablo-views` — Shared Package + +Source-only package (TypeScript directly, no build step). Same pattern as `@xtablo/shared` and `@xtablo/ui`. + +### Structure + +``` +packages/tablo-views/ +├── package.json (@xtablo/tablo-views) +├── tsconfig.json +└── src/ + ├── TabloOverviewSection.tsx + ├── TabloEtapesSection.tsx + ├── TabloTasksSection.tsx + ├── TabloFilesSection.tsx + ├── TabloDiscussionSection.tsx + ├── TabloEventsSection.tsx + ├── TabloRoadmapSection.tsx + ├── components/ (shared sub-components these sections depend on) + └── hooks/ (data-fetching hooks for tablo views, including useChat) +``` + +### What moves from `apps/main` + +- The 7 tab section components +- Sub-components they directly depend on (task cards, file list items, gantt chart, etc.) +- Data-fetching hooks used exclusively by these views (including `useChat` from `apps/main/src/hooks/useChat.ts`) + +### What stays in `apps/main` + +- `TabloDetailsPage` (page shell with tab navigation, share dialog, invite management) +- Layout, navigation, routing +- App-level providers + +### Dependencies + +`@xtablo/tablo-views` depends on: +- `@xtablo/ui` +- `@xtablo/shared` +- `@xtablo/shared-types` +- `@xtablo/chat-ui` + +Consumed by both `apps/main` and `apps/clients`. + +### Refactor in `apps/main` + +`TabloDetailsPage` imports sections from `@xtablo/tablo-views` instead of local files. Behavior stays identical — this is a move, not a rewrite. + +## `apps/clients` — Client Portal App + +### Structure + +``` +apps/clients/ +├── package.json (@xtablo/clients) +├── vite.config.ts +├── wrangler.toml (clients.xtablo.com) +├── worker/index.ts +├── index.html +├── tsconfig.json +├── tsconfig.app.json +└── src/ + ├── main.tsx + ├── App.tsx + ├── routes.tsx + ├── pages/ + │ ├── AuthCallback.tsx + │ └── ClientTabloPage.tsx + └── components/ + └── ClientLayout.tsx +``` + +### Cloudflare Worker + +`wrangler.toml` routes `clients.xtablo.com` with SPA not-found handling. Same asset-serving pattern as `apps/main` and `apps/external`. + +### Layout + +`ClientLayout.tsx` — no sidebar. Minimal top bar with: +- Tablo name and color +- Client user avatar and name +- Logout action + +### Routes + +| Path | Component | Purpose | +|------|-----------|---------| +| `/auth/callback` | `AuthCallback` | Supabase magic link redirect + invite token acceptance | +| `/tablo/:tabloId` | `ClientTabloPage` | Scoped tablo view with all tabs | +| `/` | Redirect | To `/tablo/:tabloId` if one tablo, or simple list if multiple | + +### `ClientTabloPage` + +Renders the same tab system as `TabloDetailsPage` using components from `@xtablo/tablo-views`. Differences from `apps/main`: +- No share/invite dialog +- No tablo settings or delete actions +- No admin-only actions in the UI +- File section: download and upload enabled, no delete + +### Providers + +`QueryClientProvider`, `SessionProvider`, `ThemeProvider`, i18n — same setup as other apps. No `UserStoreProvider` or organization context (clients don't belong to orgs). + +### Dev server + +Port 5175 via `pnpm dev:clients`. + +## Chat Integration + +Client users get real Supabase accounts, so chat works with minimal changes: + +- **Authentication:** Same JWT-based auth for WebSocket connections +- **Identity:** Profile row (name, optional avatar) used for chat display. Profile seeded with invited email on creation. Client can update display name on first access. +- **Permissions:** Client users can send messages and see typing indicators in tablo discussions they have access to. Tablo ID maps to channel ID. +- **`@xtablo/chat-ui`:** No changes needed. Components are already app-agnostic. +- **`useChat` hook:** Moves to `packages/tablo-views/src/hooks/` so both apps can use it. + +## Migration Strategy + +- Temporary users (`is_temporary`) remain untouched +- Existing tablo invitations continue to work via `apps/main` +- New client invites use the magic link flow via `apps/clients` +- Once all clients have migrated to magic links, a future phase removes `is_temporary` and related code diff --git a/package.json b/package.json index 50b96ef..bf99826 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dev": "turbo dev", "dev:main": "turbo dev --filter=@xtablo/main", "dev:external": "turbo dev --filter=@xtablo/external", + "dev:clients": "turbo dev --filter=@xtablo/clients", "dev:api": "turbo dev --filter=@xtablo/api", "deploy:main:staging": "turbo deploy:staging --filter=@xtablo/main", "deploy:main:prod": "turbo deploy:prod --filter=@xtablo/main", diff --git a/packages/shared-types/src/database.types.ts b/packages/shared-types/src/database.types.ts index c48c314..3ab03a5 100644 --- a/packages/shared-types/src/database.types.ts +++ b/packages/shared-types/src/database.types.ts @@ -78,6 +78,54 @@ export type Database = { }, ]; }; + client_invites: { + Row: { + created_at: string; + expires_at: string; + id: number; + invited_by: string; + invited_email: string; + invite_token: string; + is_pending: boolean; + tablo_id: string; + }; + Insert: { + created_at?: string; + expires_at?: string; + id?: number; + invited_by: string; + invited_email: string; + invite_token: string; + is_pending?: boolean; + tablo_id: string; + }; + Update: { + created_at?: string; + expires_at?: string; + id?: number; + invited_by?: string; + invited_email?: string; + invite_token?: string; + is_pending?: boolean; + tablo_id?: string; + }; + Relationships: [ + { + foreignKeyName: "client_invites_tablo_id_fkey"; + columns: ["tablo_id"]; + isOneToOne: false; + referencedRelation: "tablos"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "client_invites_invited_by_fkey"; + columns: ["invited_by"]; + isOneToOne: false; + referencedRelation: "profiles"; + referencedColumns: ["id"]; + }, + ]; + }; devis: { Row: { client_email: string; @@ -385,6 +433,7 @@ export type Database = { email: string | null; first_name: string | null; id: string; + is_client: boolean; is_temporary: boolean; last_name: string | null; last_signed_in: string | null; @@ -398,6 +447,7 @@ export type Database = { email?: string | null; first_name?: string | null; id: string; + is_client?: boolean; is_temporary?: boolean; last_name?: string | null; last_signed_in?: string | null; @@ -411,6 +461,7 @@ export type Database = { email?: string | null; first_name?: string | null; id?: string; + is_client?: boolean; is_temporary?: boolean; last_name?: string | null; last_signed_in?: string | null; diff --git a/packages/tablo-views/package.json b/packages/tablo-views/package.json new file mode 100644 index 0000000..5de94b5 --- /dev/null +++ b/packages/tablo-views/package.json @@ -0,0 +1,41 @@ +{ + "name": "@xtablo/tablo-views", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks/*": "./src/hooks/*.ts", + "./*": "./src/*.tsx" + }, + "scripts": { + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.69.0", + "@xtablo/chat-ui": "workspace:*", + "@xtablo/shared": "workspace:*", + "@xtablo/shared-types": "workspace:*", + "@xtablo/ui": "workspace:*", + "date-fns": "^4.1.0", + "lucide-react": "^0.460.0", + "react": "19.0.0", + "react-dom": "19.0.0", + "react-i18next": "^16.2.0", + "react-router-dom": "^7.9.4", + "tailwind-merge": "^3.0.2" + }, + "devDependencies": { + "@biomejs/biome": "2.2.5", + "@types/react": "19.0.10", + "@types/react-dom": "19.0.4", + "typescript": "^5.7.0", + "vite": "^6.2.2" + } +} diff --git a/apps/main/src/components/ChatMessages.tsx b/packages/tablo-views/src/ChatMessages.tsx similarity index 100% rename from apps/main/src/components/ChatMessages.tsx rename to packages/tablo-views/src/ChatMessages.tsx diff --git a/packages/tablo-views/src/ClickOutside.tsx b/packages/tablo-views/src/ClickOutside.tsx new file mode 100644 index 0000000..f39778d --- /dev/null +++ b/packages/tablo-views/src/ClickOutside.tsx @@ -0,0 +1,37 @@ +import { useClickOutside } from "@xtablo/shared/hooks/useClickOutside"; +import React from "react"; + +interface ClickOutsideProps { + children: React.ReactNode; + onClickOutside: () => void; + className?: string; + disabled?: boolean; +} + +/** + * Component that wraps children and detects clicks outside + * @param children - The content to wrap + * @param onClickOutside - Function to call when clicking outside + * @param className - Optional className for the wrapper + * @param disabled - Disable click outside detection + */ +export const ClickOutside: React.FC = ({ + children, + onClickOutside, + className, + disabled = false, +}) => { + const ref = useClickOutside( + disabled + ? () => { + // Do nothing + } + : onClickOutside + ); + + return ( +
+ {children} +
+ ); +}; diff --git a/packages/tablo-views/src/EtapesSection.tsx b/packages/tablo-views/src/EtapesSection.tsx new file mode 100644 index 0000000..c671847 --- /dev/null +++ b/packages/tablo-views/src/EtapesSection.tsx @@ -0,0 +1,366 @@ +import { cn } from "@xtablo/shared"; +import type { Etape, KanbanTask } from "@xtablo/shared-types"; +import { Button } from "@xtablo/ui/components/button"; +import { Input } from "@xtablo/ui/components/input"; +import { + CalendarIcon, + ChevronDownIcon, + ChevronRightIcon, + CircleCheckIcon, + ListChecksIcon, + PlusIcon, +} from "lucide-react"; +import { useState } from "react"; + +interface EtapesSectionProps { + etapes: Etape[]; + tabloTasks: KanbanTask[]; + tabloId: string; + isAdmin: boolean; + onCreateTask: (task: { + tablo_id: string; + title: string; + status: string; + parent_task_id: string; + is_parent: boolean; + position: number; + }) => void; + onCreateEtape: (params: { tabloId: string; title: string; position: number }) => Promise; + isCreatingEtape?: boolean; +} + +export function EtapesSection({ + etapes, + tabloTasks, + tabloId, + isAdmin, + onCreateTask, + onCreateEtape, + isCreatingEtape = false, +}: EtapesSectionProps) { + const [expandedEtapes, setExpandedEtapes] = useState>( + new Set(etapes.map((e) => e.id)) + ); + const [addingTaskToEtape, setAddingTaskToEtape] = useState(null); + const [newEtapeTitle, setNewEtapeTitle] = useState(""); + const [newTaskTitle, setNewTaskTitle] = useState(""); + + const toggleEtape = (id: string) => { + setExpandedEtapes((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleAddTask = (etapeId: string) => { + const title = newTaskTitle.trim(); + if (!title || !tabloId) return; + onCreateTask({ + tablo_id: tabloId, + title, + status: "todo", + parent_task_id: etapeId, + is_parent: false, + position: tabloTasks.filter((t) => t.parent_task_id === etapeId).length, + }); + setNewTaskTitle(""); + setAddingTaskToEtape(null); + }; + + const handleAddEtape = async () => { + const title = newEtapeTitle.trim(); + if (!title || !tabloId) { + return; + } + + const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1; + + await onCreateEtape({ + tabloId, + title, + position: nextPosition, + }); + + setNewEtapeTitle(""); + }; + + const statusConfig: Record = { + todo: { + label: "À faire", + color: "bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400", + }, + in_progress: { + label: "En cours", + color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400", + }, + in_review: { + label: "Vérification", + color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400", + }, + done: { + label: "Terminé", + color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400", + }, + }; + + return ( +
+ {isAdmin && ( +
+ setNewEtapeTitle(event.target.value)} + placeholder="Nom de la nouvelle étape..." + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleAddEtape(); + } + }} + className="h-11 sm:h-9 sm:w-80" + /> + +
+ )} + + {etapes.length === 0 ? ( +
+ +

Aucune étape

+

+ Les étapes permettent de structurer votre projet en grandes phases +

+
+ ) : ( + etapes.map((etape, index) => { + const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id); + const doneCount = childTasks.filter((t) => t.status === "done").length; + const totalCount = childTasks.length; + const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0; + const isExpanded = expandedEtapes.has(etape.id); + + // Derive status from child tasks instead of etape.status + const derivedStatus = + totalCount === 0 + ? "todo" + : doneCount === totalCount + ? "done" + : doneCount > 0 + ? "in_progress" + : "todo"; + const status = statusConfig[derivedStatus] ?? statusConfig.todo; + + return ( +
+ {/* Etape header */} + + + {/* Child tasks + add task */} + {isExpanded && ( +
+ {childTasks.length > 0 && ( +
+ {childTasks.map((task) => ( +
+ {task.status === "done" ? ( + + ) : ( +
+ )} + + {task.title} + + {task.due_date && ( +
+ + + {new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "short", + }).format(new Date(task.due_date))} + +
+ )} + {task.status && ( + + {(statusConfig[task.status] ?? statusConfig.todo).label} + + )} +
+ ))} +
+ )} + + {childTasks.length === 0 && addingTaskToEtape !== etape.id && ( +
+ Aucune tâche dans cette étape +
+ )} + + {/* Inline add task */} + {addingTaskToEtape === etape.id ? ( +
+
+ setNewTaskTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleAddTask(etape.id); + if (e.key === "Escape") { + setAddingTaskToEtape(null); + setNewTaskTitle(""); + } + }} + placeholder="Nom de la tâche..." + className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 min-w-0" + /> + + +
+ ) : ( + + )} +
+ )} +
+ ); + }) + )} +
+ ); +} diff --git a/packages/tablo-views/src/ImageColorPicker.tsx b/packages/tablo-views/src/ImageColorPicker.tsx new file mode 100644 index 0000000..27d8a6a --- /dev/null +++ b/packages/tablo-views/src/ImageColorPicker.tsx @@ -0,0 +1,114 @@ +interface ImageColorPickerProps { + creationMode: "image" | "color"; + setCreationMode: (mode: "image" | "color") => void; + selectedColor: string; + setSelectedColor: (color: string) => void; +} + +const AVAILABLE_COLORS = [ + "bg-blue-500", + "bg-green-500", + "bg-purple-500", + "bg-red-500", + "bg-yellow-500", + "bg-indigo-500", + "bg-pink-500", + "bg-teal-500", + "bg-orange-500", + "bg-cyan-500", +]; + +export const ImageColorPicker = ({ + creationMode, + setCreationMode, + selectedColor, + setSelectedColor, +}: ImageColorPickerProps) => { + return ( +
+ {/* Mode Toggle */} +
+ +
+ + +
+
+ + {/* Image Mode */} + {creationMode === "image" && ( +
+ {/* File Upload - Coming Soon */} +
+
+ + + +

+ Import d'images +

+

Bientôt disponible

+
+
+
+ )} + + {/* Color Mode */} + {creationMode === "color" && ( +
+ +
+ {AVAILABLE_COLORS.map((color) => ( + + ))} +
+
+ )} +
+ ); +}; diff --git a/packages/tablo-views/src/RoadmapSection.tsx b/packages/tablo-views/src/RoadmapSection.tsx new file mode 100644 index 0000000..6d6bcb9 --- /dev/null +++ b/packages/tablo-views/src/RoadmapSection.tsx @@ -0,0 +1,23 @@ +import type { KanbanTask, TaskStatus } from "@xtablo/shared-types"; +import { GanttChart } from "./components/gantt/GanttChart"; + +interface RoadmapSectionProps { + tabloTasks: KanbanTask[]; + onDateClick: (date: Date) => void; + onTaskStatusChange: (taskId: string, status: TaskStatus) => void; +} + +export function RoadmapSection({ + tabloTasks, + onDateClick, + onTaskStatusChange, +}: RoadmapSectionProps) { + return ( + + ); +} diff --git a/apps/main/src/components/TabloDiscussionSection.tsx b/packages/tablo-views/src/TabloDiscussionSection.tsx similarity index 68% rename from apps/main/src/components/TabloDiscussionSection.tsx rename to packages/tablo-views/src/TabloDiscussionSection.tsx index 4f9256b..8973a7f 100644 --- a/apps/main/src/components/TabloDiscussionSection.tsx +++ b/packages/tablo-views/src/TabloDiscussionSection.tsx @@ -1,17 +1,26 @@ -import { UserTablo } from "@xtablo/shared/types/tablos.types"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; import { useEffect } from "react"; -import { useChat } from "../hooks/useChat"; -import { useTabloMembers } from "../hooks/tablos"; -import { useUser } from "../providers/UserStoreProvider"; +import { useChat } from "./hooks/useChat"; import { ChatMessages } from "./ChatMessages"; +interface Member { + id: string; + name: string; + avatar_url: string | null; +} + interface TabloDiscussionSectionProps { tablo: UserTablo; isAdmin: boolean; + currentUserId: string; + members?: Member[]; } -export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) => { - const user = useUser(); +export const TabloDiscussionSection = ({ + tablo, + currentUserId, + members = [], +}: TabloDiscussionSectionProps) => { const { messages, sendMessage, @@ -22,8 +31,6 @@ export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) = markAsRead, } = useChat(tablo.id); - const { data: members = [] } = useTabloMembers(tablo.id); - // Mark as read when opening the discussion useEffect(() => { if (messages.length > 0) { @@ -36,7 +43,7 @@ export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) =
void; + onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise; + onInviteUser?: (params: { email: string; tablo_id: string }) => void; + onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void; } -export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) => { - const navigate = useNavigate(); +export const TabloEventsSection = ({ + tablo, + isAdmin, + isReadOnly = false, + events, + isLoading, + error, + currentUser, + members, + pendingInvites, + isInvitingUser, + isCancellingInvite, + onCreateEvent, + onUpdateTablo, + onInviteUser, + onCancelInvite, +}: TabloEventsSectionProps) => { const { t } = useTranslation(); - const { data: events, isLoading, error } = useEventsByTablo(tablo.id); - const isReadOnly = useIsReadOnlyUser(); // Filter upcoming events (events in the future or today) const today = new Date(); @@ -34,10 +85,6 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) return (a.start_time || "").localeCompare(b.start_time || ""); }); - const handleCreateEvent = () => { - navigate(`/planning/create?tablo_id=${tablo.id}`); - }; - const formatDate = (dateStr: string) => { const date = new Date(dateStr); return new Intl.DateTimeFormat("fr-FR", { @@ -50,7 +97,6 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) const formatTime = (timeStr: string) => { if (!timeStr) return ""; - return timeStr.slice(0, 5); // HH:MM }; @@ -66,7 +112,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) {!isReadOnly && ( )}
- +
{/* Events List */}
@@ -176,7 +233,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)

{!isReadOnly && (
- +
{/* Error Banner */} @@ -987,7 +1045,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => }} onSave={handleSaveFolder} folder={editingFolder} - isLoading={createFolder.isPending || updateFolder.isPending} + isLoading={isCreatingFolder || isUpdatingFolder} />
); diff --git a/apps/main/src/components/TabloHeaderActions.tsx b/packages/tablo-views/src/TabloHeaderActions.tsx similarity index 90% rename from apps/main/src/components/TabloHeaderActions.tsx rename to packages/tablo-views/src/TabloHeaderActions.tsx index e4f1757..1b3e54c 100644 --- a/apps/main/src/components/TabloHeaderActions.tsx +++ b/packages/tablo-views/src/TabloHeaderActions.tsx @@ -1,5 +1,5 @@ import { toast } from "@xtablo/shared"; -import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types"; +import type { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types"; import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar"; import { Button } from "@xtablo/ui/components/button"; import { @@ -13,21 +13,52 @@ import { Input } from "@xtablo/ui/components/input"; import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover"; import { Loader2, Settings, Share2, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { useInviteUser } from "../hooks/invite"; -import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; -import { useTabloMembers, useUpdateTablo } from "../hooks/tablos"; -import { useUser } from "../providers/UserStoreProvider"; import { ClickOutside } from "./ClickOutside"; import { ImageColorPicker } from "./ImageColorPicker"; +interface PendingInvite { + id: string; + invited_email: string; +} + +interface Member { + id: string; + name: string; + email?: string; + avatar_url?: string | null; + is_admin?: boolean; +} + +interface CurrentUser { + id: string; + avatar_url?: string | null; +} + interface TabloHeaderActionsProps { tablo: UserTablo; isAdmin: boolean; + currentUser: CurrentUser; + members?: Member[]; + pendingInvites?: PendingInvite[]; + isInvitingUser?: boolean; + isCancellingInvite?: boolean; + onUpdateTablo?: (data: TabloUpdate & { id: string }) => Promise; + onInviteUser?: (params: { email: string; tablo_id: string }) => void; + onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void; } -export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) => { - const { mutateAsync: updateTablo } = useUpdateTablo(); - const currentUser = useUser(); +export const TabloHeaderActions = ({ + tablo, + isAdmin, + currentUser, + members = [], + pendingInvites = [], + isInvitingUser = false, + isCancellingInvite = false, + onUpdateTablo, + onInviteUser, + onCancelInvite, +}: TabloHeaderActionsProps) => { const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); @@ -39,12 +70,6 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) const nameInputRef = useRef(null); - // Fetch members and invites for share dialog - const { data: members } = useTabloMembers(tablo?.id || ""); - const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo?.id || ""); - const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite(); - const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser(); - useEffect(() => { setEditData(tablo); setSelectedColor(tablo.color || "bg-blue-500"); @@ -59,14 +84,14 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) }, [isEditingName]); const handleSaveSettings = async () => { - if (editData && tablo) { + if (editData && tablo && onUpdateTablo) { const updatedTablo: TabloUpdate & { id: string } = { id: editData.id, name: editData.name, color: creationMode === "color" ? selectedColor : null, }; try { - await updateTablo(updatedTablo); + await onUpdateTablo(updatedTablo); toast.add( { title: "Tablo mis à jour", @@ -89,8 +114,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) }; const handleSendInvite = () => { - if (inviteEmail.trim() && tablo) { - inviteUser({ email: inviteEmail, tablo_id: tablo.id }); + if (inviteEmail.trim() && tablo && onInviteUser) { + onInviteUser({ email: inviteEmail, tablo_id: tablo.id }); setInviteEmail(""); } }; @@ -278,7 +303,7 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) size="icon" variant="ghost" className="h-8 w-8" - onClick={() => cancelInvite({ tabloId: tablo.id, inviteId: invite.id })} + onClick={() => onCancelInvite?.({ tabloId: tablo.id, inviteId: invite.id })} disabled={isCancellingInvite} title="Retirer l'invitation" > diff --git a/apps/main/src/components/TabloTasksSection.tsx b/packages/tablo-views/src/TabloTasksSection.tsx similarity index 75% rename from apps/main/src/components/TabloTasksSection.tsx rename to packages/tablo-views/src/TabloTasksSection.tsx index eccf7b5..0743baa 100644 --- a/apps/main/src/components/TabloTasksSection.tsx +++ b/packages/tablo-views/src/TabloTasksSection.tsx @@ -1,37 +1,71 @@ import { pluralize, toast } from "@xtablo/shared"; -import { UserTablo } from "@xtablo/shared/types/tablos.types"; -import type { KanbanColumn, KanbanTask, KanbanTaskInsert, TaskStatus } from "@xtablo/shared-types"; +import type { UserTablo } from "@xtablo/shared/types/tablos.types"; +import type { + Etape, + KanbanColumn, + KanbanTask, + KanbanTaskInsert, + KanbanTaskUpdate, + TaskStatus, +} from "@xtablo/shared-types"; import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; import { AlertTriangle, ListChecks } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTabloMembers } from "../hooks/tablos"; -import { - useCreateTask, - useTabloEtapes, - useTasksByTablo, - useUpdateTaskPositions, -} from "../hooks/tasks"; -import { KanbanBoard } from "./kanban/KanbanBoard"; -import { TaskModal } from "./kanban/TaskModal"; +import { KanbanBoard } from "./components/kanban/KanbanBoard"; +import type { TabloMember } from "./components/kanban/types"; +import { TaskModal } from "./components/kanban/TaskModal"; import { TabloHeaderActions } from "./TabloHeaderActions"; +interface CurrentUser { + id: string; + avatar_url?: string | null; +} + +interface PendingInvite { + id: string; + invited_email: string; +} + interface TabloTasksSectionProps { tablo: UserTablo; isAdmin: boolean; + tasks?: KanbanTask[]; + members?: TabloMember[]; + etapes?: Etape[]; + currentUser: CurrentUser; + pendingInvites?: PendingInvite[]; + isInvitingUser?: boolean; + isCancellingInvite?: boolean; + onCreateTask?: (task: KanbanTaskInsert) => void; + onUpdateTask?: (task: KanbanTaskUpdate & { id: string; tablo_id: string }) => void; + onUpdateTaskPositions?: (updates: Array<{ id: string; position: number; status: TaskStatus }>) => void; + onUpdateTablo?: (data: { id: string; name?: string | null; color?: string | null }) => Promise; + onInviteUser?: (params: { email: string; tablo_id: string }) => void; + onCancelInvite?: (params: { tabloId: string; inviteId: string }) => void; } -export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => { - const { data: members = [] } = useTabloMembers(tablo.id); +export const TabloTasksSection = ({ + tablo, + isAdmin, + tasks, + members = [], + etapes = [], + currentUser, + pendingInvites, + isInvitingUser, + isCancellingInvite, + onCreateTask, + onUpdateTask, + onUpdateTaskPositions, + onUpdateTablo, + onInviteUser, + onCancelInvite, +}: TabloTasksSectionProps) => { const [columns, setColumns] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedTask, setSelectedTask] = useState(null); const [modalStatus, setModalStatus] = useState("todo"); - const { data: tasks } = useTasksByTablo(tablo.id); - const { data: etapes = [] } = useTabloEtapes(tablo.id); - const { mutate: updateTaskPositions } = useUpdateTaskPositions(); - const { mutate: createTask } = useCreateTask(); - const memberById = useMemo( () => new Map(members.map((member) => [member.id, member])), [members] @@ -72,7 +106,6 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => return tasksWithAssigneeFallback.filter((task) => !task.parent_task_id); }, [tasksWithAssigneeFallback]); - // Helper functions defined before use const initializeColumns = useCallback((tasks: KanbanTask[]): KanbanColumn[] => { const defaultColumns: KanbanColumn[] = [ { @@ -137,19 +170,7 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => parent_task_id: taskData.parent_task_id ?? null, }; - createTask(newTask); - - // setColumns((prevColumns) => - // prevColumns.map((column: KanbanColumn) => { - // if (column.status === (taskData.status as TaskStatus)) { - // return { - // ...column, - // tasks: [newTask, ...column.tasks], - // }; - // } - // return column; - // }) - // ); + onCreateTask?.(newTask); toast.add( { @@ -162,7 +183,7 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => }; const handleTaskMove = (taskId: string, newStatus: TaskStatus) => { - updateTaskPositions([ + onUpdateTaskPositions?.([ { id: taskId, position: columns.find((column) => column.status === newStatus)?.position ?? 0, @@ -198,7 +219,18 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => Gérez vos tâches avec un tableau Kanban
- +
{/* Warning for orphaned tasks */} @@ -238,11 +270,14 @@ export const TabloTasksSection = ({ tablo, isAdmin }: TabloTasksSectionProps) => setIsModalOpen(false)} members={members} initialStatus={modalStatus} etapes={etapes} + onCreateTask={onCreateTask} + onUpdateTask={onUpdateTask} /> ); diff --git a/apps/main/src/components/gantt/GanttChart.tsx b/packages/tablo-views/src/components/gantt/GanttChart.tsx similarity index 99% rename from apps/main/src/components/gantt/GanttChart.tsx rename to packages/tablo-views/src/components/gantt/GanttChart.tsx index 49c90f8..9f24854 100644 --- a/apps/main/src/components/gantt/GanttChart.tsx +++ b/packages/tablo-views/src/components/gantt/GanttChart.tsx @@ -1,4 +1,3 @@ -import { LoadingSpinner } from "@ui/components/LoadingSpinner"; import type { KanbanTask, TaskStatus } from "@xtablo/shared-types"; import { Button } from "@xtablo/ui/components/button"; import { @@ -253,7 +252,7 @@ export function GanttChart({ tasks, isLoading, onDateClick, onTaskStatusChange } if (isLoading) { return (
- +
); } diff --git a/apps/main/src/components/kanban/InlineTaskCreate.tsx b/packages/tablo-views/src/components/kanban/InlineTaskCreate.tsx similarity index 87% rename from apps/main/src/components/kanban/InlineTaskCreate.tsx rename to packages/tablo-views/src/components/kanban/InlineTaskCreate.tsx index 0d96492..9d03af2 100644 --- a/apps/main/src/components/kanban/InlineTaskCreate.tsx +++ b/packages/tablo-views/src/components/kanban/InlineTaskCreate.tsx @@ -130,24 +130,6 @@ export const InlineTaskCreate = ({ status, members, etapes, onSubmit }: InlineTa {/* Type and Assignee */}
- {/*
- - -
*/} -
{/* Étape */} - {etapes.length > 0 && ( + {providedEtapes.length > 0 && (