# Tenant API Keys UI (`/settings/api-keys`) — Implementation Plan

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

## Goal
Deliver the complete tenant-app frontend for managing machine-to-machine API keys at `/settings/api-keys`: listing existing keys (active + recently-revoked), a create modal with scope selection, a one-time key-reveal modal, and a revoke confirmation. The entire data model (`tenant_api_keys`) and all CRUD endpoints (`GET/POST/DELETE /api/api-keys`) are owned and already shipped by `white-label-api` (spec 27); this spec is pure UI + client data-access wired to those contracts. No new database tables and no new API endpoints are introduced.

## Architecture
- **Consumes upstream (white-label-api / spec 27) — do not redefine:** table `tenant_api_keys` (`id, tenant_id, name, key_prefix, key_hash, scopes TEXT[], last_used_at, expires_at, created_by, created_at, revoked_at`); API routes `GET /api/api-keys` (returns prefix + scopes + status, never the full key), `POST /api/api-keys` (returns the full plaintext key exactly once), `DELETE /api/api-keys/:id` (revoke → sets `revoked_at`). Key format `zyk_live_{random32}` (total length 42). Stored as SHA-256 hash only; plaintext never retrievable after creation.
- **Consumes upstream (tenant-public-api / spec 39) — authoritative scope source:** the `ApiScope` union (`customers:read`, `customers:write`, `invoices:read`, `invoices:write`, `tasks:read`, `tasks:write`, `events:read`). White-label-api explicitly names tenant-public-api's `tenant_api_keys` as "the authoritative schema for both." Enterprise/White-label tiers additionally expose named internal scopes (`leads:write`, `campaigns:write`) for reseller automation. **The scope checkboxes are driven from `ApiScope` + the named enterprise scopes — NOT from the ASCII mock in the spec.** See Task 1 reconciliation note: the mock lists `time:*`, `expenses:*`, `projects:*`, `marketing:*` which are backed by no endpoint and no scope type; rendering them would produce checkboxes the backend cannot persist or enforce.
- **Consumes upstream (settings-module / spec 25):** the settings shell + sidebar tree. `/settings/api-keys` renders inside the settings shell layout (Business+ tier entry already declared in the Settings Navigation Manifest). This spec owns only the page body; the shell/sidebar is provided by settings-module.
- **Consumes upstream (foundation-auth-rbac):** `authMiddleware`, `requirePermission`, `hasScope`, `useTierGate`, `useUpgradeModal`, `Session` / `SessionPayload`, `RoleId`. Permission gate is `settings:write` **AND OWNER role** — API keys carry full scoped access; ADMIN must not self-issue keys (RBAC boundary). Tier gate is Business+.
- **Consumes upstream (foundation-design-system / @zync/ui):** `DataTable`, `DataTablePagination`, `Dialog`, `Button`, `Input`, `Checkbox`, `Badge`, `Form`, `FormField`, `FormLabel`, `FormError`, `Card`, `Stack`, `Divider`, `EmptyState`, `Skeleton`, `Spinner`, `Toast`/`toast`, `Tooltip`, `cn`.
- **Consumes upstream (system-i18n / rtl-hebrew-ui):** `LocaleProvider`, `useDirection`, `translations`, `SUPPORTED_LOCALES`; all labels localized (he/en), RTL-mirrored layout, `prefers-reduced-motion` honored on modal transitions.
- **Data flow:** React Query hooks (`useApiKeys`, `useCreateApiKey`, `useRevokeApiKey`) call the spec-27 Hono routes through the app's shared `apiClient`. `POST /api/api-keys` returns the plaintext key once; the mutation result feeds the reveal modal and is then discarded from memory (never persisted to query cache). Create + revoke mutations invalidate the `['api-keys']` query key. The list view applies a client-side 30-day filter on revoked keys (see Task 4 assumption note).

