# Tenant Portals — Implementation Plan

**Spec:** docs/specs/2026-05-30-tenant-portals.md  ·  **Slug:** tenant-portals  ·  **Wave:** 9
**Depends on:** billing-module, crm-support-center, customers-module, foundation-auth-rbac, invoices-core, kb-module, projects-module

## Goal
Deliver two portal surfaces from the existing Zync Workers deployment: (1) a white-labeled, strictly customer-isolated **Customer Portal** at `/portal/{tenantSlug}/` with its own stateful JWT auth flow (email+password, magic link, password reset), and (2) a **Staff Portal** which is the main app under reduced-permission RBAC sidebar visibility. The customer portal lets an end-client view their own dashboard, projects, invoices (with pay), support tickets, vault KB, and proposals — never another customer's data. Auth is enforced by a dedicated `portal_customer` role, a stateful `portal_sessions` table, and tenant+customer scope checks on every route.

## Architecture
- **Portal auth boundary.** A new `PortalSessionPayload` (`role: 'portal_customer'`, `tenantId`, `customerId`, `portalSessionId`, `exp`, `iat`) distinct from the staff `SessionPayload`. Portal JWTs are **stateful** — every request verifies the JWT signature AND looks up the matching `portal_sessions` row (not revoked, not expired) by SHA-256 `token_hash`. This lets staff revoke portal access and lets password reset nuke all sessions. A dedicated `portalAuthMiddleware` (separate from upstream `authMiddleware`) gates every `/api/portal/*` route except the auth endpoints.
- **Consumed upstream tables** (already defined, do not recreate): `tenants(id, slug, name, tier, country_code, default_currency, ...)`, `customers(id, tenant_id, name, status)`, `customer_contacts(id, customer_id, tenant_id, email)`, `customer_portal_users(id, customer_id, tenant_id, contact_id, user_id, portal_role, status)`, `users(id, email, password_hash, status)`, `customer_communications`, `magic_link_tokens(id, tenant_id, user_id, email, token, token_hash, purpose, expires_at, used_at)` — its `purpose` enum already includes `'portal'`, `'portal_invite'`, `'portal_password_reset'` (NO alter needed), `invoices(id, tenant_id, customer_id, status, invoice_number, total, currency, issue_date, tax_issued_at)`, `invoice_lines`, `projects(id, tenant_id, customer_id, name, status, billing_type, start_date, end_date, description)`, `tickets(id, tenant_id, customer_id, subject, status, source, ...)`, `ticket_messages(id, ticket_id, author_type, body, ...)`, `ticket_categories`, `kb_spaces(id, tenant_id, type, customer_id, is_public, slug)`, `kb_articles(id, space_id, status, slug, ...)`, `tenant_settings(tenant_id PK, ...)` (owned by expenses-module — ALTER only), `payment_methods`, `payments` (billing-module, for "Pay Now").
- **Consumed upstream exports:** `hashPassword`, `verifyPassword` (PBKDF2 600k/SHA-256), `signSession`/`verifySession` JWT helpers (portal mints its own payload via the same JWT lib), `hashToken`, `generateOpaqueToken`, `timingSafeEqual`, `tenantQuery`, `systemQuery`, `sendEmail`, `recordSystemCommunication`/`appendCustomerCommunication`, `serializeInvoice`, `getPaymentAdapter`, `safeRedirect`, `rateLimit`, `RATE_LIMITER_AUTH`, `Customer`, `InvoiceStatus`, `TicketStatus`, `Locale`, `useDirection`, `LocaleProvider`, design-system primitives (`Card`, `StatCard`, `DataTable`, `Dialog`, `Form`, `Button`, `Badge`, `Alert`, `EmptyState`, `Skeleton`).
- **Data flow.** Login/magic/reset → mint portal JWT + insert `portal_sessions` + set `httpOnly` cookie `zync_portal_session`. Each `/api/portal/*` call → `portalAuthMiddleware` verifies JWT + `portal_sessions` row → every query is `tenantQuery(db, tid)` further constrained by `customer_id = customerId`. Invoice "Pay Now" initiates a hosted-gateway payment; the gateway webhook (external spec) — not the portal — marks the invoice PAID.
- **White-label.** Tenant logo + brand color + portal title injected server-side as CSS variables into the portal HTML shell; sourced from business settings (logo/brand color/portal display name). Custom-domain routing is out of scope (white-label-api spec); portal serves only the content at the path route.
- **Locale.** There is NO `tenants.locale` column upstream. Portal locale resolves: `customer_portal_users.locale` (new delta) → tenant default derived from `tenants.country_code` (`'IL' → 'he'`) → `'he'`. The portal SPA sets `dir`/`lang` on mount from the resolved portal locale (independent of the staff locale).

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — new `routes/portal/` route tree, `middleware/portalAuth.ts`, Zod schemas. JWT via the same lib used by `signSession`.
- **Web:** `apps/zync-app` (Vite + React SPA) — `routes/portal/**` route group with a separate `PortalShell` (own `LocaleProvider`, white-label CSS-var injection, session-expiry guard).
- **DB:** `packages/db` (Drizzle) — new `portal_sessions` table + schema; ALTER `customer_portal_users` (add `locale`); ALTER `tenant_settings` (add `portal_max_session_hours`).
- **Bindings:** `RATE_LIMITER_AUTH` (forgot-password 3/hr/email), `STORAGE` (R2, ticket attachments — same allowlist as staff), `DB`/Hyperdrive (Neon Postgres), payment adapter via `getPaymentAdapter`.
- **Cross-cutting:** CSP on portal HTML shell; `timingSafeEqual` for all token comparisons; SHA-256 token hashing; 204-always on forgot-password (no enumeration); rate limiting; `aria` roles + `prefers-reduced-motion` on portal UI; RTL/Hebrew default.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 9a — schema | 1 | `packages/db/src/schema/*`, migrations | No (blocks all) |
| 9b — portal auth core | 2, 3 | `packages/auth` portal token + middleware | After 1; 2 before 3 |
| 9c — auth routes | 4, 5 | login/magic/refresh, forgot/reset routes | After 3; parallel |
| 9d — data routes | 6, 7, 8, 9 | dashboard, projects, invoices, tickets, kb, proposals, profile | After 3; parallel by domain |
| 9e — staff revoke + RBAC | 10 | customer-detail portal tab, sidebar RBAC | After 2 |
| 9f — portal SPA | 11, 12, 13, 14 | PortalShell, pages, session guard, white-label | After 4–9; 11 before 12–14 |

