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" +```