## Tech Stack
- **App:** `apps/zync-app` (Vite + React, Cloudflare Workers SPA). Route `/settings/api-keys` registered in the app router; pages/components under `apps/zync-app/src/features/api-keys/`.
- **Shared types:** view-model types live in `apps/zync-app/src/features/api-keys/types.ts`; the `ApiScope` union is imported from `@zync/types` (owned upstream) — not redefined.
- **Libraries:** `@tanstack/react-query` (data), `react-hook-form` + `zod` (create-form validation), `date-fns` (created/last-used date display, 30-day revoked filter), design-system primitives from `@zync/ui`, Clipboard API (`navigator.clipboard.writeText`) for copy-to-clipboard.
- **Bindings:** none new. Routes proxy to the existing `apps/zync-api` worker (spec-27 endpoints).
- **i18n:** Hebrew + English translation keys added under an `apiKeys` namespace.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11a | 1, 2 | types, scope catalog, zod schema, query/mutation hooks | Yes (1 and 2 independent) |
| 11b | 3 | shared row/badge components (StatusBadge, ScopeList, KeyPrefix) | After 1 |
| 11c | 4 | list page + tier/role gating + empty state | After 2,3 |
| 11d | 5, 6, 7 | Create modal, Key-reveal modal, Revoke confirm | After 1,2 (5/6/7 independent of each other) |
| 11e | 8 | route registration + settings-shell wiring | After 4 |
| 11f | 9, 10 | i18n/RTL/a11y pass, component tests | After 4,5,6,7,8 |

## Tasks

### Task 1: Scope catalog, view-model types & create-form validation schema
**Blocks:** 2, 3, 4, 5, 6, 7  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/features/api-keys/types.ts`
- Create: `apps/zync-app/src/features/api-keys/scopes.ts`
- Create: `apps/zync-app/src/features/api-keys/schemas.ts`
**Steps:**
- [ ] Import the authoritative `ApiScope` union from `@zync/types` (do NOT redefine it). Define the displayable scope catalog `API_KEY_SCOPE_GROUPS` driving the create-modal checkboxes, grouped by resource with read/write columns.
- [ ] **Reconciliation note (transcribe into a code comment):** The spec's ASCII mock lists scope checkboxes for `invoices`, `time`, `expenses`, `customers`, `projects`, `marketing`. The authoritative `ApiScope` union (tenant-public-api spec 39, named by white-label-api spec 27 as the authoritative schema) only defines `customers:read/write`, `invoices:read/write`, `tasks:read/write`, `events:read`. `time:*`, `expenses:*`, `projects:*` are backed by no endpoint and no scope type, and `marketing:*` only loosely maps to the named enterprise scopes `leads:write` / `campaigns:write`. Therefore render ONLY backend-supported scopes: the seven `ApiScope` values for all Business+ tiers, plus `leads:write` and `campaigns:write` shown only when the tenant tier is `enterprise` or `white_label`. Do NOT render `time:*`, `expenses:*`, `projects:*`, `marketing:*` checkboxes.
- [ ] Define `API_KEY_READ_SCOPES` (`customers:read`, `invoices:read`, `tasks:read`, `events:read`) used by the "Select all read" shortcut.
- [ ] Define `ApiKeyView` interface mirroring the `GET /api/api-keys` response (snake_case API → camelCase view-model): `id`, `name`, `keyPrefix`, `scopes`, `lastUsedAt`, `expiresAt`, `createdAt`, `revokedAt`, plus derived `status` (`'active' | 'revoked'`).
- [ ] Define the create-form zod schema: `name` required 1–64 chars (trimmed), `scopes` non-empty array of valid `ApiScope` values (at least one required), reject any scope value not present in `API_KEY_SCOPE_GROUPS`.
**Schema / Interfaces:**
```ts
// Imported, NOT redefined — owned by @zync/types (tenant-public-api spec 39):
//   type ApiScope =
//     | 'customers:read' | 'customers:write'
//     | 'invoices:read'  | 'invoices:write'
//     | 'tasks:read'     | 'tasks:write'
//     | 'events:read'
import type { ApiScope } from '@zync/types';

// Named enterprise-only scopes (white-label-api spec 27, reseller automation):
export type EnterpriseApiScope = 'leads:write' | 'campaigns:write';
export type SelectableScope = ApiScope | EnterpriseApiScope;

export interface ScopeGroup {
  resource: string;            // i18n key fragment, e.g. 'customers'
  read?: SelectableScope;      // e.g. 'customers:read'
  write?: SelectableScope;     // e.g. 'customers:write'
  enterpriseOnly?: boolean;    // gate render on tenant tier enterprise|white_label
}