## Tasks

### Task 1: Database schema — portal_sessions, customer_portal_users.locale, tenant_settings delta
**Blocks:** 2, 3, 4, 5, 6, 7, 8, 9, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/portalSessions.ts`
- Modify: `packages/db/src/schema/customerPortalUsers.ts` (add `locale`)
- Modify: `packages/db/src/schema/tenantSettings.ts` (add `portal_max_session_hours`)
- Create: `packages/db/migrations/<timestamp>_tenant_portals.sql`
- Modify: `packages/db/src/schema/index.ts` (export new table)
**Steps:**
- [ ] Define `portal_sessions` Drizzle table with canonical UUID PK + FKs.
- [ ] Add `locale` column to the existing `customer_portal_users` Drizzle table (do NOT redefine the table — customers-module owns it).
- [ ] Add `portal_max_session_hours` to the existing `tenant_settings` table (do NOT redefine — expenses-module owns it).
- [ ] Add indexes for portal-session lookup by `token_hash` and by `(tenant_id, customer_id)`.
- [ ] Run `drizzle-kit generate`; verify migration is additive (CREATE TABLE + ALTER ADD COLUMN, no data loss).
**Schema / Interfaces:**
```sql
CREATE TABLE portal_sessions (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash  TEXT NOT NULL,                          -- SHA-256 hex of the portal JWT
  expires_at  TIMESTAMPTZ NOT NULL,
  revoked_at  TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_portal_sessions_token ON portal_sessions(token_hash);
CREATE INDEX idx_portal_sessions_customer ON portal_sessions(tenant_id, customer_id) WHERE revoked_at IS NULL;

-- customer_portal_users delta (table owned by customers-module):
ALTER TABLE customer_portal_users
  ADD COLUMN locale TEXT DEFAULT NULL CHECK (locale IN ('he', 'en'));  -- NULL = use tenant default

-- tenant_settings delta (table owned by expenses-module):
ALTER TABLE tenant_settings
  ADD COLUMN portal_max_session_hours INTEGER NOT NULL DEFAULT 24
    CHECK (portal_max_session_hours BETWEEN 4 AND 72);
```
**Acceptance:**
- [ ] Migration applies cleanly to Neon; `portal_sessions` exists with UUID PK and both FKs.
- [ ] `customer_portal_users.locale` and `tenant_settings.portal_max_session_hours` present with correct CHECKs.

### Task 2: Portal JWT mint/verify + session lifecycle helpers
**Blocks:** 3, 4, 5, 10  ·  **Blocked by:** 1
**Files:**
- Create: `packages/auth/src/portal/token.ts`
- Create: `packages/auth/src/portal/session.ts`
- Create: `packages/auth/src/portal/types.ts`
- Modify: `packages/auth/src/index.ts` (export portal helpers + types)
**Steps:**
- [ ] Define `PortalSessionPayload` type.
- [ ] `signPortalSession(payload, secret)` mints a JWT with TTL = `min(4h, portal_max_session_hours remaining budget)` — base TTL 4h.
- [ ] `verifyPortalToken(token, secret)` verifies signature + returns decoded payload (throws on bad sig/expiry).
- [ ] `createPortalSession(db, { tenantId, customerId, userId, jwt, expiresAt })` → hashes the JWT (`hashToken`, SHA-256), inserts `portal_sessions` row, returns row id.
- [ ] `findActivePortalSession(db, tokenHash)` → row where `token_hash` matches (compared via `timingSafeEqual`), `revoked_at IS NULL`, `expires_at > now()`.
- [ ] `revokePortalSession(db, sessionId)` and `revokeAllPortalSessions(db, tenantId, customerId)` → set `revoked_at = now()` on non-expired rows.
- [ ] `rotatePortalSession(db, oldSessionId, newRow)` → revoke old + insert new in one transaction (for refresh).
**Schema / Interfaces:**
```ts
export interface PortalSessionPayload {
  role: 'portal_customer';
  tenantId: string;       // TenantId
  customerId: string;
  userId: string;         // UserId of linked users row
  portalSessionId: string;
  exp: number;
  iat: number;
}
export function signPortalSession(p: Omit<PortalSessionPayload,'exp'|'iat'>, secret: string, ttlSeconds: number): Promise<string>;
export function verifyPortalToken(token: string, secret: string): Promise<PortalSessionPayload>;
export function createPortalSession(db: Db, args: { tenantId: string; customerId: string; userId: string; jwt: string; expiresAt: Date }): Promise<{ sessionId: string }>;
export function findActivePortalSession(db: Db, tokenHash: string): Promise<{ id: string; tenantId: string; customerId: string; userId: string } | null>;
export function revokePortalSession(db: Db, sessionId: string): Promise<void>;
export function revokeAllPortalSessions(db: Db, tenantId: string, customerId: string): Promise<void>;
export function rotatePortalSession(db: Db, args: { oldSessionId: string; tenantId: string; customerId: string; userId: string; jwt: string; expiresAt: Date }): Promise<{ sessionId: string }>;
```
**Acceptance:**
- [ ] A minted token round-trips through `verifyPortalToken`; tampered token rejected.
- [ ] Token-hash comparison uses `timingSafeEqual`, never `===`.
- [ ] `revokeAllPortalSessions` flips `revoked_at` on all active rows for the customer.

### Task 3: portalAuthMiddleware — stateful portal request gate
**Blocks:** 6, 7, 8, 9  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/middleware/portalAuth.ts`
- Modify: `apps/zync-api/src/app.ts` (mount portal route tree behind middleware)
**Steps:**
- [ ] Read `zync_portal_session` `httpOnly` cookie; if absent → 401.
- [ ] `verifyPortalToken` the JWT; on failure (incl. expiry) → 401 with `{ code: 'portal_session_expired' }`.
- [ ] Assert `payload.role === 'portal_customer'`; reject any staff JWT.
- [ ] `hashToken` the JWT and call `findActivePortalSession`; if no active row → 401 (revoked/expired server-side).
- [ ] Attach `{ tenantId, customerId, userId, portalSessionId }` to Hono context for downstream handlers.
- [ ] Resolve tenant by `{tenantSlug}` path segment; assert `payload.tenantId` maps to that slug, else 403 (cross-tenant token rejected).
- [ ] Provide a `requirePortalScope(resourceCustomerId)` helper that 403s when `resourceCustomerId !== ctx.customerId`.
**Acceptance:**
- [ ] A staff `zync_session` JWT presented to `/api/portal/*` → 401 (role mismatch).
- [ ] A portal JWT whose `portal_sessions` row is revoked → 401.
- [ ] A portal JWT for tenant A used under `/portal/{slugB}/...` → 403.

### Task 4: Portal auth routes — login, magic link, refresh
**Blocks:** 11  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/portal/auth.ts`
- Create: `apps/zync-api/src/schemas/portalAuth.ts`
**Steps:**
- [ ] `POST /api/portal/auth/login` `{ tenantSlug, email, password }`: resolve tenant by slug → find `customer_portal_users` (status `'active'`) by `contact_id`’s email within tenant → load linked `users.password_hash` → `verifyPassword`. On success mint portal JWT (TTL 4h), `createPortalSession`, set `zync_portal_session` cookie, return `{ customerId, tenantSlug, expiresAt }`. Generic 401 on any mismatch.
- [ ] `POST /api/portal/auth/magic` `{ tenantSlug, email }`: look up active `customer_portal_users`; if found, `generateOpaqueToken`, insert `magic_link_tokens` with `purpose='portal'`, TTL 1h, single-use; email link `/portal/{tenantSlug}/magic?token={plaintext}` via `sendEmail`. Always 204 (no enumeration).
- [ ] `GET /api/portal/auth/magic/verify?token=&tenantSlug=`: `hashToken`, look up `magic_link_tokens` (`purpose='portal'`, not used, not expired, `timingSafeEqual` on hash); mark used; mint portal JWT + session; set cookie; redirect to `/portal/{tenantSlug}/`.
- [ ] `POST /api/portal/auth/refresh`: from current valid session cookie, validate `portal_sessions` row (not revoked, not expired); if `exp - now() < 1800s` mint a NEW 4h JWT, `rotatePortalSession` (revoke old + insert new), set new cookie; enforce max total duration = `tenant_settings.portal_max_session_hours` (default 24, cap 72) measured from the originating session’s `created_at`; beyond budget → 401 forcing re-auth. Response 204.
- [ ] `POST /api/portal/auth/logout`: revoke current `portal_sessions` row, clear cookie, 204.
**Schema / Interfaces:**
```ts
export const portalLoginSchema = z.object({ tenantSlug: z.string().min(1), email: z.string().email(), password: z.string().min(8) });
export const portalMagicSchema = z.object({ tenantSlug: z.string().min(1), email: z.string().email() });
```
**Acceptance:**
- [ ] Valid credentials yield a `zync_portal_session` cookie and a `portal_sessions` row.
- [ ] Magic-link request always returns 204 regardless of email existence.
- [ ] Refresh within last 30 min rotates the session; total lifetime never exceeds the tenant cap.

### Task 5: Portal password reset — forgot + reset
**Blocks:** 11  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/portal/passwordReset.ts`
- Modify: `apps/zync-api/src/schemas/portalAuth.ts`
**Steps:**
- [ ] `POST /api/portal/auth/forgot-password` `{ email, tenantSlug }`: enforce `RATE_LIMITER_AUTH` at 3 requests/hour/email; resolve `customer_portal_users WHERE email = :email AND tenant_id = :tid AND status = 'active'`; if found insert `magic_link_tokens` `purpose='portal_password_reset'`, TTL 1h, single-use; email link `/portal/{tenantSlug}/reset-password?token={plaintext}` via `sendEmail`. **Always 204** (no enumeration).
- [ ] `POST /api/portal/auth/reset-password` `{ token, tenantSlug, newPassword }`: `hashToken`, look up token (`purpose='portal_password_reset'`, not used, not expired, `timingSafeEqual`); validate `newPassword` (min 8); `hashPassword` (PBKDF2 600k/SHA-256) → update linked `users.password_hash`; mark token used; `revokeAllPortalSessions(tenantId, customerId)`; 204. Invalid/expired/used token → 400.
**Schema / Interfaces:**
```ts
export const forgotPasswordSchema = z.object({ email: z.string().email(), tenantSlug: z.string().min(1) });
export const resetPasswordSchema = z.object({ token: z.string().min(1), tenantSlug: z.string().min(1), newPassword: z.string().min(8) });
```
**Acceptance:**
- [ ] `forgot-password` returns 204 for both existing and non-existing emails; 4th request within an hour is rate-limited.
- [ ] Successful reset updates `users.password_hash` and revokes ALL active `portal_sessions` for the customer.
- [ ] Reusing a consumed reset token → 400.

### Task 6: Portal dashboard + projects routes
**Blocks:** 12  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/portal/dashboard.ts`
- Create: `apps/zync-api/src/routes/portal/projects.ts`
**Steps:**
- [ ] `GET /api/portal/dashboard`: aggregate scoped to `customerId` — outstanding balance (sum `total` of `invoices` in TAX_ISSUED/PARTIALLY_PAID unpaid for customer), active projects count (`projects WHERE customer_id = :cid AND status = 'active'`), open tickets count (`tickets WHERE customer_id = :cid AND status IN ('open','in_progress','pending_customer')`), unread/new vault KB article count (PUBLISHED articles in customer vault spaces). Return widget summary.
- [ ] `GET /api/portal/projects`: list `projects WHERE customer_id = :cid` (read-only). Fields: name, billing_type (as "type"), status, start_date, end_date, customer-visible description, progress (task-completion % if the project has tasks). No task-level detail (V1).
- [ ] All queries via `tenantQuery(db, tenantId)` + explicit `customer_id = ctx.customerId`.
**Schema / Interfaces:**
```ts
interface PortalDashboard {
  outstandingBalance: { amount: string; currency: string };
  activeProjects: number;
  openTickets: number;
  unreadKbArticles: number;
}
interface PortalProject { id: string; name: string; type: string; status: string; startDate: string | null; endDate: string | null; description: string | null; progressPct: number | null }
```
**Acceptance:**
- [ ] Dashboard widgets reflect only the authenticated customer’s data.
- [ ] Projects of a different customer are never returned (scope test).

### Task 7: Portal invoices routes (list, HTML, pay)
**Blocks:** 12  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/portal/invoices.ts`
**Steps:**
- [ ] `GET /api/portal/invoices?status=`: list invoices for `customerId` filtered to `status IN ('TAX_ISSUED','PAID','PARTIALLY_PAID')` only (DRAFT/CANCELLED/VOID never visible); optional status filter (All · Tax Issued · Paid · Partially Paid). Serialize via `serializeInvoice` (number, date, amount, status).
- [ ] `GET /api/portal/invoices/:id/html`: assert invoice belongs to `customerId` (else 403/404); render invoice HTML — for TAX_ISSUED serve the immutable R2 snapshot, else render on the fly. Reject DRAFT/CANCELLED.
- [ ] `POST /api/portal/invoices/:id/pay`: assert ownership + invoice is `TAX_ISSUED` and unpaid; require a configured payment adapter (`getPaymentAdapter`); initiate hosted tokenization/pay session and return the gateway redirect URL. The portal does NOT call `record-payment`; the **gateway webhook is authoritative** for marking PAID. If no adapter configured → 409.
**Schema / Interfaces:**
```ts
interface PortalInvoiceListItem { id: string; invoiceNumber: string | null; issueDate: string | null; total: string; currency: string; status: InvoiceStatus }
// POST /pay → { redirectUrl: string }
```
**Acceptance:**
- [ ] Only TAX_ISSUED/PAID/PARTIALLY_PAID invoices for the customer appear; DRAFT/CANCELLED excluded.
- [ ] `pay` on a non-TAX_ISSUED or already-paid invoice → 409; portal never writes a payment row directly.
- [ ] Cross-customer invoice id → 403/404.

### Task 8: Portal tickets routes (list, create, detail, reply)
**Blocks:** 12  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/portal/tickets.ts`
- Create: `apps/zync-api/src/schemas/portalTickets.ts`
**Steps:**
- [ ] `GET /api/portal/tickets`: list `tickets WHERE customer_id = :cid` (customer’s own only). Fields: subject, status, created_at, last message at.
- [ ] `POST /api/portal/tickets` `{ subject, body, categoryId, attachments[] }`: create `tickets` row with `source='portal'`, `customer_id = ctx.customerId`; insert first `ticket_messages` (`author_type='customer'`); attachments uploaded to R2 under the same allowlist as staff. Category must be one of the tenant’s allowed `ticket_categories`.
- [ ] `GET /api/portal/tickets/:id`: assert ownership; return thread (`ticket_messages` ordered, with author_type) — plain text, no rich text.
- [ ] `POST /api/portal/tickets/:id/reply` `{ body, attachments[] }`: assert ownership + ticket not closed; append `ticket_messages` (`author_type='customer'`); email notification to staff per the comms pipeline; record `customer_communications` row (`channel='ticket'`).
**Schema / Interfaces:**
```ts
export const createPortalTicketSchema = z.object({ subject: z.string().min(1).max(200), body: z.string().min(1), categoryId: z.string().uuid(), attachmentKeys: z.array(z.string()).max(10).optional() });
export const replyPortalTicketSchema = z.object({ body: z.string().min(1), attachmentKeys: z.array(z.string()).max(10).optional() });
```
**Acceptance:**
- [ ] A customer can never load another customer’s ticket (scope 403/404).
- [ ] Portal-created tickets carry `source='portal'` and a `customer` first message.
- [ ] Reply appends `author_type='customer'` and triggers a staff notification.

### Task 9: Portal KB, proposals, profile routes
**Blocks:** 12  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/portal/kb.ts`
- Create: `apps/zync-api/src/routes/portal/proposals.ts`
- Create: `apps/zync-api/src/routes/portal/profile.ts`
- Modify: `apps/zync-api/src/schemas/portalAuth.ts` (profile schemas)
**Steps:**
- [ ] `GET /api/portal/kb/spaces`: list `kb_spaces WHERE type = 'vault' AND customer_id = :cid` (the customer’s vault spaces) plus `is_public = true` spaces if any.
- [ ] `GET /api/portal/kb/articles/:id`: assert the article’s space is a vault for this customer (or public) AND `status = 'PUBLISHED'`; return rendered content (DRAFT never served to portal).
- [ ] `GET /api/portal/proposals`: list `proposals WHERE customer_id = :cid` (active + archived). **Cross-wave note:** the `proposals` table is owned by `marketing-catalogs-campaigns`, which is also wave 9 and NOT a declared dependency — guard this route so it degrades gracefully (empty list) if the table/module is absent at build time; reference exact columns `public_token`, `status`, `customer_id`, `name`, `sent_at`, `expires_at`.
- [ ] `PATCH /api/portal/profile` `{ name?, locale? }`: update the linked contact/portal-user display name and `customer_portal_users.locale` (`'he'|'en'|null`). Email is read-only.
- [ ] `PATCH /api/portal/profile/password` `{ currentPassword, newPassword }`: `verifyPassword` current → `hashPassword` new → update `users.password_hash`; revoke all OTHER `portal_sessions` for this customer (keep current). 204.
**Schema / Interfaces:**
```ts
export const portalProfileSchema = z.object({ name: z.string().min(1).max(120).optional(), locale: z.enum(['he','en']).nullable().optional() });
export const portalPasswordChangeSchema = z.object({ currentPassword: z.string().min(8), newPassword: z.string().min(8) });
```
**Acceptance:**
- [ ] Only PUBLISHED articles in the customer’s vault (or public) spaces are returned; DRAFT yields 404.
- [ ] Proposals route returns only this customer’s proposals and tolerates the module being absent.
- [ ] Password change updates `users.password_hash` and revokes other sessions.

### Task 10: Staff-side portal management + Staff Portal RBAC sidebar
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-app/src/routes/customers/CustomerDetail.tsx` (Portal tab — revoke sessions)
- Create: `apps/zync-api/src/routes/customers/portalSessions.ts`
- Modify: `apps/zync-app/src/components/AppSidebar.tsx` (RBAC-driven visibility)
**Steps:**
- [ ] Add `POST /api/customers/:id/portal-users/:uid/revoke-sessions` (staff, `users:invite` perm): `revokeAllPortalSessions(tenantId, customerId)` for the customer. Used by the customer-detail "Revoke all sessions" action.
- [ ] On the customer detail Portal tab, add a "Revoke all sessions" button (force re-auth) alongside existing freeze/unfreeze.
- [ ] Ensure deleting/suspending a customer contact triggers `revokeAllPortalSessions` (hook into existing freeze flow).
- [ ] Staff Portal: render the main-app sidebar from the staff role’s permission set (`OWNER`/`ADMIN`/`MANAGER`/`STAFF`/`READ_ONLY`). `STAFF` sees only assigned tasks, time tracking, personal KB; `READ_ONLY` sees read-only assigned projects + tasks. Profile + neural-sync preferences visible to all roles. Enforcement remains standard `tenantQuery(db, tenantId)` + permission checks — no new auth path.
**Acceptance:**
- [ ] Staff "Revoke all sessions" flips `revoked_at` on all active portal sessions; the customer’s next request 401s.
- [ ] A `STAFF` user sees a reduced sidebar; a `READ_ONLY` user cannot mutate projects/tasks.

### Task 11: Portal SPA shell — routing, white-label, session-expiry guard
**Blocks:** 12, 13, 14  ·  **Blocked by:** 4, 5
**Files:**
- Create: `apps/zync-app/src/portal/PortalShell.tsx`
- Create: `apps/zync-app/src/portal/portalRoutes.tsx`
- Create: `apps/zync-app/src/portal/usePortalSession.ts`
- Create: `apps/zync-app/src/portal/portalLocale.ts`
- Modify: `apps/zync-app/src/main.tsx` (mount `/portal/:tenantSlug/*` route group)
**Steps:**
- [ ] Build `PortalShell` with its OWN `LocaleProvider`: resolve portal locale = `customer_portal_users.locale` → tenant default (`country_code 'IL' → 'he'`) → `'he'`; set `document.documentElement.setAttribute('dir', dir)` and `('lang', locale)` on portal route mount (independent of staff locale). Use `useDirection`.
- [ ] Inject white-label CSS variables (tenant logo, primary brand color, portal title) server-side into the shell; brand color applies to CTAs + header; title defaults to tenant business name.
- [ ] Apply a strict CSP to portal HTML responses.
- [ ] `usePortalSession`: on load and on each route transition, decode the portal JWT `exp`; if expired → store `sessionStorage.portal_return_to = location.pathname` and redirect to `/portal/{tenantSlug}/login?session_expired=1`. If within last 30 min of TTL → call `POST /api/portal/auth/refresh`. After successful login, redirect to `portal_return_to` with same-origin validation (`safeRedirect`).
- [ ] Respect `prefers-reduced-motion` for all portal transitions; ensure `aria` roles on nav/landmarks.
**Acceptance:**
- [ ] Portal sets `dir`/`lang` from the resolved portal locale, not the browser language.
- [ ] An expired session redirects to login with the banner flag and restores the original path post-login (same-origin only).
- [ ] Logo + brand color render from tenant settings.

### Task 12: Portal pages — dashboard, projects, invoices
**Blocks:** —  ·  **Blocked by:** 6, 7, 11
**Files:**
- Create: `apps/zync-app/src/portal/pages/Dashboard.tsx`
- Create: `apps/zync-app/src/portal/pages/Projects.tsx`
- Create: `apps/zync-app/src/portal/pages/Invoices.tsx`
**Steps:**
- [ ] Dashboard (`/portal/{tenantSlug}/`): four `StatCard` widgets (Outstanding Balance, Active Projects, Open Tickets, Unread KB Articles); "Pay Now" CTA on outstanding balance → routes to invoices with payment CTA.
- [ ] Projects (`/portal/{tenantSlug}/projects`): read-only `DataTable`/cards of customer projects — name, type, status, dates, progress %, description. No task detail.
- [ ] Invoices (`/portal/{tenantSlug}/invoices`): `DataTable` (number, date, amount, status) with status filter dropdown (All · Tax Issued · Paid · Partially Paid); "View" → invoice HTML; "Pay Now" on TAX_ISSUED unpaid → `POST /pay` → redirect to gateway; return page polls session status.
- [ ] Empty/error states via `EmptyState` + `ErrorState`; skeletons while loading.
**Acceptance:**
- [ ] Status filter limits to the three visible statuses; DRAFT/CANCELLED never appear.
- [ ] "Pay Now" only enabled for TAX_ISSUED unpaid invoices and opens the gateway.

### Task 13: Portal pages — tickets, KB, proposals
**Blocks:** —  ·  **Blocked by:** 8, 9, 11
**Files:**
- Create: `apps/zync-app/src/portal/pages/Tickets.tsx`
- Create: `apps/zync-app/src/portal/pages/TicketDetail.tsx`
- Create: `apps/zync-app/src/portal/pages/Kb.tsx`
- Create: `apps/zync-app/src/portal/pages/Proposals.tsx`
**Steps:**
- [ ] Tickets list + create form (subject, body, category from allowed categories, attachment upload via R2 allowlist).
- [ ] Ticket detail: thread view (plain text), customer reply form; replies append as `author_type='customer'`.
- [ ] KB (`/portal/{tenantSlug}/kb`): vault space tree + article reader (PUBLISHED only); reuse kb-module article rendering.
- [ ] Proposals (`/portal/{tenantSlug}/proposals`): list active + archived proposals (same view as public `/p/{token}` but authenticated); gracefully empty if proposals module absent.
**Acceptance:**
- [ ] Customer sees only their own tickets and proposals.
- [ ] DRAFT articles never render in the portal reader.

### Task 14: Portal pages — login, magic, reset, profile
**Blocks:** —  ·  **Blocked by:** 4, 5, 11
**Files:**
- Create: `apps/zync-app/src/portal/pages/Login.tsx`
- Create: `apps/zync-app/src/portal/pages/MagicCallback.tsx`
- Create: `apps/zync-app/src/portal/pages/ForgotPassword.tsx`
- Create: `apps/zync-app/src/portal/pages/ResetPassword.tsx`
- Create: `apps/zync-app/src/portal/pages/Profile.tsx`
**Steps:**
- [ ] Login (`/portal/{tenantSlug}/login`): email+password form + "Magic link" + "Forgot password?"; show "Your session has expired. Please sign in again." banner when `?session_expired=1`.
- [ ] Magic callback (`/portal/{tenantSlug}/magic?token=`): call verify endpoint, then route to dashboard.
- [ ] Forgot password: email field → `POST /forgot-password`; always shows the same "check your email" confirmation (no enumeration).
- [ ] Reset password (`/portal/{tenantSlug}/reset-password?token=`): new-password form → `POST /reset-password` → redirect to login.
- [ ] Profile (`/portal/{tenantSlug}/profile`): name (editable), email (read-only), password change, notification preferences + locale selector.
- [ ] All forms use `Form`/`Input`/`Button`; `aria` labels; RTL-aware.
**Acceptance:**
- [ ] Forgot-password UI gives an identical response for any email.
- [ ] Reset flow lands the user back at login; magic flow lands at dashboard.
- [ ] Session-expired banner appears only with the query flag.
