docs: add client magic links design spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-15 08:26:52 +02:00
parent 4f31275c82
commit bc00eaf53e
No known key found for this signature in database

View file

@ -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=<invite_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=<invite_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