// Only backend-supported scopes — mock's time/expenses/projects/marketing intentionally omitted.
export const API_KEY_SCOPE_GROUPS: ScopeGroup[] = [
  { resource: 'customers', read: 'customers:read', write: 'customers:write' },
  { resource: 'invoices',  read: 'invoices:read',  write: 'invoices:write'  },
  { resource: 'tasks',     read: 'tasks:read',     write: 'tasks:write'     },
  { resource: 'events',    read: 'events:read' },
  { resource: 'leads',      write: 'leads:write',     enterpriseOnly: true },
  { resource: 'campaigns',  write: 'campaigns:write', enterpriseOnly: true },
];

export const API_KEY_READ_SCOPES: ApiScope[] = [
  'customers:read', 'invoices:read', 'tasks:read', 'events:read',
];

export interface ApiKeyView {
  id: string;                  // uuid
  name: string;
  keyPrefix: string;           // first 8 chars, e.g. 'zyk_live'
  scopes: SelectableScope[];
  lastUsedAt: string | null;   // ISO 8601
  expiresAt: string | null;    // ISO 8601, null = never
  createdAt: string;           // ISO 8601
  revokedAt: string | null;    // ISO 8601, null = active
  status: 'active' | 'revoked';
}

// zod (apps/zync-app/src/features/api-keys/schemas.ts)
import { z } from 'zod';
const SELECTABLE = [
  'customers:read','customers:write','invoices:read','invoices:write',
  'tasks:read','tasks:write','events:read','leads:write','campaigns:write',
] as const;
export const createApiKeySchema = z.object({
  name: z.string().trim().min(1).max(64),
  scopes: z.array(z.enum(SELECTABLE)).min(1, 'errors.scopes_required'),
});
export type CreateApiKeyInput = z.infer<typeof createApiKeySchema>;
```
**Acceptance:**
- [ ] `ApiScope` is imported from `@zync/types`, never redefined locally.
- [ ] No checkbox is rendered for `time:*`, `expenses:*`, `projects:*`, or `marketing:*`; the reconciliation rationale is present as a code comment.
- [ ] `leads:write` / `campaigns:write` appear only for `enterprise` / `white_label` tiers.
- [ ] `createApiKeySchema` rejects empty `name`, `name` > 64 chars, empty `scopes`, and any scope outside the selectable set.

### Task 2: React Query data-access hooks
**Blocks:** 4, 5, 7  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/features/api-keys/api.ts`
- Create: `apps/zync-app/src/features/api-keys/hooks.ts`
**Steps:**
- [ ] In `api.ts`, implement thin `apiClient` wrappers: `fetchApiKeys()` → `GET /api/api-keys`; `createApiKey(input)` → `POST /api/api-keys`; `revokeApiKey(id)` → `DELETE /api/api-keys/:id`. Map snake_case response fields to the `ApiKeyView` camelCase view-model; derive `status` from `revokedAt`.
- [ ] `useApiKeys()` — `useQuery({ queryKey: ['api-keys'], queryFn: fetchApiKeys })`. Returns `ApiKeyView[]`.
- [ ] `useCreateApiKey()` — `useMutation` posting `CreateApiKeyInput`; on success returns `{ key: string; apiKey: ApiKeyView }` where `key` is the one-time plaintext key (`zyk_live_...`). **Do NOT write the plaintext key into the query cache.** Invalidate `['api-keys']` on settle.
- [ ] `useRevokeApiKey()` — `useMutation` calling `revokeApiKey(id)`; invalidate `['api-keys']` on success; surface `toast` on error.
- [ ] All hooks propagate the spec-27 error envelope; on `403 tier_required` map to the upsell path (Task 4 handles render), on other errors call `toast` with the localized message.
**Schema / Interfaces:**
```ts
export interface CreateApiKeyResponse {
  key: string;          // plaintext zyk_live_... — shown ONCE, never cached
  apiKey: ApiKeyView;   // the persisted row (prefix + scopes, no full key)
}
export function useApiKeys(): UseQueryResult<ApiKeyView[]>;
export function useCreateApiKey(): UseMutationResult<CreateApiKeyResponse, ApiError, CreateApiKeyInput>;
export function useRevokeApiKey(): UseMutationResult<void, ApiError, string /* keyId */>;
```
**Acceptance:**
- [ ] Plaintext key from `useCreateApiKey` is never stored in React Query cache (only returned transiently from `mutateAsync`).
- [ ] Create and revoke both invalidate `['api-keys']`.
- [ ] API errors surface a localized `toast`; `403 tier_required` is distinguishable for upsell handling.

