8.4 KiB
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 (
getBillableMemberCountfilters outis_clientusers)
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)
- Admin opens tablo share dialog, enters client email
POST /api/v1/tablos/:tabloId/client-invites— validates admin access, createsclient_invitesrow with generated token andexpires_at = now() + 30 days- If no Supabase account exists for that email, the API creates one via
supabase.auth.admin.createUser({ email })and setsis_client: trueon the resulting profile row. Atablo_accessrow is pre-granted (is_admin: false,is_active: true). - 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 - Supabase sends the magic link email to the client
Client clicks the link
- Supabase verifies the auth token, redirects to
clients.xtablo.com/auth/callback?token=<invite_token> - Callback page exchanges the Supabase auth token for a session
- The
invite_tokenis used to callPOST /api/v1/client-invites/:token/accept— marks invite as accepted (is_pending: false), confirmstablo_accessis active - 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_invitesrow 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 detailsGET /api/v1/tablo-data/:tabloId/*— tasks, etapes, events, files metadataGET /api/v1/tablo-files/:tabloId/*— file downloadsPOST /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
useChatfromapps/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.useChathook: Moves topackages/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_temporaryand related code