docs: add client magic links design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4f31275c82
commit
bc00eaf53e
1 changed files with 222 additions and 0 deletions
222
docs/superpowers/specs/2026-04-15-client-magic-links-design.md
Normal file
222
docs/superpowers/specs/2026-04-15-client-magic-links-design.md
Normal 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
|
||||
Loading…
Reference in a new issue