### Task 3: Shared presentational components (status badge, scope list, key prefix, last-used)
**Blocks:** 4  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/features/api-keys/components/ApiKeyStatusBadge.tsx`
- Create: `apps/zync-app/src/features/api-keys/components/ScopeList.tsx`
- Create: `apps/zync-app/src/features/api-keys/components/KeyPrefixCell.tsx`
**Steps:**
- [ ] `ApiKeyStatusBadge` — `Badge` rendering Active (success variant, `●`) or Revoked (muted variant, `✕`) from `ApiKeyView.status`; includes `aria-label` with the localized status text (not just the glyph).
- [ ] `ScopeList` — renders the scopes array truncated to fit the column (e.g. first 2 + "+N more"), with a `Tooltip` exposing the full scope list. Localize each scope label via the `apiKeys.scopes.*` namespace; render `(all)` when scopes cover every selectable scope.
- [ ] `KeyPrefixCell` — renders `{keyPrefix}…` monospaced (e.g. `zyk_live…`), with `aria-label` "key prefix {prefix}".
- [ ] All components use design-system tokens only (no hardcoded colors/spacing) and mirror correctly under RTL via `useDirection`.
**Acceptance:**
- [ ] Status conveyed by text/`aria-label`, not color/glyph alone (a11y).
- [ ] `ScopeList` truncates with an accessible tooltip showing the full set; covers-all renders `(all)`.
- [ ] No hardcoded colors or spacing; components mirror under RTL.

### Task 4: API Keys list page with tier + OWNER-role gating
**Blocks:** 8  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-app/src/features/api-keys/ApiKeysPage.tsx`
**Steps:**
- [ ] Build the page header: title "Settings / API Keys", the explanatory text ("API keys let external integrations access Zync on your behalf. Keys are shown once — store them securely."), and the `[+ New API Key]` button.
- [ ] **Tier gate (Business+):** call `useTierGate('business')`. For `freelancer` tenants, render the upgrade prompt in place of the create button + table — wire `useUpgradeModal` (spec 33) so the prompt opens the upsell modal. Do not render the create button for freelancer.
- [ ] **OWNER-only gate (security cross-cutting):** the `[+ New API Key]` button and each row's Revoke button render only when the current `Session` role is `OWNER`. ADMIN holding `settings:write` sees the list read-only with NO create and NO revoke controls. Transcribe the rationale as a comment: API keys carry full scoped access; ADMIN self-issuing a key would bypass RBAC role boundaries. (The API also enforces OWNER per spec 27 — the UI gate is defense-in-depth, not the sole guard.)
- [ ] Render the keys table via `DataTable` with columns: Name, Prefix (`KeyPrefixCell`), Scopes (`ScopeList`), Created date, Last used date, Status (`ApiKeyStatusBadge`), Revoke action. Localize dates via the i18n date formatter (he/en).
- [ ] **Revoked-key 30-day window (assumption — write it down):** ownership of `tenant_api_keys` lifecycle is white-label-api (spec 27). Assume `GET /api/api-keys` returns active keys plus revoked keys, and apply a **client-side** filter hiding rows whose `revokedAt` is older than 30 days (`isBefore(revokedAt, subDays(now, 30))`). Revoked-but-within-30-days rows render greyed with no Revoke button. Add a `TODO-free` comment stating this is a client-side filter and that if spec 27's API already excludes >30-day-revoked keys the filter is a harmless no-op.
- [ ] Loading state: `Skeleton` rows. Error state: inline error with retry. Empty state: `EmptyState` ("No API keys yet") with the create CTA (OWNER only).
- [ ] Mount the legend "● = Active   ✕ = Revoked (shown last 30 days)" below the table, localized.
**Acceptance:**
- [ ] Freelancer tenant sees the upsell prompt (via `useUpgradeModal`), never the create button or key table create path.
- [ ] ADMIN (with `settings:write`, non-OWNER) sees the list but NO create button and NO revoke buttons.
- [ ] OWNER sees create + per-row revoke.
- [ ] Revoked keys greyed and revoke-button-less; those revoked > 30 days ago are hidden (client-side filter, documented).
- [ ] Loading/error/empty states all render; dates localized; layout mirrors under RTL.

