From bc00eaf53ee998abaf516865db7d6031fa727c60 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 08:26:52 +0200 Subject: [PATCH 01/15] docs: add client magic links design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-15-client-magic-links-design.md | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-15-client-magic-links-design.md 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..690d46c --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-client-magic-links-design.md @@ -0,0 +1,222 @@ +# 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 +- 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 From 3bed5e20be44ac261a734200b349db7fcec84922 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 08:32:06 +0200 Subject: [PATCH 02/15] docs: add expiration warning for admins to client magic links spec Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/superpowers/specs/2026-04-15-client-magic-links-design.md | 1 + 1 file changed, 1 insertion(+) 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 index 690d46c..ffabd8c 100644 --- a/docs/superpowers/specs/2026-04-15-client-magic-links-design.md +++ b/docs/superpowers/specs/2026-04-15-client-magic-links-design.md @@ -57,6 +57,7 @@ No schema changes. Client users get a standard row with `is_admin: false`, `is_a - 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 From 05c552ce730b6cc2659f3e84da7e4353eed6a5b2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 08:53:22 +0200 Subject: [PATCH 03/15] docs: add client magic links implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-15-client-magic-links.md | 1822 +++++++++++++++++ 1 file changed, 1822 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-client-magic-links.md 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" +``` From ec9c9622aa5e29bea4157219ac8d6b2dfd06b6e3 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 08:58:10 +0200 Subject: [PATCH 04/15] feat(db): add is_client column and client_invites table --- packages/shared-types/src/database.types.ts | 51 +++++++++++++++++++ .../20260415120000_add_client_invites.sql | 40 +++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 supabase/migrations/20260415120000_add_client_invites.sql 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/supabase/migrations/20260415120000_add_client_invites.sql b/supabase/migrations/20260415120000_add_client_invites.sql new file mode 100644 index 0000000..7ee07fd --- /dev/null +++ b/supabase/migrations/20260415120000_add_client_invites.sql @@ -0,0 +1,40 @@ +-- 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() + ) + ); From 9e75f9b78d251bc463f0021a1aec9389dcfa11e5 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 09:00:33 +0200 Subject: [PATCH 05/15] feat: scaffold packages/tablo-views package --- packages/tablo-views/package.json | 40 ++++++++++++++++++++++++++++++ packages/tablo-views/src/index.ts | 21 ++++++++++++++++ packages/tablo-views/tsconfig.json | 27 ++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 packages/tablo-views/package.json create mode 100644 packages/tablo-views/src/index.ts create mode 100644 packages/tablo-views/tsconfig.json diff --git a/packages/tablo-views/package.json b/packages/tablo-views/package.json new file mode 100644 index 0000000..464a36f --- /dev/null +++ b/packages/tablo-views/package.json @@ -0,0 +1,40 @@ +{ + "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" + } +} diff --git a/packages/tablo-views/src/index.ts b/packages/tablo-views/src/index.ts new file mode 100644 index 0000000..c60efd7 --- /dev/null +++ b/packages/tablo-views/src/index.ts @@ -0,0 +1,21 @@ +// Section components — will be populated as components are moved from apps/main +// 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"; diff --git a/packages/tablo-views/tsconfig.json b/packages/tablo-views/tsconfig.json new file mode 100644 index 0000000..8fc29d8 --- /dev/null +++ b/packages/tablo-views/tsconfig.json @@ -0,0 +1,27 @@ +{ + "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"] +} From ccb66f99d8adb11d8385b97096ed784c2356c1a6 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 09:03:00 +0200 Subject: [PATCH 06/15] feat(api): add is_client check to middleware and billing --- .../api/src/__tests__/helpers/billing.test.ts | 5 ++++ .../__tests__/middlewares/middlewares.test.ts | 29 ++++++++++++++++++- apps/api/src/helpers/billing.ts | 5 ++-- apps/api/src/middlewares/middleware.ts | 4 +-- 4 files changed, 38 insertions(+), 5 deletions(-) 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/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/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); } From e10145d991231454c8b95e38aea146e3be7f2903 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 09:22:11 +0200 Subject: [PATCH 07/15] feat(api): add client invite endpoints with magic link flow Adds createClientUser helper, POST/GET/DELETE /client-invites routes, and mounts the router at /client-invites in authRouter. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/routes/clientInvites.test.ts | 386 ++++++++++++++++++ apps/api/src/helpers/helpers.ts | 65 +++ apps/api/src/routers/authRouter.ts | 2 + apps/api/src/routers/clientInvites.ts | 223 ++++++++++ 4 files changed, 676 insertions(+) create mode 100644 apps/api/src/__tests__/routes/clientInvites.test.ts create mode 100644 apps/api/src/routers/clientInvites.ts 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/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/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; +}; From bc28194d3db50b9254c1e34f1051813d3bbf4f57 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 09:23:06 +0200 Subject: [PATCH 08/15] refactor: move tablo view components to packages/tablo-views Moves kanban, gantt, section components, chat hooks and extracted EtapesSection/RoadmapSection from apps/main into the new shared packages/tablo-views package. Components that previously depended on app-specific providers (UserStoreProvider, useIsReadOnlyUser, etc.) are refactored to receive data/callbacks as props, keeping the package free of apps/main dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tablo-views/package.json | 3 +- .../tablo-views/src}/ChatMessages.tsx | 0 packages/tablo-views/src/ClickOutside.tsx | 37 ++ packages/tablo-views/src/EtapesSection.tsx | 366 ++++++++++++++++++ packages/tablo-views/src/ImageColorPicker.tsx | 114 ++++++ packages/tablo-views/src/RoadmapSection.tsx | 23 ++ .../src}/TabloDiscussionSection.tsx | 25 +- .../tablo-views/src}/TabloEventsSection.tsx | 89 ++++- .../tablo-views/src}/TabloFilesSection.tsx | 168 +++++--- .../tablo-views/src}/TabloHeaderActions.tsx | 63 ++- .../tablo-views/src}/TabloTasksSection.tsx | 103 +++-- .../src/components/gantt/GanttChart.tsx | 3 +- .../components/kanban/InlineTaskCreate.tsx | 18 - .../src/components/kanban/KanbanBoard.tsx | 0 .../src/components/kanban/KanbanColumn.tsx | 0 .../src/components/kanban/KanbanTaskCard.tsx | 0 .../src/components/kanban/TaskModal.tsx | 64 ++- .../src/components/kanban/types.ts | 0 .../tablo-views}/src/hooks/useChat.ts | 0 .../tablo-views}/src/hooks/useChatUnread.ts | 0 packages/tablo-views/src/index.ts | 29 +- packages/tablo-views/src/vite-env.d.ts | 1 + packages/tablo-views/tsconfig.json | 3 +- pnpm-lock.yaml | 55 +++ 24 files changed, 961 insertions(+), 203 deletions(-) rename {apps/main/src/components => packages/tablo-views/src}/ChatMessages.tsx (100%) create mode 100644 packages/tablo-views/src/ClickOutside.tsx create mode 100644 packages/tablo-views/src/EtapesSection.tsx create mode 100644 packages/tablo-views/src/ImageColorPicker.tsx create mode 100644 packages/tablo-views/src/RoadmapSection.tsx rename {apps/main/src/components => packages/tablo-views/src}/TabloDiscussionSection.tsx (68%) rename {apps/main/src/components => packages/tablo-views/src}/TabloEventsSection.tsx (78%) rename {apps/main/src/components => packages/tablo-views/src}/TabloFilesSection.tsx (90%) rename {apps/main/src/components => packages/tablo-views/src}/TabloHeaderActions.tsx (90%) rename {apps/main/src/components => packages/tablo-views/src}/TabloTasksSection.tsx (75%) rename {apps/main => packages/tablo-views}/src/components/gantt/GanttChart.tsx (99%) rename {apps/main => packages/tablo-views}/src/components/kanban/InlineTaskCreate.tsx (87%) rename {apps/main => packages/tablo-views}/src/components/kanban/KanbanBoard.tsx (100%) rename {apps/main => packages/tablo-views}/src/components/kanban/KanbanColumn.tsx (100%) rename {apps/main => packages/tablo-views}/src/components/kanban/KanbanTaskCard.tsx (100%) rename {apps/main => packages/tablo-views}/src/components/kanban/TaskModal.tsx (82%) rename {apps/main => packages/tablo-views}/src/components/kanban/types.ts (100%) rename {apps/main => packages/tablo-views}/src/hooks/useChat.ts (100%) rename {apps/main => packages/tablo-views}/src/hooks/useChatUnread.ts (100%) create mode 100644 packages/tablo-views/src/vite-env.d.ts diff --git a/packages/tablo-views/package.json b/packages/tablo-views/package.json index 464a36f..5de94b5 100644 --- a/packages/tablo-views/package.json +++ b/packages/tablo-views/package.json @@ -35,6 +35,7 @@ "@biomejs/biome": "2.2.5", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", - "typescript": "^5.7.0" + "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 && (
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..622e533 100644 --- a/apps/main/src/pages/tablo.tsx +++ b/apps/main/src/pages/tablo.tsx @@ -40,7 +40,7 @@ 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 { TaskModal } from "@xtablo/tablo-views"; import { ProjectCardList } from "src/components/ProjectCardList"; import { Badge } from "@xtablo/ui/components/badge"; import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos"; diff --git a/apps/main/src/pages/tasks.tsx b/apps/main/src/pages/tasks.tsx index f4fa3f9..a2763d2 100644 --- a/apps/main/src/pages/tasks.tsx +++ b/apps/main/src/pages/tasks.tsx @@ -40,8 +40,7 @@ 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 { GanttChart, TaskModal } from "@xtablo/tablo-views"; 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/pnpm-lock.yaml b/pnpm-lock.yaml index f0e6669..ecad098 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,91 @@ importers: specifier: ^4.14.0 version: 4.44.0(@cloudflare/workers-types@4.20260411.1) + apps/clients: + dependencies: + '@tanstack/react-query': + specifier: ^5.69.0 + version: 5.90.5(react@19.0.0) + '@xtablo/chat-ui': + specifier: workspace:* + version: link:../../packages/chat-ui + '@xtablo/shared': + specifier: workspace:* + version: link:../../packages/shared + '@xtablo/shared-types': + specifier: workspace:* + version: link:../../packages/shared-types + '@xtablo/tablo-views': + specifier: workspace:* + version: link:../../packages/tablo-views + '@xtablo/ui': + specifier: workspace:* + version: link:../../packages/ui + i18next: + specifier: ^25.6.0 + version: 25.6.0(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.0 + version: 8.2.0 + lucide-react: + specifier: ^0.460.0 + version: 0.460.0(react@19.0.0) + react: + specifier: 19.0.0 + version: 19.0.0 + react-dom: + specifier: 19.0.0 + version: 19.0.0(react@19.0.0) + react-i18next: + specifier: ^16.2.0 + version: 16.2.0(i18next@25.6.0(typescript@5.9.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.9.3) + react-router-dom: + specifier: ^7.9.4 + version: 7.9.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + tailwind-merge: + specifier: ^3.0.2 + version: 3.3.1 + zustand: + specifier: ^5.0.5 + version: 5.0.8(@types/react@19.0.10)(react@19.0.0)(use-sync-external-store@1.6.0(react@19.0.0)) + devDependencies: + '@biomejs/biome': + specifier: 2.2.5 + version: 2.2.5 + '@cloudflare/vite-plugin': + specifier: ^1.9.4 + version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.44.0(@cloudflare/workers-types@4.20260411.1)) + '@tailwindcss/vite': + specifier: ^4.0.14 + version: 4.1.15(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)) + '@types/react': + specifier: 19.0.10 + version: 19.0.10 + '@types/react-dom': + specifier: 19.0.4 + version: 19.0.4(@types/react@19.0.10) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)) + tailwindcss: + specifier: ^4.0.14 + version: 4.1.15 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.2.2 + version: 6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.1)(tsx@4.20.6)) + wrangler: + specifier: ^4.24.3 + version: 4.44.0(@cloudflare/workers-types@4.20260411.1) + apps/external: dependencies: '@tanstack/react-query': @@ -314,6 +399,9 @@ importers: '@xtablo/shared-types': specifier: workspace:* version: link:../../packages/shared-types + '@xtablo/tablo-views': + specifier: workspace:* + version: link:../../packages/tablo-views '@xtablo/ui': specifier: workspace:* version: link:../../packages/ui From 118b23bfb1a45e0cd6df10fd88599dd2716a5ce8 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 14:18:27 +0200 Subject: [PATCH 12/15] feat(main): add client invite UI to share dialog Adds three React Query hooks (usePendingClientInvites, useCreateClientInvite, useCancelClientInvite) and a new Client Access section in the share dialog with email input, pending invite list, expiry countdown, and orange warning badge for invites expiring in less than 5 days. Co-Authored-By: Claude Sonnet 4.6 --- apps/main/src/hooks/client_invites.ts | 89 ++++++++++++++++++ apps/main/src/pages/tablo-details.tsx | 126 ++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 apps/main/src/hooks/client_invites.ts diff --git a/apps/main/src/hooks/client_invites.ts b/apps/main/src/hooks/client_invites.ts new file mode 100644 index 0000000..ea6d49b --- /dev/null +++ b/apps/main/src/hooks/client_invites.ts @@ -0,0 +1,89 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast, useSession } from "@xtablo/shared"; +import { useAuthedApi } from "./auth"; + +type PendingClientInvite = { + id: number; + invited_email: string; + expires_at: string; + is_pending: boolean; + created_at: string; +}; + +export const usePendingClientInvites = (tabloId: string) => { + const api = useAuthedApi(); + const { session } = useSession(); + + return useQuery({ + queryKey: ["client-invites", tabloId], + queryFn: async () => { + const { data } = await api.get( + `/api/v1/client-invites/${tabloId}/pending` + ); + return data; + }, + enabled: !!tabloId && !!session, + }); +}; + +export const useCreateClientInvite = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, email }: { tabloId: string; email: string }) => { + const { data } = await api.post( + `/api/v1/client-invites/${tabloId}`, + { email } + ); + return data; + }, + onSuccess: (_data, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] }); + toast.add( + { + title: "Lien magique envoyé", + description: "L'invitation client a été envoyée avec succès", + type: "success", + }, + { timeout: 3000 } + ); + }, + onError: (error) => { + console.error("Error creating client invite:", error); + toast.add( + { + title: "Erreur", + description: "Impossible d'envoyer l'invitation client", + type: "error", + }, + { timeout: 5000 } + ); + }, + }); +}; + +export const useCancelClientInvite = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => { + await api.delete(`/api/v1/client-invites/${tabloId}/${inviteId}`); + }, + onSuccess: (_data, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] }); + }, + onError: (error) => { + console.error("Error cancelling client invite:", error); + toast.add( + { + title: "Erreur", + description: "Impossible d'annuler l'invitation client", + type: "error", + }, + { timeout: 5000 } + ); + }, + }); +}; diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index 940ba76..42d82f2 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -34,6 +34,7 @@ import { Sun, UserPlusIcon, Waves, + XIcon, Zap, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -52,6 +53,11 @@ import { useInviteUser } from "../hooks/invite"; import { useTabloFileNames, useDownloadTabloFile, useUploadTabloFile, useDeleteTabloFile } from "../hooks/tablo_data"; import { useTabloFolders, useCreateTabloFolder, useUpdateTabloFolder, useDeleteTabloFolder } from "../hooks/tablo_folders"; import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; +import { + usePendingClientInvites, + useCreateClientInvite, + useCancelClientInvite, +} from "../hooks/client_invites"; import { useTabloMembers, useTabloOverviewLayout, @@ -187,6 +193,7 @@ export const TabloDetailsPage = () => { const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); + const [clientInviteEmail, setClientInviteEmail] = useState(""); const [isLayoutEditMode, setIsLayoutEditMode] = useState(false); const [draggedOverviewBlock, setDraggedOverviewBlock] = useState<{ zone: "left" | "right"; @@ -200,6 +207,9 @@ export const TabloDetailsPage = () => { const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? ""); const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite(); const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser(); + const { data: pendingClientInvites } = usePendingClientInvites(tabloId ?? ""); + const { mutate: createClientInvite, isPending: isCreatingClientInvite } = useCreateClientInvite(); + const { mutate: cancelClientInvite, isPending: isCancellingClientInvite } = useCancelClientInvite(); const { mutate: updateTask } = useUpdateTask(); const { mutate: updateTablo, mutateAsync: updateTabloAsync } = useUpdateTablo(); const { mutate: createTask } = useCreateTask(); @@ -1032,6 +1042,122 @@ 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é + + )} + +
+ ); + })} +
+
+ )} +
From f3fb08c9b2b60b8b487b3db8f3d03923fba3d45b Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 14:30:55 +0200 Subject: [PATCH 13/15] feat(clients): add layout, auth callback, tablo page, and list page Adds SessionProvider to main.tsx, creates ClientLayout with minimal top bar, AuthCallback for magic link handling, ClientTabloPage with all 7 tabs using tablo-views components, and ClientTabloListPage with auto-redirect for single tablo. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/clients/src/components/ClientLayout.tsx | 67 ++++ apps/clients/src/lib/supabase.ts | 10 + apps/clients/src/main.tsx | 16 +- apps/clients/src/pages/AuthCallback.tsx | 66 ++++ .../clients/src/pages/ClientTabloListPage.tsx | 63 ++++ apps/clients/src/pages/ClientTabloPage.tsx | 310 ++++++++++++++++++ apps/clients/src/routes.tsx | 12 +- apps/clients/tsconfig.json | 1 + 8 files changed, 536 insertions(+), 9 deletions(-) create mode 100644 apps/clients/src/components/ClientLayout.tsx create mode 100644 apps/clients/src/lib/supabase.ts create mode 100644 apps/clients/src/pages/AuthCallback.tsx create mode 100644 apps/clients/src/pages/ClientTabloListPage.tsx create mode 100644 apps/clients/src/pages/ClientTabloPage.tsx 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/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/main.tsx b/apps/clients/src/main.tsx index ecf1020..d158df1 100644 --- a/apps/clients/src/main.tsx +++ b/apps/clients/src/main.tsx @@ -1,11 +1,13 @@ 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"; @@ -14,12 +16,14 @@ 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 index 4f94f7f..57a23ce 100644 --- a/apps/clients/src/routes.tsx +++ b/apps/clients/src/routes.tsx @@ -1,11 +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 ( - Auth callback placeholder
} /> - Tablo view placeholder
} /> - Client portal placeholder
} /> + } /> + }> + } /> + } /> + ); } diff --git a/apps/clients/tsconfig.json b/apps/clients/tsconfig.json index 64a1401..f2fa327 100644 --- a/apps/clients/tsconfig.json +++ b/apps/clients/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", From e8044182d8f9e39ab13078eba197d417160e2e7e Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 14:33:38 +0200 Subject: [PATCH 14/15] fix: resolve lint and formatting issues in apps/main Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/main/src/components/ChatHeader.tsx | 4 +- .../main/src/components/DashboardTaskList.tsx | 2 +- apps/main/src/components/Layout.tsx | 10 +- apps/main/src/components/NavigationBar.tsx | 12 +- .../src/components/SubscriptionCard.test.tsx | 16 ++- .../TabloDiscussionSection.test.tsx | 2 +- .../components/TabloEventsSection.test.tsx | 2 +- .../src/components/TabloFilesSection.test.tsx | 9 +- .../src/components/TabloOverviewSection.tsx | 2 +- .../main/src/components/UpgradePanel.test.tsx | 14 ++- apps/main/src/components/UpgradePanel.tsx | 3 +- apps/main/src/components/kanban/index.ts | 2 +- .../src/contexts/UpgradeBlockContext.test.tsx | 16 ++- .../main/src/contexts/UpgradeBlockContext.tsx | 2 +- apps/main/src/hooks/auth.ts | 2 +- apps/main/src/hooks/client_invites.ts | 7 +- apps/main/src/hooks/tasks.ts | 2 +- apps/main/src/main.css | 8 +- apps/main/src/pages/chat.tsx | 4 +- apps/main/src/pages/settings.tsx | 6 +- apps/main/src/pages/tablo-details.tsx | 114 ++++++++++++------ apps/main/src/pages/tablo.tsx | 6 +- apps/main/src/pages/tasks.tsx | 2 +- 23 files changed, 153 insertions(+), 94 deletions(-) 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 24c5772..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 "@xtablo/tablo-views"; 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/SubscriptionCard.test.tsx b/apps/main/src/components/SubscriptionCard.test.tsx index c8a8af6..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); @@ -51,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, @@ -75,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", @@ -123,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 bac0eb6..08218e3 100644 --- a/apps/main/src/components/TabloDiscussionSection.test.tsx +++ b/apps/main/src/components/TabloDiscussionSection.test.tsx @@ -1,6 +1,6 @@ +import { TabloDiscussionSection } from "@xtablo/tablo-views"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; -import { TabloDiscussionSection } from "@xtablo/tablo-views"; vi.mock("@xtablo/tablo-views/hooks/useChat", () => ({ useChat: () => ({ diff --git a/apps/main/src/components/TabloEventsSection.test.tsx b/apps/main/src/components/TabloEventsSection.test.tsx index 3f06166..0fee1a3 100644 --- a/apps/main/src/components/TabloEventsSection.test.tsx +++ b/apps/main/src/components/TabloEventsSection.test.tsx @@ -1,6 +1,6 @@ +import { TabloEventsSection } from "@xtablo/tablo-views"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../utils/testHelpers"; -import { TabloEventsSection } from "@xtablo/tablo-views"; vi.mock("@xtablo/tablo-views/hooks/events", () => ({ useEventsByTablo: () => ({ diff --git a/apps/main/src/components/TabloFilesSection.test.tsx b/apps/main/src/components/TabloFilesSection.test.tsx index e8ec114..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 "@xtablo/tablo-views"; 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 da680be..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 "@xtablo/tablo-views"; interface TabloOverviewSectionProps { tablo: UserTablo; diff --git a/apps/main/src/components/UpgradePanel.test.tsx b/apps/main/src/components/UpgradePanel.test.tsx index b1a0b6f..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 = { @@ -63,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", @@ -129,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 = { @@ -826,9 +850,13 @@ export const TabloDetailsPage = () => { onCreateTask={(task) => createTask(task)} onUpdateTask={(task) => updateTask(task)} onUpdateTaskPositions={(updates) => updateTaskPositions(updates)} - onUpdateTablo={(data) => updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => {})} + onUpdateTablo={(data) => + updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) + } onInviteUser={inviteUser} - onCancelInvite={(params) => cancelInvite({ ...params, inviteId: Number(params.inviteId) })} + onCancelInvite={(params) => + cancelInvite({ ...params, inviteId: Number(params.inviteId) }) + } /> )} {activeSection === "files" && ( @@ -849,15 +877,19 @@ export const TabloDetailsPage = () => { isCancellingInvite={isCancellingInvite} isCreatingFolder={isCreatingFolder} isUpdatingFolder={isUpdatingFolder} - onCreateFile={(params) => uploadFile(params).then(() => {})} - onDeleteFile={(params) => deleteFile(params).then(() => {})} - onDownloadFile={(params) => downloadFile(params).then(() => {})} - onCreateFolder={(params) => createFolder(params).then(() => {})} - onUpdateFolder={(params) => updateFolder(params).then(() => {})} - onDeleteFolder={(params) => deleteFolder(params).then(() => {})} - onUpdateTablo={(data) => updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => {})} + 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) })} + onCancelInvite={(params) => + cancelInvite({ ...params, inviteId: Number(params.inviteId) }) + } /> )} {activeSection === "discussion" && ( @@ -882,10 +914,14 @@ export const TabloDetailsPage = () => { pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} isInvitingUser={isInvitingUser} isCancellingInvite={isCancellingInvite} - onCreateEvent={() => {}} - onUpdateTablo={(data) => updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => {})} + onCreateEvent={() => undefined} + onUpdateTablo={(data) => + updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) + } onInviteUser={inviteUser} - onCancelInvite={(params) => cancelInvite({ ...params, inviteId: Number(params.inviteId) })} + onCancelInvite={(params) => + cancelInvite({ ...params, inviteId: Number(params.inviteId) }) + } /> )} @@ -895,8 +931,13 @@ export const TabloDetailsPage = () => { 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(() => {})} + onCreateTask={(task) => + createTask({ + ...task, + status: task.status as "todo" | "in_progress" | "in_review" | "done", + }) + } + onCreateEtape={(params) => createEtape(params).then(() => undefined)} isCreatingEtape={isCreatingEtape} /> )} @@ -1164,4 +1205,3 @@ export const TabloDetailsPage = () => {
); }; - diff --git a/apps/main/src/pages/tablo.tsx b/apps/main/src/pages/tablo.tsx index 622e533..f23fceb 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 "@xtablo/tablo-views"; 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) { diff --git a/apps/main/src/pages/tasks.tsx b/apps/main/src/pages/tasks.tsx index a2763d2..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,7 +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, TaskModal } from "@xtablo/tablo-views"; import { useTablosList } from "../hooks/tablos"; import { useAllTasks, useUpdateTask } from "../hooks/tasks"; import { useUser } from "../providers/UserStoreProvider"; From a42b79574f82b89d7475d77c29560a8ec811dd8c Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 15 Apr 2026 15:52:53 +0200 Subject: [PATCH 15/15] fix: unique inspector ports, remove temp invite UI, fix read-only race --- apps/clients/vite.config.ts | 4 +- apps/external/vite.config.ts | 4 +- apps/main/src/pages/tablo-details.tsx | 76 --------------------------- apps/main/src/pages/tablo.tsx | 2 +- apps/main/vite.config.ts | 2 +- 5 files changed, 6 insertions(+), 82 deletions(-) diff --git a/apps/clients/vite.config.ts b/apps/clients/vite.config.ts index 908aa8d..dfed7ff 100644 --- a/apps/clients/vite.config.ts +++ b/apps/clients/vite.config.ts @@ -5,10 +5,10 @@ import { defineConfig, type PluginOption } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig(({ mode }) => { - const plugins: PluginOption[] = [react(), tailwindcss(), tsconfigPaths()]; + const plugins: PluginOption[] = [react(), tailwindcss(), tsconfigPaths({ ignoreConfigErrors: true })]; if (mode !== "test" && process.env.VITEST !== "true") { - plugins.push(cloudflare()); + plugins.push(cloudflare({ inspectorPort: 9232 })); } return { 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/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index c6eeb80..42988dd 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -971,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 && (
diff --git a/apps/main/src/pages/tablo.tsx b/apps/main/src/pages/tablo.tsx index f23fceb..2a1caa3 100644 --- a/apps/main/src/pages/tablo.tsx +++ b/apps/main/src/pages/tablo.tsx @@ -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/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(