Fix config.ts

This commit is contained in:
Arthur Belleville 2026-05-01 10:33:00 +02:00
parent 2cf5eb8789
commit 90d34833e8
No known key found for this signature in database
6 changed files with 812 additions and 7 deletions

View file

@ -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");
});
});

View file

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

View file

@ -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"}
{"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"}

View file

@ -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"}
{"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"}

View file

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

View file

@ -86,7 +86,7 @@ export function AuthCardShell({
>
{topLeft || showThemeToggle ? (
<div className="mb-6 flex items-center justify-between">
<div>{topLeft}</div>
<div className="p-2">{topLeft}</div>
{showThemeToggle ? (
<Button
variant="ghost"