### Task 5: Create API Key modal
**Blocks:** 6  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-app/src/features/api-keys/components/CreateApiKeyModal.tsx`
**Steps:**
- [ ] Build a `Dialog` titled "New API Key" with a `react-hook-form` form bound to `createApiKeySchema` (zodResolver).
- [ ] Name field: required `Input`, 1–64 chars, with `FormError`.
- [ ] Scopes: render `API_KEY_SCOPE_GROUPS` as a read/write checkbox grid (one row per resource). Render `enterpriseOnly` groups (`leads:write`, `campaigns:write`) only when tenant tier is `enterprise` or `white_label`. Each `Checkbox` has an associated `FormLabel` and the localized scope label.
- [ ] Shortcuts: "Select all read" → checks every scope in `API_KEY_READ_SCOPES`; "Select none" → clears all. Render as buttons/links beneath the grid.
- [ ] Validation: block submit unless name valid and ≥1 scope selected; show `FormError` "At least one scope is required" otherwise.
- [ ] On "Generate Key" submit: call `useCreateApiKey().mutateAsync(input)`; on success, close this modal and hand the returned plaintext `key` to the reveal modal (Task 6) via parent state — do not render the key here. On error, surface `FormError`/`toast`.
- [ ] Dialog a11y: focus trap, `role="dialog"`, `aria-modal`, labelled title, ESC/`Cancel` closes; honor `prefers-reduced-motion` on open/close transition.
**Acceptance:**
- [ ] Submit disabled until name (1–64) valid and ≥1 scope selected.
- [ ] "Select all read" checks exactly the four read scopes; "Select none" clears all.
- [ ] Enterprise scopes hidden for business tier, shown for enterprise/white_label.
- [ ] On success the create modal closes and passes the plaintext key to the reveal modal; key is never rendered in this modal.
- [ ] Dialog is focus-trapped, ESC-dismissable, and respects reduced-motion.

### Task 6: Key-reveal modal (shown once, dismissal-gated)
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/api-keys/components/KeyRevealModal.tsx`
**Steps:**
- [ ] Build a `Dialog` titled "Your new API key" receiving the plaintext `key` (`zyk_live_...`) as a prop from the create flow. Display it in a monospaced, selectable read-only field.
- [ ] "Copy to clipboard" button → `navigator.clipboard.writeText(key)`; on success show a transient "Copied" affordance and set internal `hasCopied = true`.
- [ ] Render the warning: "⚠ This key will not be shown again. Store it somewhere safe (e.g. your password manager or secret vault)." plus an "I've saved my key" `Checkbox`.
- [ ] **Dismissal gating (transcribe verbatim):** the `[×]` close control and the "I've saved my key — Close" button are **disabled** until "Copy to clipboard" has been clicked (`hasCopied`) OR the "I've saved my key" checkbox is ticked. Prevents accidental dismissal before the key is captured.
- [ ] **Not reopenable:** once closed, the plaintext key is dropped from component/parent state (set to `null`); there is no path to re-open the reveal modal. Add a comment: if the key is lost, the user must revoke and create a new one (per spec).
- [ ] Dialog a11y: focus trap, `role="dialog"`, `aria-modal`, the key field has an `aria-label`; honor `prefers-reduced-motion`.
**Acceptance:**
- [ ] `[×]` and "Close" are disabled until copy-clicked OR "I've saved my key" checked.
- [ ] After close, the plaintext key is cleared from state and the modal cannot be reopened.
- [ ] Copy writes the exact `zyk_live_...` value to the clipboard.
- [ ] Dialog is focus-trapped and respects reduced-motion.

