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