xtablo-source/docs/superpowers/specs/2026-04-15-client-magic-links-design.md
Arthur Belleville bc00eaf53e
docs: add client magic links design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:26:52 +02:00

8.3 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 (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.

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
  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