diff --git a/apps/api/src/__tests__/config/stripe-config.test.ts b/apps/api/src/__tests__/config/stripe-config.test.ts index 0a29917..d573af7 100644 --- a/apps/api/src/__tests__/config/stripe-config.test.ts +++ b/apps/api/src/__tests__/config/stripe-config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { createConfig } from "../../config.js"; import type { Secrets } from "../../secrets.js"; @@ -17,6 +17,12 @@ const baseSecrets: Secrets = { stripeWebhookSecretStaging: "whsec_live_staging_secret_manager", }; +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; +}); + describe("createConfig stripe env overrides", () => { it("prefers env stripe keys in development to avoid local sandbox/account mismatch", () => { process.env.NODE_ENV = "development"; @@ -28,4 +34,21 @@ describe("createConfig stripe env overrides", () => { expect(config.STRIPE_SECRET_KEY).toBe("sk_test_env_override"); expect(config.STRIPE_WEBHOOK_SECRET).toBe("whsec_test_env_override"); }); + + it("derives a staging API base URL instead of localhost when API_BASE_URL is unset", () => { + process.env.NODE_ENV = "staging"; + process.env.SUPABASE_URL = "https://example.supabase.co"; + process.env.EMAIL_USER = "test@xtablo.com"; + process.env.EMAIL_CLIENT_ID = "client-id"; + process.env.STRIPE_SOLO_PRICE_ID = "price_solo"; + process.env.STRIPE_TEAM_PRICE_ID = "price_team"; + process.env.STRIPE_FOUNDER_PRICE_ID = "price_founder"; + process.env.R2_ACCOUNT_ID = "r2-account"; + process.env.XTABLO_URL = "https://app-staging.xtablo.com"; + delete process.env.API_BASE_URL; + + const config = createConfig(baseSecrets); + + expect(config.API_BASE_URL).toBe("https://api-staging.xtablo.com/api/v1"); + }); }); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 0b03ecc..fb87d27 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -54,6 +54,37 @@ function validateEnvVar(name: string, value: string | undefined): string { return value; } +function trimTrailingSlash(value: string) { + return value.replace(/\/+$/, ""); +} + +function resolveApiBaseUrl(input: { + apiBaseUrl?: string; + nodeEnv: AppConfig["NODE_ENV"]; + port: number; + xtabloUrl: string; +}) { + if (input.apiBaseUrl) { + return input.apiBaseUrl; + } + + if (input.nodeEnv === "development" || input.nodeEnv === "test") { + return `http://localhost:${input.port}/api/v1`; + } + + const xtabloUrl = trimTrailingSlash(input.xtabloUrl); + + if (xtabloUrl === "https://app.xtablo.com") { + return "https://api.xtablo.com/api/v1"; + } + + if (xtabloUrl === "https://app-staging.xtablo.com") { + return "https://api-staging.xtablo.com/api/v1"; + } + + return `${xtabloUrl}/api/v1`; +} + export function createConfig(secrets?: Secrets): AppConfig { const NODE_ENV = (process.env.NODE_ENV || "development") as | "development" @@ -73,11 +104,13 @@ export function createConfig(secrets?: Secrets): AppConfig { isStagingMode ? secrets!.stripeWebhookSecretStaging : secrets!.stripeWebhookSecret; const getStripeSecretKeyFromEnv = () => process.env.STRIPE_SECRET_KEY; const getStripeWebhookSecretFromEnv = () => process.env.STRIPE_WEBHOOK_SECRET; + const XTABLO_URL = process.env.XTABLO_URL || "https://app.xtablo.com"; + const PORT = parseInt(process.env.PORT || "8080", 10); // Base configuration const baseConfig: AppConfig = { NODE_ENV, - PORT: parseInt(process.env.PORT || "8080", 10), + PORT, SUPABASE_URL: validateEnvVar("SUPABASE_URL", process.env.SUPABASE_URL), SUPABASE_SERVICE_ROLE_KEY: isTestMode ? validateEnvVar("SUPABASE_SERVICE_ROLE_KEY", process.env.SUPABASE_SERVICE_ROLE_KEY) @@ -108,8 +141,13 @@ export function createConfig(secrets?: Secrets): AppConfig { EMAIL_REFRESH_TOKEN: isTestMode ? validateEnvVar("EMAIL_REFRESH_TOKEN", process.env.EMAIL_REFRESH_TOKEN) : secrets!.emailRefreshToken, - API_BASE_URL: process.env.API_BASE_URL || `http://localhost:${process.env.PORT || "8080"}/api/v1`, - XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com", + API_BASE_URL: resolveApiBaseUrl({ + apiBaseUrl: process.env.API_BASE_URL, + nodeEnv: NODE_ENV, + port: PORT, + xtabloUrl: XTABLO_URL, + }), + XTABLO_URL, R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID), R2_ACCESS_KEY_ID: isTestMode ? validateEnvVar("R2_ACCESS_KEY_ID", process.env.R2_ACCESS_KEY_ID) diff --git a/apps/clients/tsconfig.tsbuildinfo b/apps/clients/tsconfig.tsbuildinfo index 0266a11..f38412a 100644 --- a/apps/clients/tsconfig.tsbuildinfo +++ b/apps/clients/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/envproduction.test.ts","./src/i18n.test.ts","./src/i18n.ts","./src/main.tsx","./src/maincss.test.ts","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/components/clientauthgate.tsx","./src/components/clientlayout.test.tsx","./src/components/clientlayout.tsx","./src/lib/rum.ts","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.test.tsx","./src/pages/clienttablopage.tsx","./src/pages/loginpage.test.tsx","./src/pages/loginpage.tsx","./src/pages/resetpasswordpage.test.tsx","./src/pages/resetpasswordpage.tsx","./src/pages/setpasswordpage.test.tsx","./src/pages/setpasswordpage.tsx","./src/test/testhelpers.test.tsx","./src/test/testhelpers.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/envproduction.test.ts","./src/i18n.test.ts","./src/i18n.ts","./src/main.tsx","./src/maincss.test.ts","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/components/clientauthgate.tsx","./src/components/clientlayout.test.tsx","./src/components/clientlayout.tsx","./src/hooks/useclientportal.ts","./src/hooks/useclientsession.ts","./src/lib/api.ts","./src/lib/rum.ts","./src/lib/supabase.ts","./src/pages/authcallback.tsx","./src/pages/clienttablolistpage.tsx","./src/pages/clienttablopage.test.tsx","./src/pages/clienttablopage.tsx","./src/pages/loginpage.test.tsx","./src/pages/loginpage.tsx","./src/pages/resetpasswordpage.test.tsx","./src/pages/resetpasswordpage.tsx","./src/pages/setpasswordpage.test.tsx","./src/pages/setpasswordpage.tsx","./src/test/testhelpers.test.tsx","./src/test/testhelpers.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/apps/external/tsconfig.tsbuildinfo b/apps/external/tsconfig.tsbuildinfo index 0b37a6d..22943ee 100644 --- a/apps/external/tsconfig.tsbuildinfo +++ b/apps/external/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-30-client-magic-link-auth.md b/docs/superpowers/plans/2026-04-30-client-magic-link-auth.md new file mode 100644 index 0000000..776b751 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-client-magic-link-auth.md @@ -0,0 +1,744 @@ +# Client Magic Link Auth Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `profiles.is_client` and the Supabase-auth-based client portal with a backend-owned client identity system using email magic links, stateless JWT session cookies, and API-backed authorization. + +**Architecture:** Introduce `public.clients`, `public.client_access`, and `public.client_magic_links` as the new client auth model. The API will own magic-link issuance, one-time exchange, cookie verification, and client-scoped resource authorization; `apps/clients` will stop using Supabase Auth and direct browser Supabase reads, and will instead rely on backend APIs with `withCredentials` cookies. + +**Tech Stack:** Supabase Postgres, Hono API, JWT + HttpOnly cookies, React 19, React Router, TanStack Query, Axios, Vitest, pnpm workspaces. + +**Spec:** `docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md` + +--- + +## File Structure + +### New files + +**Database** +- `supabase/migrations/20260501100000_create_client_auth_tables.sql` — creates `clients`, `client_access`, and `client_magic_links` +- `supabase/migrations/20260501150000_drop_profiles_is_client.sql` — removes `profiles.is_client` after code migration is complete + +**API helpers and tests** +- `apps/api/src/helpers/clientAccounts.ts` — normalize email, upsert client identities, manage access grants +- `apps/api/src/helpers/clientSessions.ts` — sign/verify client session JWTs, sign/verify magic-link JWT payloads, hash or look up one-time tokens +- `apps/api/src/__tests__/helpers/clientSessions.test.ts` — unit tests for JWT and cookie helpers +- `apps/api/src/routers/clientAuth.ts` — request-link, exchange, logout, and current-client endpoints +- `apps/api/src/routers/clientPortal.ts` — cookie-authenticated endpoints used by `apps/clients` +- `apps/api/src/__tests__/routes/clientAuth.test.ts` — request-link, exchange, logout, neutral-response, and invalid-token coverage +- `apps/api/src/__tests__/routes/clientPortal.test.ts` — client-cookie authorization and tablo/resource access coverage + +**Client frontend** +- `apps/clients/src/lib/api.ts` — Axios instance with `withCredentials: true` +- `apps/clients/src/hooks/useClientSession.ts` — loads current client session from the API +- `apps/clients/src/hooks/useClientPortal.ts` — React Query hooks for tablos, tasks, etapes, files, events, and members +- `apps/clients/src/test/clientSessionTestUtils.tsx` — shared test wrapper for cookie-backed session mocks + +### Files to delete after migration + +- `apps/clients/src/lib/supabase.ts` +- `apps/clients/src/pages/AuthCallback.tsx` +- `apps/clients/src/pages/ResetPasswordPage.tsx` +- `apps/clients/src/pages/SetPasswordPage.tsx` +- `apps/clients/src/pages/ResetPasswordPage.test.tsx` +- `apps/clients/src/pages/SetPasswordPage.test.tsx` + +### Modified files + +**API** +- `apps/api/src/config.ts` — add client-auth env vars: JWT signing secret, session TTL, magic-link TTL, cookie name, cookie domain, and clients app URL +- `apps/api/src/types/app.types.ts` — add client-authenticated environment typing +- `apps/api/src/middlewares/middleware.ts` — add client-cookie auth middleware and remove `is_client`-based collaborator gating +- `apps/api/src/index.ts` — keep credentialed CORS working for client cookie requests +- `apps/api/src/routers/index.ts` — mount `clientAuth` and `clientPortal` routers +- `apps/api/src/routers/clientInvites.ts` — keep admin invite creation/cancel/list behavior, but switch its internals to the new `clients` tables and magic-link issuance +- `apps/api/src/routers/tablo.ts` or `apps/api/src/routers/tablo_data.ts` — extract reusable tablo-loading helpers if needed by `clientPortal.ts` +- `apps/api/src/helpers/helpers.ts` — remove old `findOrCreateClientAccount` / `ensureClientTabloAccess` logic once new helper files take over +- `apps/api/src/helpers/billing.ts` — remove `is_client` billing exclusions +- `apps/api/src/routers/user.ts` — stop exposing or expecting `is_client` on collaborator profile responses +- `apps/api/src/__tests__/routes/clientInvites.test.ts` +- `apps/api/src/__tests__/middlewares/middlewares.test.ts` +- `apps/api/src/__tests__/helpers/billing.test.ts` + +**Shared types** +- `packages/shared-types/src/database.types.ts` — add `clients`, `client_access`, `client_magic_links`; remove `profiles.is_client` + +**Main app** +- `apps/main/src/hooks/client_invites.ts` — adapt to the simplified invite response shape +- `apps/main/src/pages/tablo-details.tsx` — update invite copy and pending-invite UI +- `apps/main/src/pages/tablo-details.layout.test.tsx` +- `apps/main/src/components/ProtectedRoute.tsx` — remove `user.is_client` redirect logic +- `apps/main/src/components/AuthenticationGateway.tsx` — remove `user.is_client` redirect logic +- `apps/main/src/lib/clientPortal.ts` — delete if no longer needed +- `apps/main/src/components/ProtectedRoute.test.tsx` +- `apps/main/src/components/AuthenticationGateway.test.tsx` +- `apps/main/src/components/AuthenticationGateway.unit.tsx` +- `apps/main/src/providers/UserStoreProvider.tsx` — ensure the collaborator profile type no longer depends on `is_client` +- `apps/main/src/utils/testHelpers.tsx` +- `apps/main/src/contexts/UpgradeBlockContext.test.tsx` + +**Client app** +- `apps/clients/src/main.tsx` — remove `SessionProvider` +- `apps/clients/src/routes.tsx` — remove callback/setup/reset routes and keep email-link login + protected routes +- `apps/clients/src/components/ClientAuthGate.tsx` — use backend session endpoint instead of Supabase session state +- `apps/clients/src/components/ClientLayout.tsx` — render current client email/name from backend session and call logout endpoint +- `apps/clients/src/components/ClientLayout.test.tsx` +- `apps/clients/src/pages/LoginPage.tsx` — convert to email-only request-link form +- `apps/clients/src/pages/LoginPage.test.tsx` +- `apps/clients/src/pages/ClientTabloListPage.tsx` — fetch from backend instead of `supabase.from("user_tablos")` +- `apps/clients/src/pages/ClientTabloPage.tsx` — migrate all data and mutations to backend hooks +- `apps/clients/src/pages/ClientTabloPage.test.tsx` + +--- + +## Chunk 1: Backend Auth Foundation + +### Task 1: Create the new client-auth schema and shared types + +**Files:** +- Create: `supabase/migrations/20260501100000_create_client_auth_tables.sql` +- Modify: `packages/shared-types/src/database.types.ts` +- Test: `apps/api/src/__tests__/routes/clientInvites.test.ts` + +- [ ] **Step 1: Write the failing invite tests against the new tables** + +Add or rewrite route tests in `apps/api/src/__tests__/routes/clientInvites.test.ts` for: + +```ts +it("upserts one global client identity per normalized email", async () => {}); +it("creates a client_access grant for the invited tablo", async () => {}); +it("creates a one-time invite magic link row", async () => {}); +it("reuses the same client row when the same email is invited again", async () => {}); +``` + +Make the assertions target `clients`, `client_access`, and `client_magic_links`, not `profiles.is_client` or `client_invites`. + +- [ ] **Step 2: Run the route test file to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientInvites.test.ts +``` + +Expected: +- FAIL because the current implementation still writes to `profiles.is_client` and `client_invites` + +- [ ] **Step 3: Write the schema migration** + +In `supabase/migrations/20260501100000_create_client_auth_tables.sql`, create the new tables with the minimum approved shape: + +```sql +create table public.clients (...); +create unique index clients_normalized_email_idx on public.clients (normalized_email); + +create table public.client_access (...); +create unique index client_access_active_unique_idx + on public.client_access (client_id, tablo_id) + where revoked_at is null; + +create table public.client_magic_links (...); +create index client_magic_links_active_idx + on public.client_magic_links (client_id, purpose, expires_at) + where consumed_at is null; +``` + +Use one-time-use fields (`consumed_at`) and explicit expiry (`expires_at`). Do not recreate `is_pending` semantics under a different name. + +- [ ] **Step 4: Update shared database types** + +Update `packages/shared-types/src/database.types.ts` so it exposes: +- `clients` +- `client_access` +- `client_magic_links` + +Keep the new tables typed before any router work starts. + +- [ ] **Step 5: Run a type-focused verification** + +Run: + +```bash +pnpm --filter @xtablo/api typecheck +pnpm --filter @xtablo/clients typecheck +pnpm --filter @xtablo/main typecheck +``` + +Expected: +- collaborator app and client app still compile before code migration starts +- any failures should only be from the new type references you have not wired yet + +- [ ] **Step 6: Commit** + +```bash +git add supabase/migrations/20260501100000_create_client_auth_tables.sql packages/shared-types/src/database.types.ts apps/api/src/__tests__/routes/clientInvites.test.ts +git commit -m "feat: add client auth tables" +``` + +### Task 2: Add client account helpers, session helpers, and cookie middleware + +**Files:** +- Create: `apps/api/src/helpers/clientAccounts.ts` +- Create: `apps/api/src/helpers/clientSessions.ts` +- Create: `apps/api/src/__tests__/helpers/clientSessions.test.ts` +- Modify: `apps/api/src/config.ts` +- Modify: `apps/api/src/types/app.types.ts` +- Modify: `apps/api/src/middlewares/middleware.ts` +- Modify: `apps/api/src/helpers/helpers.ts` + +- [ ] **Step 1: Write the failing helper and middleware tests** + +Add tests in `apps/api/src/__tests__/helpers/clientSessions.test.ts` for: + +```ts +it("signs and verifies a client session JWT", async () => {}); +it("rejects expired client session JWTs", async () => {}); +it("extracts the configured client cookie from the request", async () => {}); +it("signs magic-link JWTs with jti and expiry claims", async () => {}); +``` + +Extend `apps/api/src/__tests__/middlewares/middlewares.test.ts` with: + +```ts +it("authenticates a client request from the client session cookie", async () => {}); +it("returns 401 when the client cookie is missing or invalid", async () => {}); +``` + +- [ ] **Step 2: Run the tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientSessions.test.ts middlewares.test.ts +``` + +Expected: +- FAIL because no client cookie auth primitives exist yet + +- [ ] **Step 3: Add config and environment support** + +In `apps/api/src/config.ts`, add: + +```ts +CLIENT_AUTH_JWT_SECRET: string; +CLIENT_AUTH_COOKIE_NAME: string; +CLIENT_AUTH_COOKIE_DOMAIN: string; +CLIENT_MAGIC_LINK_TTL_MINUTES: number; +CLIENT_SESSION_TTL_DAYS: number; +CLIENTS_URL: string; +``` + +Use explicit config fields instead of reading `process.env` directly inside routers. + +- [ ] **Step 4: Implement focused account and token helpers** + +In `apps/api/src/helpers/clientAccounts.ts`, add small single-purpose helpers: + +```ts +export function normalizeClientEmail(email: string): string {} +export async function upsertClientByEmail(...) {} +export async function ensureActiveClientAccess(...) {} +export async function clientHasAnyActiveAccess(...) {} +export async function revokeClientAccess(...) {} +``` + +In `apps/api/src/helpers/clientSessions.ts`, add: + +```ts +export function signClientSession(...) {} +export function verifyClientSession(...) {} +export function signClientMagicLink(...) {} +export function verifyClientMagicLink(...) {} +export function buildClientSessionCookie(...) {} +export function clearClientSessionCookie(...) {} +export function readClientSessionCookie(...) {} +``` + +Keep cookie formatting and JWT signing out of the routers. + +- [ ] **Step 5: Add client-auth middleware and typing** + +Update `apps/api/src/types/app.types.ts` with a client-authenticated environment shape, for example: + +```ts +type ClientEnv = BaseEnv & { + Variables: BaseEnv["Variables"] & { + client: Tables<"clients">; + }; +}; +``` + +Then update `apps/api/src/middlewares/middleware.ts` to expose: + +```ts +clientAuthMiddleware +maybeClientAuthMiddleware +``` + +These should verify the cookie, load the `clients` row, and set `c.set("client", client)`. + +- [ ] **Step 6: Remove helper duplication** + +Move client-specific logic out of `apps/api/src/helpers/helpers.ts`. Leave only thin wrappers there if other files still import the old names during the migration. + +- [ ] **Step 7: Run backend verification** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientSessions.test.ts middlewares.test.ts +pnpm --filter @xtablo/api typecheck +``` + +Expected: +- PASS for helper and middleware coverage + +- [ ] **Step 8: Commit** + +```bash +git add apps/api/src/helpers/clientAccounts.ts apps/api/src/helpers/clientSessions.ts apps/api/src/__tests__/helpers/clientSessions.test.ts apps/api/src/config.ts apps/api/src/types/app.types.ts apps/api/src/middlewares/middleware.ts apps/api/src/helpers/helpers.ts apps/api/src/__tests__/middlewares/middlewares.test.ts +git commit -m "feat: add client auth helpers and middleware" +``` + +--- + +## Chunk 2: Backend Routes And Authorized Client API + +### Task 3: Replace the invite flow and add public client-auth routes + +**Files:** +- Create: `apps/api/src/routers/clientAuth.ts` +- Create: `apps/api/src/__tests__/routes/clientAuth.test.ts` +- Modify: `apps/api/src/routers/clientInvites.ts` +- Modify: `apps/api/src/routers/index.ts` +- Modify: `apps/api/src/index.ts` +- Test: `apps/api/src/__tests__/routes/clientInvites.test.ts` + +- [ ] **Step 1: Write the failing route tests** + +Add `apps/api/src/__tests__/routes/clientAuth.test.ts` covering: + +```ts +it("returns a neutral success response for request-link even when the email is unknown", async () => {}); +it("creates and emails a login magic link when the client has active access", async () => {}); +it("rejects an expired or consumed exchange token", async () => {}); +it("sets the client session cookie when a valid token is exchanged", async () => {}); +it("clears the cookie on logout", async () => {}); +it("returns the current client from /me when the cookie is valid", async () => {}); +``` + +Extend `clientInvites.test.ts` with: + +```ts +it("sends an invite magic link email for a new client", async () => {}); +it("reissues access via the same global client row for an existing client", async () => {}); +it("cancelling an invite revokes the targeted access row", async () => {}); +``` + +- [ ] **Step 2: Run the route tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientAuth.test.ts clientInvites.test.ts +``` + +Expected: +- FAIL because `clientAuth` router does not exist and `clientInvites` still uses the old Supabase-auth client model + +- [ ] **Step 3: Implement `clientAuth` public routes** + +In `apps/api/src/routers/clientAuth.ts`, add: + +```ts +POST /client-auth/request-link +GET /client-auth/exchange +POST /client-auth/logout +GET /client-auth/me +``` + +Behavior: +- `request-link` + - always return a neutral success payload + - only create and email a magic link if the normalized email maps to a client with active access +- `exchange` + - verify JWT + backing `client_magic_links` row + - reject missing / expired / consumed links + - mark `consumed_at` + - set the client session cookie + - redirect to the `redirect_to` route or a safe default inside `clients.xtablo.com` +- `logout` + - clear the cookie +- `me` + - require cookie auth and return the current client identity + +- [ ] **Step 4: Rework admin invite creation** + +In `apps/api/src/routers/clientInvites.ts`, keep the existing admin-facing surface: + +```ts +POST /client-invites/:tabloId +GET /client-invites/:tabloId/pending +DELETE /client-invites/:tabloId/:inviteId +``` + +But change the implementation to: +- upsert `clients` +- ensure `client_access` +- create a `purpose = 'invite'` magic-link row +- send an invite email that targets the new backend exchange route +- stop creating Supabase Auth users +- stop updating `profiles.is_client` +- stop using password-setup semantics + +Pending invites should now read from `client_magic_links` filtered by `purpose = 'invite'` and `consumed_at is null`. + +- [ ] **Step 5: Mount the router and keep CORS cookie-safe** + +In `apps/api/src/routers/index.ts`, mount `clientAuth.ts`. + +In `apps/api/src/index.ts`, verify the CORS middleware continues to send: +- `credentials: true` +- exact caller origin + +Do not rely on Authorization headers for the client portal anymore. + +- [ ] **Step 6: Run backend auth route verification** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientAuth.test.ts clientInvites.test.ts +pnpm --filter @xtablo/api typecheck +``` + +Expected: +- PASS for invite issuance, neutral login-link requests, exchange, logout, and `/me` + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/routers/clientAuth.ts apps/api/src/__tests__/routes/clientAuth.test.ts apps/api/src/routers/clientInvites.ts apps/api/src/routers/index.ts apps/api/src/index.ts apps/api/src/__tests__/routes/clientInvites.test.ts +git commit -m "feat: add client magic link auth routes" +``` + +### Task 4: Add the cookie-authenticated client portal API + +**Files:** +- Create: `apps/api/src/routers/clientPortal.ts` +- Create: `apps/api/src/__tests__/routes/clientPortal.test.ts` +- Modify: `apps/api/src/routers/index.ts` +- Modify: `apps/api/src/routers/tablo.ts` +- Modify: `apps/api/src/routers/tablo_data.ts` +- Modify: `apps/api/src/helpers/billing.ts` + +- [ ] **Step 1: Write the failing client portal route tests** + +Add tests for: + +```ts +it("lists only the tablos accessible to the authenticated client", async () => {}); +it("loads a single tablo only when client_access is active", async () => {}); +it("rejects access to a tablo outside the client's grants", async () => {}); +it("allows file, folder, task, etape, event, and member reads through the client portal API", async () => {}); +``` + +Keep response shapes aligned with the data `apps/clients` already expects today so the frontend rewrite is mostly transport-level. + +- [ ] **Step 2: Run the portal route tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientPortal.test.ts +``` + +Expected: +- FAIL because there is no cookie-authenticated client portal router yet + +- [ ] **Step 3: Implement `clientPortal.ts`** + +Add a dedicated router such as: + +```ts +GET /client-portal/tablos +GET /client-portal/tablos/:tabloId +GET /client-portal/tablos/:tabloId/tasks +GET /client-portal/tablos/:tabloId/etapes +GET /client-portal/tablos/:tabloId/events +GET /client-portal/tablos/:tabloId/members +GET /client-portal/tablos/:tabloId/files +GET /client-portal/tablos/:tabloId/folders +POST /client-portal/tablos/:tabloId/files +GET /client-portal/tablos/:tabloId/files/:fileName +POST /client-portal/tablos/:tabloId/folders +PUT /client-portal/tablos/:tabloId/folders/:folderId +DELETE /client-portal/tablos/:tabloId/folders/:folderId +``` + +Each route must: +- require `clientAuthMiddleware` +- verify `client_access` for the requested `tabloId` +- reuse existing query logic from `tablo.ts` / `tablo_data.ts` where practical + +- [ ] **Step 4: Extract shared query helpers instead of duplicating router code** + +If `tablo.ts` or `tablo_data.ts` contains query logic you need twice, extract helper functions with one responsibility, for example: + +```ts +async function loadTabloByIdForClient(...) {} +async function loadTabloTasks(...) {} +async function loadTabloFolders(...) {} +``` + +Do not copy-paste large query blocks into `clientPortal.ts`. + +- [ ] **Step 5: Remove billing and collaborator assumptions** + +Update `apps/api/src/helpers/billing.ts` to stop filtering `profiles.is_client`. Client identities no longer live in `profiles`, so collaborator billing should count only collaborator profile rows. + +- [ ] **Step 6: Run backend portal verification** + +Run: + +```bash +pnpm --filter @xtablo/api test -- clientPortal.test.ts billing.test.ts +pnpm --filter @xtablo/api typecheck +``` + +Expected: +- PASS for client portal data access and billing logic + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/routers/clientPortal.ts apps/api/src/__tests__/routes/clientPortal.test.ts apps/api/src/routers/index.ts apps/api/src/routers/tablo.ts apps/api/src/routers/tablo_data.ts apps/api/src/helpers/billing.ts apps/api/src/__tests__/helpers/billing.test.ts +git commit -m "feat: add cookie-authenticated client portal api" +``` + +--- + +## Chunk 3: Frontend Migration And Legacy Cleanup + +### Task 5: Migrate `apps/clients` to backend session cookies + +**Files:** +- Create: `apps/clients/src/lib/api.ts` +- Create: `apps/clients/src/hooks/useClientSession.ts` +- Create: `apps/clients/src/hooks/useClientPortal.ts` +- Create: `apps/clients/src/test/clientSessionTestUtils.tsx` +- Modify: `apps/clients/src/main.tsx` +- Modify: `apps/clients/src/routes.tsx` +- Modify: `apps/clients/src/components/ClientAuthGate.tsx` +- Modify: `apps/clients/src/components/ClientLayout.tsx` +- Modify: `apps/clients/src/pages/LoginPage.tsx` +- Modify: `apps/clients/src/pages/LoginPage.test.tsx` +- Modify: `apps/clients/src/pages/ClientTabloListPage.tsx` +- Modify: `apps/clients/src/pages/ClientTabloPage.tsx` +- Modify: `apps/clients/src/pages/ClientTabloPage.test.tsx` +- Modify: `apps/clients/src/components/ClientLayout.test.tsx` +- Delete: `apps/clients/src/lib/supabase.ts` +- Delete: `apps/clients/src/pages/AuthCallback.tsx` +- Delete: `apps/clients/src/pages/ResetPasswordPage.tsx` +- Delete: `apps/clients/src/pages/SetPasswordPage.tsx` +- Delete: `apps/clients/src/pages/ResetPasswordPage.test.tsx` +- Delete: `apps/clients/src/pages/SetPasswordPage.test.tsx` + +- [ ] **Step 1: Write the failing client app tests** + +Update tests to cover: + +```ts +it("submits an email-only login form and shows a neutral success state", async () => {}); +it("redirects unauthenticated users to /login after the session endpoint returns 401", async () => {}); +it("loads the current client from the backend session endpoint", async () => {}); +it("loads client tablos and tablo details from backend API hooks instead of Supabase", async () => {}); +it("logs out via the backend and returns to /login", async () => {}); +``` + +- [ ] **Step 2: Run the client app tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/clients test -- LoginPage.test.tsx ClientLayout.test.tsx ClientTabloPage.test.tsx +``` + +Expected: +- FAIL because the app still depends on `SessionProvider`, `supabase.auth`, and browser-side Supabase queries + +- [ ] **Step 3: Introduce a cookie-aware client API layer** + +In `apps/clients/src/lib/api.ts`, create an Axios instance: + +```ts +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + withCredentials: true, + headers: { "Content-Type": "application/json" }, +}); +``` + +Then add `useClientSession.ts` and `useClientPortal.ts` so pages do not build ad hoc API clients inline. + +- [ ] **Step 4: Remove Supabase session bootstrap from the app shell** + +Update `apps/clients/src/main.tsx` to remove `SessionProvider` and the Supabase client import entirely. + +Update `ClientAuthGate.tsx` to: +- call `/api/v1/client-auth/me` +- store the intended destination in `localStorage` +- redirect to `/login` when the API says unauthenticated + +Update `ClientLayout.tsx` to: +- read the current client from `useClientSession()` +- call `POST /api/v1/client-auth/logout` + +- [ ] **Step 5: Convert login and protected pages** + +Update `LoginPage.tsx` so it: +- renders a single email field +- submits to `POST /api/v1/client-auth/request-link` +- shows a generic “check your email” confirmation state +- removes password and reset-password flows + +Update `ClientTabloListPage.tsx` and `ClientTabloPage.tsx` so every query and mutation uses `useClientPortal.ts` hooks backed by the new API endpoints. + +Preserve current response shapes where possible to minimize UI churn in `@xtablo/tablo-views`. + +- [ ] **Step 6: Delete the obsolete client auth pages** + +Remove: +- `AuthCallback.tsx` +- `ResetPasswordPage.tsx` +- `SetPasswordPage.tsx` + +Also remove their routes from `apps/clients/src/routes.tsx`. + +- [ ] **Step 7: Run client app verification** + +Run: + +```bash +pnpm --filter @xtablo/clients test -- LoginPage.test.tsx ClientLayout.test.tsx ClientTabloPage.test.tsx +pnpm --filter @xtablo/clients typecheck +``` + +Expected: +- PASS for email-link login, auth gate, logout, and cookie-backed data loading + +- [ ] **Step 8: Commit** + +```bash +git add apps/clients/src/lib/api.ts apps/clients/src/hooks/useClientSession.ts apps/clients/src/hooks/useClientPortal.ts apps/clients/src/test/clientSessionTestUtils.tsx apps/clients/src/main.tsx apps/clients/src/routes.tsx apps/clients/src/components/ClientAuthGate.tsx apps/clients/src/components/ClientLayout.tsx apps/clients/src/pages/LoginPage.tsx apps/clients/src/pages/LoginPage.test.tsx apps/clients/src/pages/ClientTabloListPage.tsx apps/clients/src/pages/ClientTabloPage.tsx apps/clients/src/pages/ClientTabloPage.test.tsx apps/clients/src/components/ClientLayout.test.tsx +git add -u apps/clients/src/lib/supabase.ts apps/clients/src/pages/AuthCallback.tsx apps/clients/src/pages/ResetPasswordPage.tsx apps/clients/src/pages/SetPasswordPage.tsx apps/clients/src/pages/ResetPasswordPage.test.tsx apps/clients/src/pages/SetPasswordPage.test.tsx +git commit -m "feat: migrate clients app to cookie auth" +``` + +### Task 6: Remove legacy `is_client` logic from collaborator flows and finish cleanup + +**Files:** +- Create: `supabase/migrations/20260501150000_drop_profiles_is_client.sql` +- Modify: `apps/api/src/middlewares/middleware.ts` +- Modify: `apps/api/src/routers/user.ts` +- Modify: `apps/main/src/components/ProtectedRoute.tsx` +- Modify: `apps/main/src/components/AuthenticationGateway.tsx` +- Modify: `apps/main/src/components/ProtectedRoute.test.tsx` +- Modify: `apps/main/src/components/AuthenticationGateway.test.tsx` +- Modify: `apps/main/src/components/AuthenticationGateway.unit.tsx` +- Modify: `apps/main/src/hooks/client_invites.ts` +- Modify: `apps/main/src/pages/tablo-details.tsx` +- Modify: `apps/main/src/pages/tablo-details.layout.test.tsx` +- Modify: `apps/main/src/providers/UserStoreProvider.tsx` +- Modify: `apps/main/src/utils/testHelpers.tsx` +- Modify: `apps/main/src/contexts/UpgradeBlockContext.test.tsx` +- Delete: `apps/main/src/lib/clientPortal.ts` if no remaining imports + +- [ ] **Step 1: Write the failing cleanup tests** + +Update or add tests asserting: + +```ts +it("does not special-case collaborator routing based on user.is_client", async () => {}); +it("shows generic client invite success copy after invite creation", async () => {}); +it("stops expecting is_client in collaborator test fixtures", async () => {}); +``` + +- [ ] **Step 2: Run the targeted main-app and API tests to verify failure** + +Run: + +```bash +pnpm --filter @xtablo/main test -- ProtectedRoute.test.tsx AuthenticationGateway.test.tsx tablo-details.layout.test.tsx +pnpm --filter @xtablo/api test -- middlewares.test.ts +``` + +Expected: +- FAIL because collaborator routes and middleware still reference `is_client` + +- [ ] **Step 3: Remove `is_client` from runtime code** + +Update: +- `apps/api/src/middlewares/middleware.ts` +- `apps/api/src/routers/user.ts` +- `apps/main/src/components/ProtectedRoute.tsx` +- `apps/main/src/components/AuthenticationGateway.tsx` +- `apps/main/src/providers/UserStoreProvider.tsx` + +The collaborator app should no longer know about a client-user subtype inside the collaborator session model. + +- [ ] **Step 4: Update invite UX and fixtures** + +Update `apps/main/src/hooks/client_invites.ts` and `apps/main/src/pages/tablo-details.tsx` so the success states match the new backend behavior: +- client invited by email +- magic link sent +- pending invites listed from `client_magic_links` + +Then remove `is_client` from test helpers and fixtures. + +- [ ] **Step 5: Drop the old schema field** + +In `supabase/migrations/20260501150000_drop_profiles_is_client.sql`, remove the legacy column: + +```sql +alter table public.profiles drop column if exists is_client; +``` + +Only add this migration after the codebase no longer reads or writes the column. + +- [ ] **Step 6: Run final verification** + +Run: + +```bash +pnpm --filter @xtablo/api test +pnpm --filter @xtablo/api typecheck +pnpm --filter @xtablo/clients test +pnpm --filter @xtablo/clients typecheck +pnpm --filter @xtablo/main test -- ProtectedRoute.test.tsx AuthenticationGateway.test.tsx tablo-details.layout.test.tsx +pnpm --filter @xtablo/main typecheck +``` + +Expected: +- PASS for full client-auth flow and collaborator cleanup + +- [ ] **Step 7: Commit** + +```bash +git add supabase/migrations/20260501150000_drop_profiles_is_client.sql apps/api/src/middlewares/middleware.ts apps/api/src/routers/user.ts apps/main/src/components/ProtectedRoute.tsx apps/main/src/components/AuthenticationGateway.tsx apps/main/src/components/ProtectedRoute.test.tsx apps/main/src/components/AuthenticationGateway.test.tsx apps/main/src/components/AuthenticationGateway.unit.tsx apps/main/src/hooks/client_invites.ts apps/main/src/pages/tablo-details.tsx apps/main/src/pages/tablo-details.layout.test.tsx apps/main/src/providers/UserStoreProvider.tsx apps/main/src/utils/testHelpers.tsx apps/main/src/contexts/UpgradeBlockContext.test.tsx +git add -u apps/main/src/lib/clientPortal.ts +git commit -m "refactor: remove legacy is_client flow" +``` + +--- + +## Notes For Execution + +- Keep response payloads for `ClientTabloPage.tsx` as close as possible to the current shapes from `user_tablos`, `tasks_with_assignee`, `events_and_tablos`, and related queries. The feature is already large; do not rewrite UI state models unless the API boundary forces it. +- Do not mix collaborator bearer-token auth and client cookie auth in the same frontend hooks. Keep them separate. +- Prefer backend exchange-and-redirect for invite/login links so the raw magic-link token is not handled by React unless absolutely necessary. +- Do not add a `client_sessions` table in v1. That is explicitly out of scope for this plan. +- Delay the `drop_profiles_is_client` migration until every compile-time and runtime reference is gone. + +Plan complete and saved to `docs/superpowers/plans/2026-04-30-client-magic-link-auth.md`. Ready to execute? diff --git a/packages/auth-ui/src/AuthCardShell.tsx b/packages/auth-ui/src/AuthCardShell.tsx index eaa1113..f53b6a5 100644 --- a/packages/auth-ui/src/AuthCardShell.tsx +++ b/packages/auth-ui/src/AuthCardShell.tsx @@ -86,7 +86,7 @@ export function AuthCardShell({ > {topLeft || showThemeToggle ? (
-
{topLeft}
+
{topLeft}
{showThemeToggle ? (