# 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
- Admins are warned in the UI when the expiration is soon (less than 5 days)
- Admin can revoke access by setting `tablo_access.is_active = false`

### Returning clients

- Active session + valid `tablo_access` = direct access, no re-invitation needed
- Expired session requires a new magic link from the admin

## API Permission Scoping

### Middleware

New middleware variant: `clientUserCheckMiddleware` — returns `403` for `is_client` users on non-client-accessible routes.

### Client-accessible endpoints

- `GET /api/v1/tablos/:tabloId` — view tablo details
- `GET /api/v1/tablo-data/:tabloId/*` — tasks, etapes, events, files metadata
- `GET /api/v1/tablo-files/:tabloId/*` — file downloads
- `POST /api/v1/tablo-files/:tabloId/upload` — file uploads
- Chat endpoints (messages, typing, presence via WebSocket)
- `GET /api/v1/user/me` — own profile

### Blocked for client users

- Tablo CRUD (create, update, delete)
- Invite management (sending/cancelling invites)
- Organization endpoints
- Billing/Stripe endpoints
- Settings, user management

### Billing

`getBillableMemberCount` updated to exclude `is_client` users (same pattern as `is_temporary`).

### RLS policies

New row-level policies on `client_invites`:
- Admins can manage invites they created
- Clients can read their own invites (by email match)

## `packages/tablo-views` — Shared Package

Source-only package (TypeScript directly, no build step). Same pattern as `@xtablo/shared` and `@xtablo/ui`.

### Structure

```
packages/tablo-views/
├── package.json          (@xtablo/tablo-views)
├── tsconfig.json
└── src/
    ├── TabloOverviewSection.tsx
    ├── TabloEtapesSection.tsx
    ├── TabloTasksSection.tsx
    ├── TabloFilesSection.tsx
    ├── TabloDiscussionSection.tsx
    ├── TabloEventsSection.tsx
    ├── TabloRoadmapSection.tsx
    ├── components/        (shared sub-components these sections depend on)
    └── hooks/             (data-fetching hooks for tablo views, including useChat)
```

### What moves from `apps/main`

- The 7 tab section components
- Sub-components they directly depend on (task cards, file list items, gantt chart, etc.)
- Data-fetching hooks used exclusively by these views (including `useChat` from `apps/main/src/hooks/useChat.ts`)

### What stays in `apps/main`

- `TabloDetailsPage` (page shell with tab navigation, share dialog, invite management)
- Layout, navigation, routing
- App-level providers

### Dependencies

`@xtablo/tablo-views` depends on:
- `@xtablo/ui`
- `@xtablo/shared`
- `@xtablo/shared-types`
- `@xtablo/chat-ui`

Consumed by both `apps/main` and `apps/clients`.

### Refactor in `apps/main`

`TabloDetailsPage` imports sections from `@xtablo/tablo-views` instead of local files. Behavior stays identical — this is a move, not a rewrite.

## `apps/clients` — Client Portal App

### Structure

```
apps/clients/
├── package.json          (@xtablo/clients)
├── vite.config.ts
├── wrangler.toml         (clients.xtablo.com)
├── worker/index.ts
├── index.html
├── tsconfig.json
├── tsconfig.app.json
└── src/
    ├── main.tsx
    ├── App.tsx
    ├── routes.tsx
    ├── pages/
    │   ├── AuthCallback.tsx
    │   └── ClientTabloPage.tsx
    └── components/
        └── ClientLayout.tsx
```

### Cloudflare Worker

`wrangler.toml` routes `clients.xtablo.com` with SPA not-found handling. Same asset-serving pattern as `apps/main` and `apps/external`.

### Layout

`ClientLayout.tsx` — no sidebar. Minimal top bar with:
- Tablo name and color
- Client user avatar and name
- Logout action

### Routes

| Path | Component | Purpose |
|------|-----------|---------|
| `/auth/callback` | `AuthCallback` | Supabase magic link redirect + invite token acceptance |
| `/tablo/:tabloId` | `ClientTabloPage` | Scoped tablo view with all tabs |
| `/` | Redirect | To `/tablo/:tabloId` if one tablo, or simple list if multiple |

### `ClientTabloPage`

Renders the same tab system as `TabloDetailsPage` using components from `@xtablo/tablo-views`. Differences from `apps/main`:
- No share/invite dialog
- No tablo settings or delete actions
- No admin-only actions in the UI
- File section: download and upload enabled, no delete

### Providers

`QueryClientProvider`, `SessionProvider`, `ThemeProvider`, i18n — same setup as other apps. No `UserStoreProvider` or organization context (clients don't belong to orgs).

### Dev server

Port 5175 via `pnpm dev:clients`.

## Chat Integration

Client users get real Supabase accounts, so chat works with minimal changes:

- **Authentication:** Same JWT-based auth for WebSocket connections
- **Identity:** Profile row (name, optional avatar) used for chat display. Profile seeded with invited email on creation. Client can update display name on first access.
- **Permissions:** Client users can send messages and see typing indicators in tablo discussions they have access to. Tablo ID maps to channel ID.
- **`@xtablo/chat-ui`:** No changes needed. Components are already app-agnostic.
- **`useChat` hook:** Moves to `packages/tablo-views/src/hooks/` so both apps can use it.

## Migration Strategy

- Temporary users (`is_temporary`) remain untouched
- Existing tablo invitations continue to work via `apps/main`
- New client invites use the magic link flow via `apps/clients`
- Once all clients have migrated to magic links, a future phase removes `is_temporary` and related code
