# Custom Domain Settings UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-custom-domain-settings-ui.md  ·  **Slug:** custom-domain-settings-ui  ·  **Wave:** 11
**Depends on:** foundation-auth-rbac, settings-module, white-label-api

## Goal
Build the full `/settings/white-label` page UI that drives the custom portal domain lifecycle defined by `white-label-api` (the `tenant_domains` table, CF custom-hostname provisioning, and the `/api/settings/domains` endpoints). This spec owns the React page, its data hook, and exactly one new backend route — `POST /api/settings/domains/:id/verify` (immediate, rate-limited DNS re-check). It also renders the Enterprise tier gate + non-Enterprise upsell and a link-out to outbound-email (SMTP) settings.

## Architecture
- **Page** `/settings/white-label` renders inside the settings shell (owned by `settings-module`; this route is listed in that spec's Settings Navigation Manifest as spec 137, Enterprise tier). The page is Enterprise-only: non-Enterprise tenants see an upsell card instead of the form.
- **Data source:** consumes `white-label-api`'s `tenant_domains` table and its three endpoints (`GET`/`POST`/`DELETE /api/settings/domains`). This plan does NOT create `tenant_domains` — it is owned upstream by `white-label-api` and reproduced here as reference only.
- **New backend artifact:** `POST /api/settings/domains/:id/verify` — triggers an immediate DNS verification by invoking white-label-api's existing domain-verification routine (the same logic the upstream `domain-verify` cron runs every 15 min), then applies a KV rate-limit of 1 request per 2 minutes per domain. This route does NOT contain fresh Cloudflare API calls; it delegates to the upstream routine.
- **Data flow:** Page loads `GET /api/settings/domains` → renders one of: Add form (no domain), Pending/DNS-instructions, Active, or Error state. User actions hit `POST /api/settings/domains` (add), `POST /api/settings/domains/:id/verify` (check now), `DELETE /api/settings/domains/:id` (remove). While a domain is `PENDING` the page auto-polls `GET /api/settings/domains` so it transitions to Active without a manual refresh.

### Status / CNAME contract (consumed from white-label-api — canonical)
The `tenant_domains` table and its lifecycle are owned by `white-label-api` (wave 10). This UI consumes them verbatim:
- Status column name: **`status`**.
- Status values: **`pending` | `verified` | `active` | `error`** (lowercase 4-state; terminal state is `active`).
- CNAME target shown to tenant: **`portal.zync.is`**.

This plan does NOT create or alter `tenant_domains` — see the canonical CREATE TABLE in `white-label-api` (Schema / Interfaces). It is consumed read/write through that module's accessors and `/api/settings/domains` endpoints only.

## Tech Stack
- **App:** `apps/zync-app` (Vite + React) — the page component, hook, route registration in the settings shell.
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — the one new route handler.
- **Packages:** `@zync/ui` (Card, Button, Input, Form, FormField, FormLabel, FormError, Badge, Dialog, Alert, Spinner, Skeleton, EmptyState, toast), `@zync/auth` (authMiddleware, requirePermission, requireTier), `@zync/types`, `@zync/config`.
- **Tier gating / upsell:** `useTierGate`, `useUpgradeModal`, `requireTier` (foundation exports).
- **i18n / RTL:** `useDirection`, `LocaleProvider`, `translations`.
- **Bindings:** `RATELIMIT_KV` (KV) for the verify rate-limit; `rateLimit` helper.
- **Validation:** zod (`require-zod-validation-in-routes`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A | 1 | `apps/zync-api/src/routes/settings-domains.ts` | No (root of API surface) |
| B | 2, 3 | `apps/zync-app/src/hooks/useCustomDomains.ts`, `apps/zync-app/src/lib/domain.ts` | Yes (after A for types) |
| C | 4, 5, 6, 7 | `apps/zync-app/src/pages/settings/WhiteLabelPage.tsx` and sub-components | Yes (components are independent leaves) |
| D | 8, 9 | settings shell route registration, i18n strings, a11y pass | After C |

## Tasks

### Task 1: New verify endpoint `POST /api/settings/domains/:id/verify`
**Blocks:** 2  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/src/routes/settings-domains.ts` (white-label-api owns this file; add one route)
**Steps:**
- [ ] Register `POST /api/settings/domains/:id/verify` on the existing settings-domains router.
- [ ] Apply `authMiddleware`, then `requirePermission('settings:write')` and `requireTier('enterprise')` — verify is Enterprise-admin only.
- [ ] Validate `:id` as a zod UUID; load the `tenant_domains` row scoped to the caller's `tenant_id` (use `tenantQuery`, never raw Drizzle from routes). 404 if not found or owned by another tenant.
- [ ] Rate-limit via `rateLimit` against `RATELIMIT_KV` with key `domain-verify:{domainId}` and a 120-second window (1 request / 2 min). On limit hit return `429 rate_limited` with a `Retry-After` header.
- [ ] On pass: invoke white-label-api's existing domain-verification routine for this single domain (the same routine the `domain-verify` cron calls — do NOT write fresh CF API calls here). It checks DNS resolution, and on success transitions `status` → `active`, sets `verified_at = now()`, and provisions the CF custom hostname; on continued failure it may set `status = 'error'` with `error_message`.
- [ ] Return the refreshed domain row: `{ id, domain, status, verified_at, error_message, cloudflare_hostname_id }`.
- [ ] Audit the action within the transaction (`require-audit-in-transaction`): `domain.verify_triggered`.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/routes/settings-domains.ts
// POST /api/settings/domains/:id/verify
// auth: authMiddleware -> requirePermission('settings:write') -> requireTier('enterprise')
// 429 rate_limited if called within 120s of the previous verify for this domain
type DomainRow = {
  id: string;
  domain: string;
  status: 'pending' | 'verified' | 'active' | 'error';
  verified_at: string | null;
  error_message: string | null;
  cloudflare_hostname_id: string | null;
};
// returns: DomainRow (200)
```
**Acceptance:**
- [ ] A second call within 2 minutes for the same domain returns 429 with `Retry-After`.
- [ ] Non-Enterprise or non-`settings:write` caller is rejected (403) before any verification runs.
- [ ] On a domain whose CNAME now resolves, the route returns `status: 'active'` with `verified_at` set.

### Task 2: `useCustomDomains` data hook
**Blocks:** 4, 5, 6, 7  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/hooks/useCustomDomains.ts`
**Steps:**
- [ ] Implement `useCustomDomains()` using react-query: `GET /api/settings/domains` returning the tenant's domain list (spec is one-domain-per-tenant, so the UI treats the list as 0-or-1).
- [ ] Expose mutations: `addDomain({ domain })` → `POST /api/settings/domains`; `verifyDomain(id)` → `POST /api/settings/domains/:id/verify`; `removeDomain(id)` → `DELETE /api/settings/domains/:id`. Each invalidates the domains query on success.
- [ ] Implement conditional auto-poll: when the current domain `status === 'pending'`, set react-query `refetchInterval` to 30s; disable polling otherwise. (Architecture decision: button + auto-poll so users who forget to click still see the transition.)
- [ ] Surface mutation errors via `toast` (e.g. 409 `domain_already_verified` → "That domain is already in use by another account"; 429 → "Please wait before checking again").
**Schema / Interfaces:**
```ts
export type CustomDomain = {
  id: string;
  domain: string;
  status: 'pending' | 'verified' | 'active' | 'error';
  verified_at: string | null;
  error_message: string | null;
  cloudflare_hostname_id: string | null;
};
export function useCustomDomains(): {
  domain: CustomDomain | null;     // 0-or-1 per tenant
  isLoading: boolean;
  addDomain: (input: { domain: string }) => Promise<CustomDomain>;     // returns { ..., status:'pending', cname_target:'portal.zync.is' } shape
  verifyDomain: (id: string) => Promise<CustomDomain>;
  removeDomain: (id: string) => Promise<void>;
};
```
**Acceptance:**
- [ ] While a domain is `pending`, the hook refetches every 30s and stops once status becomes `active` or `error`.
- [ ] `addDomain` rejects surface the upstream 409 message to the caller.

### Task 3: Domain input validation + CNAME helper
**Blocks:** 4  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/lib/domain.ts`
**Steps:**
- [ ] Implement `isValidPortalSubdomain(value: string): boolean` — accepts a fully-qualified hostname (e.g. `portal.acme.com`), lowercased, no protocol/path, no wildcard. Reject bare apex domains per spec note "Only portal subdomain mapping supported."
- [ ] Export `CNAME_TARGET = 'portal.zync.is'` as the single source of truth for the value shown in DNS instructions.
- [ ] Implement `cnameRecord(domain: string)` returning `{ type: 'CNAME', host: domain, pointsTo: CNAME_TARGET }` for the instructions table.
**Schema / Interfaces:**
```ts
export const CNAME_TARGET = 'portal.zync.is';
export function isValidPortalSubdomain(value: string): boolean;
export function cnameRecord(domain: string): { type: 'CNAME'; host: string; pointsTo: string };
```
**Acceptance:**
- [ ] `portal.acme.com` passes; `acme.com`, `https://portal.acme.com`, `*.acme.com`, and empty string fail.

### Task 4: WhiteLabelPage shell + tier gate + Add-domain form state
**Blocks:** 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-app/src/pages/settings/WhiteLabelPage.tsx`
**Steps:**
- [ ] Build the page container: heading "Settings > White-Label" inside the settings shell. Use `Card` for each section.
- [ ] Gate with `useTierGate('enterprise')`: if not Enterprise, render the Non-Enterprise upsell card (Task 7) and stop.
- [ ] When Enterprise and `domain === null`: render the "Custom Portal Domain" card with the current portal URL line (`zync.is/portal/{tenantSlug}`), explanatory copy, a `Form` with one `Input` (placeholder `portal.acme.com`), the helper text "Only portal subdomain mapping supported.", and an `[Add domain]` `Button`.
- [ ] Wire the form: validate with `isValidPortalSubdomain`; on submit call `addDomain`. Disable the button while pending; show `FormError` for invalid input or server rejection.
- [ ] When a domain exists, delegate rendering to the appropriate state component (Tasks 5/6) based on `status`.
- [ ] Show a `Skeleton` while `isLoading`.
**Acceptance:**
- [ ] Non-Enterprise tenant sees only the upsell card; the form is never rendered.
- [ ] Submitting `acme.com` shows an inline validation error and does not call the API.
- [ ] After a successful add, the page advances to the Pending/DNS-instructions state.

### Task 5: Pending (DNS instructions) state component
**Blocks:** 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-app/src/pages/settings/components/DomainPendingState.tsx`
**Steps:**
- [ ] Render the domain row with a `Badge` "Pending DNS verification" using a clock glyph plus text (status conveyed by text + icon, never color alone).
- [ ] Render the CNAME instructions table: columns Type / Host / Points to → `CNAME` / `{domain}` / `portal.zync.is` (from `cnameRecord`).
- [ ] `[Copy CNAME value]` button copies `portal.zync.is` to clipboard; announce success via an `aria-live="polite"` region ("Copied CNAME value").
- [ ] `[I've added the record — check now]` button calls `verifyDomain(id)`; disable + show inline note when rate-limited (429).
- [ ] Render the helper paragraph: "Verification checks run every 15 minutes. TLS certificate is provisioned automatically after DNS resolves (may take up to 48 hours for DNS propagation)."
- [ ] The pending `Spinner`/animation must respect `prefers-reduced-motion` (no spin under reduced motion).
- [ ] `[Remove]` button opens the remove-confirm dialog (Task 7 shares the dialog).
**Acceptance:**
- [ ] Copy action writes `portal.zync.is` and an aria-live message is announced.
- [ ] "Check now" disables and surfaces a wait message when the endpoint returns 429.
- [ ] Under `prefers-reduced-motion: reduce`, the pending indicator does not animate.

### Task 6: Active state + Error state components
**Blocks:** 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-app/src/pages/settings/components/DomainActiveState.tsx`
- Create: `apps/zync-app/src/pages/settings/components/DomainErrorState.tsx`
**Steps:**
- [ ] Active: `Badge` "Active" (check glyph + text), TLS line "TLS: Valid · Expires {yyyy-MM}" derived from cert info if present, "Your portal is live at {domain}", and `[Open portal →]` linking to `https://{domain}`. `[Remove]` opens the confirm dialog.
- [ ] Error: `Badge` "Error" (cross glyph + text), the `error_message` (e.g. "DNS verification failed after 48 hours. CNAME not found: {domain}"), the "Common causes" bullet list (CNAME not added, propagation in progress, typo in host), and two actions: `[Show DNS instructions]` (re-renders the CNAME instructions block from Task 5) and `[Retry verification]` (calls `verifyDomain(id)`). `[Remove]` opens the confirm dialog.
- [ ] All status indicators use text + icon (color is supplementary only).
**Acceptance:**
- [ ] Active state renders the portal link to `https://{domain}` and the TLS expiry line.
- [ ] Error state shows the upstream `error_message`, the common-causes list, and both Show-instructions and Retry actions.

### Task 7: Remove-confirm dialog + Non-Enterprise upsell + Outbound Email link-out
**Blocks:** 8  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/pages/settings/components/RemoveDomainDialog.tsx`
- Create: `apps/zync-app/src/pages/settings/components/WhiteLabelUpsell.tsx`
- Create: `apps/zync-app/src/pages/settings/components/OutboundEmailSection.tsx`
**Steps:**
- [ ] RemoveDomainDialog: `Dialog` with body "Remove {domain}? Your portal will revert to zync.is/portal/{tenantSlug}. Clients who bookmarked {domain} will get a 404." Buttons `[Cancel]` and `[Remove domain]` (destructive). On confirm call `removeDomain(id)` (→ `DELETE /api/settings/domains/:id`, which upstream unprovisions the CF hostname before deleting the row); toast on success and return the page to the Add form.
- [ ] WhiteLabelUpsell: card "Custom Domain — Enterprise feature" with copy "Map your own domain (e.g. portal.acme.com) to your client portal. Requires Enterprise plan." and an `[Upgrade to Enterprise →]` button wired to `useUpgradeModal()` (open the upgrade modal targeting the Enterprise tier).
- [ ] OutboundEmailSection (Enterprise only, rendered below the domain card): card "Outbound Email" with copy "Send from your own domain instead of zync.is." and a `[Configure SMTP settings →]` button that navigates to `/settings/integrations/smtp` (owned by spec 51).
**Acceptance:**
- [ ] Confirming removal calls DELETE and the page returns to the Add-domain form.
- [ ] Upsell `[Upgrade to Enterprise →]` opens the upgrade modal.
- [ ] Outbound Email section is hidden for non-Enterprise tenants and links to `/settings/integrations/smtp`.

### Task 8: Route registration + i18n strings + RTL/CSP/a11y pass
**Blocks:** —  ·  **Blocked by:** 4, 5, 6, 7
**Files:**
- Modify: settings shell router in `apps/zync-app/src/pages/settings/` (register `/settings/white-label` → `WhiteLabelPage`, Enterprise tier flag per settings-module manifest)
- Modify: `packages/config` (or app) i18n message catalogs — add `en` and `he` strings for every label/helper/error in this page
**Steps:**
- [ ] Register the `/settings/white-label` route inside the settings shell so it renders in the settings layout with the sidebar entry already declared by `settings-module`.
- [ ] Add all visible strings to the i18n catalogs (Hebrew + English): card titles, helper texts, button labels, badge labels, remove-confirm copy, upsell copy, common-causes bullets, copy-success announcement.
- [ ] Wrap layout with direction awareness via `useDirection` so the CNAME table, badges, and buttons mirror correctly under RTL (Hebrew). Use logical CSS properties (no hardcoded left/right).
- [ ] Confirm no inline styles/scripts are introduced that would violate CSP; all clipboard access uses the standard async Clipboard API (no eval, no inline handlers).
- [ ] a11y verification: status badges expose text + icon (color-independent); the "Copy CNAME value" success is announced via `aria-live`; the pending indicator honors `prefers-reduced-motion`; the remove `Dialog` traps focus and is dismissible by Escape.
**Acceptance:**
- [ ] Navigating to `/settings/white-label` renders inside the settings shell with a working sidebar entry.
- [ ] All page text resolves in both `he` and `en`; under `he` the layout is RTL-correct.
- [ ] No CSP violations are reported in the console when adding/copying/removing a domain.