### Task 7: Revoke confirmation
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/features/api-keys/components/RevokeApiKeyDialog.tsx`
**Steps:**
- [ ] Build a confirmation `Dialog` titled "Revoke API key?" showing the key name and prefix (e.g. `"Zapier Integration" (zyk_live…)`) and the warning: "Any integrations using this key will stop working immediately and cannot be undone."
- [ ] Buttons: `Cancel` (closes) and `Revoke Key` (destructive variant) → `useRevokeApiKey().mutateAsync(keyId)`; on success close, `toast` confirmation, and the list refreshes via `['api-keys']` invalidation (row greys per Task 4 rules).
- [ ] Disable the Revoke button while the mutation is in flight (`Spinner`); on error surface `toast`.
- [ ] Only reachable from a row's Revoke button, which itself is OWNER-only (Task 4).
- [ ] Dialog a11y: focus trap, `role="dialog"`, `aria-modal`, destructive action clearly labelled; honor `prefers-reduced-motion`.
**Acceptance:**
- [ ] Confirm calls `DELETE /api/api-keys/:id`; on success the row becomes greyed/revoked and the revoke button disappears.
- [ ] Revoke button shows a loading state and is disabled during the request.
- [ ] Cancel closes without mutating; dialog is focus-trapped and respects reduced-motion.

### Task 8: Route registration & settings-shell wiring
**Blocks:** 9  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-app/src/router.tsx` (or the app's route manifest)
- Modify: `apps/zync-app/src/features/settings/settingsNav.ts` (settings sidebar entry, if not already declared by settings-module)
**Steps:**
- [ ] Register the `/settings/api-keys` route rendering `ApiKeysPage` inside the settings shell layout (provided by settings-module). The route is guarded by `authMiddleware` + `requirePermission('settings:write')` at the app-route level; finer OWNER/tier gating is applied inside the page (Task 4).
- [ ] Ensure the settings sidebar shows the "API Keys" entry under the Business+ section (the Settings Navigation Manifest already lists `/settings/api-keys` as Business+ / spec 60 — confirm it points to this page; add it if absent).
- [ ] Confirm deep-linking to `/settings/api-keys` as a non-OWNER lands on the read-only list (not a hard 403), and as a freelancer lands on the upsell prompt.
**Acceptance:**
- [ ] `/settings/api-keys` renders `ApiKeysPage` within the settings shell.
- [ ] Sidebar entry present under Business+; route requires authentication + `settings:write`.
- [ ] Non-OWNER and freelancer deep-links resolve to read-only/upsell respectively (no raw 403 page).

### Task 9: i18n keys, RTL & reduced-motion pass
**Blocks:** —  ·  **Blocked by:** 4, 5, 6, 7, 8
**Files:**
- Modify: `apps/zync-app/src/i18n/en/apiKeys.json` (create if absent)
- Modify: `apps/zync-app/src/i18n/he/apiKeys.json` (create if absent)
**Steps:**
- [ ] Add the `apiKeys` namespace for both `en` and `he`: page title, intro text, column headers (Name, Prefix, Scopes, Created, Last used, Status, Revoke), legend, scope labels (one per selectable scope + `(all)`), the three modal titles and bodies, the dismissal warning, the revoke warning, "Select all read", "Select none", empty-state, and the freelancer upsell copy.
- [ ] Verify all user-facing strings route through `translations` (no hardcoded English in components).
- [ ] Verify the page, table, and all three dialogs mirror correctly under RTL (`useDirection`) — checkbox grid, action buttons, and the prefix/scope cells.
- [ ] Verify every modal open/close transition is gated on `prefers-reduced-motion`.
**Acceptance:**
- [ ] Both `en` and `he` `apiKeys` namespaces are complete; no hardcoded user-facing strings remain.
- [ ] Full RTL mirroring verified for Hebrew locale.
- [ ] All modal transitions respect `prefers-reduced-motion`.

### Task 10: Component tests
**Blocks:** —  ·  **Blocked by:** 4, 5, 6, 7
**Files:**
- Create: `apps/zync-app/src/features/api-keys/__tests__/ApiKeysPage.test.tsx`
- Create: `apps/zync-app/src/features/api-keys/__tests__/CreateApiKeyModal.test.tsx`
- Create: `apps/zync-app/src/features/api-keys/__tests__/KeyRevealModal.test.tsx`
- Create: `apps/zync-app/src/features/api-keys/__tests__/RevokeApiKeyDialog.test.tsx`
**Steps:**
- [ ] `ApiKeysPage`: freelancer → upsell; ADMIN(non-OWNER) → no create/no revoke controls; OWNER → create + revoke present; revoked-within-30-days greyed, revoked->30-days hidden; loading/empty/error states render.
- [ ] `CreateApiKeyModal`: submit disabled until valid name + ≥1 scope; "Select all read" checks exactly the four read scopes; "Select none" clears; enterprise scopes hidden on business tier and shown on enterprise; success hands plaintext key to the reveal flow; no `time:*`/`expenses:*`/`projects:*`/`marketing:*` checkbox is rendered.
- [ ] `KeyRevealModal`: `[×]`/Close disabled until copy clicked OR "I've saved my key" checked; after close the key is cleared and cannot be reopened; copy writes the exact key.
- [ ] `RevokeApiKeyDialog`: confirm calls revoke + invalidates; cancel does not mutate; revoke button disabled while in flight.
**Acceptance:**
- [ ] All four test files pass and assert the security/dismissal/tier/scope-reconciliation behaviors above.
