# Admin Dashboard (System Admin Control Plane) — Implementation Plan

**Spec:** docs/specs/2026-05-30-admin-dashboard.md  ·  **Slug:** admin-dashboard  ·  **Wave:** 3
**Depends on:** foundation-auth-rbac, foundation-design-system, system-i18n

## Goal
Deliver a fully isolated system-admin control plane at `admin.zync.is` for Zync staff to manage tenants, tenant users, tenant roles, admin-plane roles, platform stats, and statutory tax rates. The plane has its own React SPA (`apps/zync-admin`), its own admin session type (`session.type === 'admin'`, no tenant `tid`), and its own RBAC (`admin_roles`) distinct from tenant RBAC. It introduces two new tables (`admin_roles`, `tax_rates`), extends the upstream `admin_users` table with a `role_id`, and ships the `getTaxRate` lookup helper consumed downstream by reports-analytics and contractor-payouts.

## Architecture
- **New app `apps/zync-admin`** — Vite + React SPA on Cloudflare Workers, deployed to `admin.zync.is`. Shares `@zync/ui`, `@zync/types`, `@zync/auth`, `@zync/db`. Bundle isolated from `zync-app` to minimize attack surface.
- **Admin API surface** lives in `zync-api` under `/api/admin/*`, gated by the upstream `requireAdminSession()` middleware (checks `session.type === 'admin'` AND `session.totp_verified === true`) plus a new `requireAdminPermission(perm)` middleware that checks the admin role's permission array.
- **Auth** reuses upstream admin auth (`signAdminSession`, `verifyAdminSession`, `AdminSessionPayload`, `requireAdminSession`, `POST /api/admin/auth/*` routes already defined in foundation-auth-rbac). This plan adds the admin RBAC layer (`admin_roles`, `admin_users.role_id`) and the role-permission resolution that populates `AdminSessionPayload.permissions`.
- **Upstream tables consumed:** `admin_users` (extended here with `role_id`), `tenants`, `tenant_memberships` (writes `status`/`freeze_reason`), `users`, `roles`, `permissions`, `role_permissions`, `invitations`, `refresh_tokens` (revoked on freeze), `usage_counters` (services log), `vat_rates` (VAT section, read-only + add-VAT writes via upstream).
- **Upstream exports consumed:** `requireAdminSession`, `signAdminSession`, `verifyAdminSession`, `AdminSessionPayload`, `verifyPassword`, `revokeAllTrustedDevicesForTenant`, `getVatRate`, `vatRates`, `DB`, `createDb`, `buildPaginated`, `PaginatedResponse`, `PaginationParams`, `clampLimit`, `getMaxTeamMembers`, plus UI primitives `DataTable`, `DataTablePagination`, `StatCard`, `Dialog`, `Form`, `FormField`, `Select`, `Input`, `Switch`, `Checkbox`, `Button`, `Badge`, `Tabs`, `Breadcrumb`, `EmptyState`, `ErrorPage`, `Toaster`, `toast`, `LocaleProvider`, `useDirection`.
- **New tables:** `admin_roles`, `tax_rates`. **Modified table:** `admin_users` (+`role_id`).
- **Data flow (freeze):** UI action → `POST /api/admin/tenants/:slug/users/:id/freeze {reason}` → set `tenant_memberships.status='frozen'`, store `freeze_reason`, bump `user_version` (immediate JWT invalidation), revoke that user's `refresh_tokens` for the tenant, write admin audit row.
- **Tax rates:** `tax_rates` is the source of truth for non-VAT statutory rates; VAT continues to live in `vat_rates`. `getTaxRate(db, country, type, date)` resolves the most recent effective rate ≤ date.

## Tech Stack
- **Apps:** new `apps/zync-admin` (Vite 5 + React 18 + TanStack Query + react-router); routes in existing `apps/zync-api` (Hono) under `/api/admin/*`.
- **Packages:** `@zync/db` (Drizzle schema + queries: `admin_roles`, `taxRates`, `getTaxRate`, admin/tenant management queries), `@zync/auth` (`requireAdminPermission`, admin-role resolution), `@zync/types` (admin permission constants, tax-type constants), `@zync/ui` (reused primitives + admin i18n keys).
- **DB:** Neon Postgres via Cloudflare Hyperdrive. Drizzle ORM. UUID PKs, TIMESTAMPTZ, JSONB, CHECK enums.
- **Bindings (zync-api):** `HYPERDRIVE` (Postgres), `KV` (user_version cache, admin audit IP context), `ADMIN_ENCRYPTION_KEY` secret (existing).
- **Bindings (zync-admin worker):** static asset serving + same-origin proxy to `zync-api` for `/api/admin/*`.
- **i18n:** all admin UI strings via `t('admin.*')` keys added to `en.json` and `he.json` (5 Hebrew plural forms for any count key). RTL via `useDirection`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 3a — schema & types | 1, 2 | `packages/db/src/schema/admin.ts`, `packages/db/src/schema/tax.ts`, `packages/types/src/admin.ts` | Tasks 1 and 2 parallel |
| 3b — db queries & seeds | 3, 4, 5 | `packages/db/src/queries/{tax,admin-roles,admin-tenants}.ts`, `packages/db/seeds/*` | Parallel after 1–2 |
| 3c — auth middleware | 6 | `packages/auth/src/admin-permissions.ts` | After 2 |
| 3d — admin API routes | 7, 8, 9, 10, 11 | `apps/zync-api/src/routes/admin/*` | After 3b/3c; routes parallel among themselves |
| 3e — admin app scaffold | 12, 13 | `apps/zync-admin/*` | After 6 (auth client) |
| 3f — admin UI pages | 14, 15, 16, 17, 18, 19 | `apps/zync-admin/src/pages/*` | After 12–13 + their API route; pages parallel |
| 3g — i18n & a11y pass | 20 | `packages/ui/src/i18n/*`, page files | Last |

## Tasks

### Task 1: Tax rates schema, seed data, and lookup helper
**Blocks:** 3, 11, 18  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/tax.ts`
- Create: `packages/db/src/queries/tax.ts`
- Create: `packages/db/seeds/tax-rates.ts`
- Modify: `packages/db/src/schema/index.ts` (export `taxRates`)
- Modify: `packages/db/src/index.ts` (re-export `getTaxRate`)
**Steps:**
- [ ] Define the `tax_rates` Drizzle table per the DDL below; PK `id UUID DEFAULT gen_random_uuid()`, FK `created_by` → `admin_users(id)`.
- [ ] Add the `UNIQUE (country_code, tax_type, effective_from)` constraint.
- [ ] Implement `getTaxRate(db, countryCode, taxType, date)` exactly as the signature below: most-recent `effective_from <= date`, returns `Number(row?.rate ?? 0)`.
- [ ] Seed the IL non-VAT rates from the spec table (corporate income 0.23 from 2018-01-01; six personal brackets with `threshold_ils` ceilings; withholding_default 0.25). Brackets 1–6 thresholds: 81480, 116760, 187440, 260520, 557580, NULL (highest).
- [ ] Do NOT seed VAT here — VAT stays in upstream `vat_rates`.
- [ ] Export `taxRates` from schema index and `getTaxRate` from package root.
**Schema / Interfaces:**
```sql
CREATE TABLE tax_rates (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  country_code   TEXT NOT NULL,
  tax_type       TEXT NOT NULL CHECK (tax_type IN (
                   'corporate_income',
                   'personal_bracket_1','personal_bracket_2','personal_bracket_3',
                   'personal_bracket_4','personal_bracket_5','personal_bracket_6',
                   'withholding_default')),
  rate           NUMERIC(6,4) NOT NULL,        -- decimal fraction, e.g. 0.2300 for 23%
  effective_from DATE NOT NULL,
  threshold_ils  NUMERIC,                       -- bracket income ceiling ILS/yr; NULL = highest bracket
  notes          TEXT,
  created_by     UUID NOT NULL REFERENCES admin_users(id),
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (country_code, tax_type, effective_from)
);
```
```ts
// packages/db/src/queries/tax.ts
import { and, eq, lte, desc } from 'drizzle-orm'
import type { DB } from '../src'
import { taxRates } from '../src/schema/tax'

export async function getTaxRate(
  db: DB, countryCode: string, taxType: string, date: Date,
): Promise<number> {
  const row = await db
    .select({ rate: taxRates.rate })
    .from(taxRates)
    .where(and(
      eq(taxRates.countryCode, countryCode),
      eq(taxRates.taxType, taxType),
      lte(taxRates.effectiveFrom, date),
    ))
    .orderBy(desc(taxRates.effectiveFrom))
    .limit(1)
  return Number(row[0]?.rate ?? 0)
}
```
**Acceptance:**
- [ ] `drizzle-kit` generates a migration creating `tax_rates` with the CHECK enum and UNIQUE constraint.
- [ ] Seed inserts 8 IL rows (corporate + 6 brackets + withholding); `getTaxRate(db,'IL','corporate_income',new Date('2024-01-01'))` returns `0.23`.
- [ ] `getTaxRate` returns `0` when no matching effective rate exists.

### Task 2: Admin RBAC schema + admin permission/type constants
**Blocks:** 3, 4, 6, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/admin.ts`
- Modify: `packages/db/src/schema/index.ts` (export `adminRoles`; re-export upstream `adminUsers`)
- Create: `packages/types/src/admin.ts`
- Modify: `packages/types/src/index.ts` (export admin constants/types)
**Steps:**
- [ ] Create the `admin_roles` Drizzle table per the DDL below (`permissions TEXT[]`, `is_system_role BOOLEAN`).
- [ ] Add a migration that ALTERs the upstream `admin_users` table to add `role_id UUID REFERENCES admin_roles(id)` (nullable initially to permit seeding order, then backfilled to the `SUPER_ADMIN` role and set NOT NULL in the same migration after seed insert — see Task 4).
- [ ] Define `ADMIN_PERMISSIONS` (the 9 admin permission keys) and `AdminPermission` type in `@zync/types`.
- [ ] Define `ADMIN_TAX_TYPES` constant list and `TaxType` union type.
- [ ] Define `DEFAULT_ADMIN_ROLES` constant (SUPER_ADMIN / SUPPORT / BILLING with their permission arrays) consumed by the seed.
**Schema / Interfaces:**
```sql
CREATE TABLE admin_roles (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name           TEXT NOT NULL UNIQUE,
  permissions    TEXT[] NOT NULL DEFAULT '{}',
  is_system_role BOOLEAN NOT NULL DEFAULT false,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Modify upstream admin_users (defined in foundation-auth-rbac):
ALTER TABLE admin_users
  ADD COLUMN role_id UUID REFERENCES admin_roles(id);
-- After seeding SUPER_ADMIN + backfilling existing admins:
-- ALTER TABLE admin_users ALTER COLUMN role_id SET NOT NULL;
```
```ts
// packages/types/src/admin.ts
export const ADMIN_PERMISSIONS = [
  'admin.tenants:read', 'admin.tenants:write',
  'admin.users:read',   'admin.users:write',
  'admin.billing:read', 'admin.billing:write',
  'admin.roles:manage',
  'admin.audit:read',
  'admin.analytics:read',
] as const
export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number]

export const ADMIN_TAX_TYPES = [
  'corporate_income',
  'personal_bracket_1','personal_bracket_2','personal_bracket_3',
  'personal_bracket_4','personal_bracket_5','personal_bracket_6',
  'withholding_default',
] as const
export type TaxType = (typeof ADMIN_TAX_TYPES)[number]

export const DEFAULT_ADMIN_ROLES = [
  { name: 'SUPER_ADMIN', isSystemRole: true, permissions: [...ADMIN_PERMISSIONS] },
  { name: 'SUPPORT',     isSystemRole: true, permissions: ['admin.tenants:read','admin.users:read'] },
  { name: 'BILLING',     isSystemRole: true, permissions: ['admin.tenants:read','admin.billing:read','admin.billing:write'] },
] as const
```
**Acceptance:**
- [ ] Migration creates `admin_roles` and adds `admin_users.role_id` FK.
- [ ] `ADMIN_PERMISSIONS` exports all 9 keys; `ADMIN_TAX_TYPES` exports all 8 types.
- [ ] Types compile and are re-exported from `@zync/types`.

### Task 3: Tax rates admin queries (list + add with overlap/immutability rules)
**Blocks:** 11  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `packages/db/src/queries/tax.ts`
**Steps:**
- [ ] `listTaxRates(db, countryCode)` — returns all `tax_rates` rows for a country, ordered by `tax_type` then `effective_from DESC`, joined to `admin_users.email` as `addedByEmail`.
- [ ] `addTaxRate(db, input)` — insert; validate (a) no existing rate of same `(country_code, tax_type)` with the same or overlapping `effective_from` (UNIQUE enforces equality; reject if an existing row has `effective_from` equal — surface 409), (b) `threshold_ils` required for `personal_bracket_1..5`, NULL allowed only for `personal_bracket_6` and non-bracket types.
- [ ] Immutability guard helper `assertTaxRateMutable(effectiveFrom)` — throws if `effective_from <= today` (cannot edit/delete past rates; corrections are new future entries).
- [ ] `listVatRatesForAdmin(db, countryCode)` — read-only projection of upstream `vatRates` for the VAT section.
**Schema / Interfaces:**
```ts
export interface AddTaxRateInput {
  countryCode: string
  taxType: TaxType
  rate: number            // decimal fraction
  effectiveFrom: string   // ISO date
  thresholdIls: number | null
  notes: string | null
  createdBy: string       // admin_users.id
}
export async function listTaxRates(db: DB, countryCode: string): Promise<TaxRateRow[]>
export async function addTaxRate(db: DB, input: AddTaxRateInput): Promise<TaxRateRow>
export function assertTaxRateMutable(effectiveFrom: Date): void  // throws TaxRateImmutableError
export async function listVatRatesForAdmin(db: DB, countryCode: string): Promise<VatRateRow[]>
```
**Acceptance:**
- [ ] Adding a rate with an `effective_from` that duplicates an existing `(country, type)` entry raises a 409-mappable error.
- [ ] `addTaxRate` for a future date succeeds; for a past/today date the immutability guard rejects edits/deletes.
- [ ] `listTaxRates` returns rows grouped-sortable by type and reverse-chronological within type.

### Task 4: Admin roles seed + admin user backfill
**Blocks:** 6, 10  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/seeds/admin-roles.ts`
- Modify: `packages/db/seeds/index.ts` (register seed; run before tax-rates seed so `created_by` FK can reference a seeded admin if needed)
**Steps:**
- [ ] Seed the three system admin roles from `DEFAULT_ADMIN_ROLES` (idempotent upsert on `name`).
- [ ] Backfill any existing `admin_users.role_id IS NULL` to the `SUPER_ADMIN` role id.
- [ ] Emit the follow-up `ALTER TABLE admin_users ALTER COLUMN role_id SET NOT NULL` only after backfill (guard so re-running is safe).
**Acceptance:**
- [ ] After seed, `admin_roles` has 3 rows with `is_system_role = true`.
- [ ] Every `admin_users` row has a non-null `role_id`.

### Task 5: Admin tenant-management queries
**Blocks:** 7, 8, 9, 12  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/src/queries/admin-tenants.ts`
- Create: `packages/db/src/queries/admin-stats.ts`
**Steps:**
- [ ] `getPlatformStats(db)` — aggregate: total active users, pending approvals across tenants (`tenant_memberships.status` pending — note upstream membership status is `'active'|'frozen'`; pending approvals are derived from `invitations` with `accepted_at NULL` plus memberships awaiting approval where tenant `require_approval`), active users last-30d, frozen users (`tenant_memberships.status='frozen'`), open invitations (`invitations.accepted_at IS NULL AND expires_at > now()`), tenant counts grouped by `tenants.tier`.
- [ ] `listTenantsAdmin(db, {search, tier, status, sort, page, limit})` — paginated; columns name, slug, tier, user count, join date (`created_at`), LTV (sum of paid amounts — left join billing/payments source; where unavailable in wave 3, compute as 0 and mark TODO-free by returning `ltv: 0` with a `ltvAvailable: false` flag), status. Uses upstream `buildPaginated` + `clampLimit`.
- [ ] `getTenantDetailAdmin(db, slug)` — tenant metadata, tier, country, active/frozen/pending counts.
- [ ] `listTenantUsersAdmin(db, slug, {page, limit=50})` — join `users` + `tenant_memberships` + `roles`: name, email, role name, status, last login, joined.
- [ ] `listTenantRolesAdmin(db, slug)` — all `roles` for tenant with their `role_permissions`→`permissions.key` arrays; flag `is_system_role`.
**Schema / Interfaces:**
```ts
export interface PlatformStats {
  totalActiveUsers: number
  pendingApprovals: number
  activeUsers30d: number
  frozenUsers: number
  openInvitations: number
  tenantsByTier: Record<'freelancer'|'business'|'enterprise'|'white_label', number>
}
export interface AdminTenantListParams extends PaginationParams {
  search?: string; tier?: TenantTier; status?: 'active'|'suspended';
  sort?: 'name'|'created_at'|'tier'|'status'
}
export async function getPlatformStats(db: DB): Promise<PlatformStats>
export async function listTenantsAdmin(db: DB, p: AdminTenantListParams): Promise<PaginatedResponse<AdminTenantRow>>
export async function getTenantDetailAdmin(db: DB, slug: string): Promise<AdminTenantDetail | null>
export async function listTenantUsersAdmin(db: DB, slug: string, p: PaginationParams): Promise<PaginatedResponse<AdminTenantUserRow>>
export async function listTenantRolesAdmin(db: DB, slug: string): Promise<AdminTenantRoleRow[]>
```
**Acceptance:**
- [ ] `getPlatformStats` returns all six metric fields and a per-tier tenant count map.
- [ ] `listTenantsAdmin` honors search (name/slug ILIKE), tier/status filters, sort, and pagination at default limit.
- [ ] `listTenantUsersAdmin` paginates at 50/page and returns role name + membership status.

### Task 6: requireAdminPermission middleware + admin role resolution
**Blocks:** 7, 8, 9, 10, 11  ·  **Blocked by:** 2, 4
**Files:**
- Create: `packages/auth/src/admin-permissions.ts`
- Modify: `packages/auth/src/index.ts` (export `requireAdminPermission`, `resolveAdminPermissions`)
**Steps:**
- [ ] `resolveAdminPermissions(db, adminUserId)` — join `admin_users` → `admin_roles`, return the `permissions` array; this is called when minting the admin session so `AdminSessionPayload.permissions` carries them.
- [ ] `requireAdminPermission(perm: AdminPermission)` — Hono middleware that reads the admin session (already validated by upstream `requireAdminSession`) and returns 403 if `session.permissions` does not include `perm`. Uses constant-time membership where relevant (array includes is fine; tokens compared via upstream `timingSafeEqual`).
- [ ] Document that all `/api/admin/*` mutation routes chain `requireAdminSession()` then `requireAdminPermission(perm)`.
**Schema / Interfaces:**
```ts
import type { AdminPermission } from '@zync/types'
export async function resolveAdminPermissions(db: DB, adminUserId: string): Promise<AdminPermission[]>
export function requireAdminPermission(perm: AdminPermission): MiddlewareHandler
```
**Acceptance:**
- [ ] A SUPPORT-role admin hitting an `admin.tenants:write` route gets 403; SUPER_ADMIN passes.
- [ ] `AdminSessionPayload.permissions` is populated from `admin_roles.permissions` at session mint time.

### Task 7: Admin stats + tenants list/detail API routes
**Blocks:** 14, 15, 16  ·  **Blocked by:** 5, 6
**Files:**
- Create: `apps/zync-api/src/routes/admin/stats.ts`
- Create: `apps/zync-api/src/routes/admin/tenants.ts`
- Modify: `apps/zync-api/src/routes/admin/index.ts` (mount)
**Steps:**
- [ ] `GET /api/admin/stats` → `requireAdminSession` + `requireAdminPermission('admin.tenants:read')` → `getPlatformStats`. Include the recent-activity feed: last 20 system-level audit rows.
- [ ] `GET /api/admin/tenants` → `admin.tenants:read` → `listTenantsAdmin` with zod-validated query (`search`, `tier`, `status`, `sort`, `page`, `limit`).
- [ ] `GET /api/admin/tenants/:slug` → `admin.tenants:read` → `getTenantDetailAdmin`; 404 if null. Include tier-history timeline (from admin audit / tier-change rows).
- [ ] Enforce: no route accepts `tenantId` from body/query — always resolve from `:slug` path param.
- [ ] Validate `Origin` is `https://admin.zync.is` for any mutation (none here; documented).
- [ ] Wrap responses in upstream `PaginatedResponse` shape where paginated.
**Schema / Interfaces:**
```
GET /api/admin/stats                  → { stats: PlatformStats, recentActivity: AdminAuditRow[] }
GET /api/admin/tenants?search&tier&status&sort&page&limit → PaginatedResponse<AdminTenantRow>
GET /api/admin/tenants/:slug          → AdminTenantDetail (404 if not found)
```
**Acceptance:**
- [ ] All three routes require a valid admin session; anonymous → 401, wrong permission → 403.
- [ ] Query params are zod-validated; invalid `tier` → 400.
- [ ] `:slug` is the only tenant selector; supplying `tenantId` in body is ignored.

### Task 8: Admin tenant user-management API routes (approve/freeze/unfreeze, list)
**Blocks:** 17  ·  **Blocked by:** 5, 6
**Files:**
- Create: `apps/zync-api/src/routes/admin/tenant-users.ts`
- Modify: `apps/zync-api/src/routes/admin/index.ts`
**Steps:**
- [ ] `GET /api/admin/tenants/:slug/users` → `admin.users:read` → `listTenantUsersAdmin` (50/page).
- [ ] `POST /api/admin/tenants/:slug/users/:id/approve` → `admin.users:write` → set membership `status='active'` for a pending member; write admin audit row; (notify user — best effort via upstream notifications).
- [ ] `POST /api/admin/tenants/:slug/users/:id/freeze` body `{reason: string}` → `admin.users:write` → set `tenant_memberships.status='frozen'`, store `freeze_reason`, bump user_version (immediate JWT invalidation), revoke that user's `refresh_tokens` for the tenant; write admin audit row with actor/IP. Zod-validate `reason` non-empty.
- [ ] `POST /api/admin/tenants/:slug/users/:id/unfreeze` → `admin.users:write` → set membership `status='active'`, clear `freeze_reason`; audit.
- [ ] Revoke invitation: `DELETE`-style handled here or via roles route — implement `POST` revoke on pending `invitations` (set `accepted_at` sentinel or delete pending row) gated by `admin.users:write`.
- [ ] Every mutation logs an admin audit row (actor admin id, action, target user id, timestamp, IP from `CF-Connecting-IP`).
**Schema / Interfaces:**
```
GET  /api/admin/tenants/:slug/users             → PaginatedResponse<AdminTenantUserRow>  (admin.users:read)
POST /api/admin/tenants/:slug/users/:id/approve  → 204                                    (admin.users:write)
POST /api/admin/tenants/:slug/users/:id/freeze   body {reason} → 204                      (admin.users:write)
POST /api/admin/tenants/:slug/users/:id/unfreeze → 204                                     (admin.users:write)
```
**Acceptance:**
- [ ] Freeze sets `tenant_memberships.status='frozen'` + `freeze_reason`, revokes the user's refresh tokens, and bumps user_version.
- [ ] Empty/missing `reason` → 400.
- [ ] Each mutation produces an admin audit row capturing actor, action, target, IP.

### Task 9: Admin tenant audit + tenant roles API routes
**Blocks:** 17, 19  ·  **Blocked by:** 5, 6
**Files:**
- Create: `apps/zync-api/src/routes/admin/tenant-audit.ts`
- Create: `apps/zync-api/src/routes/admin/tenant-roles.ts`
- Modify: `apps/zync-api/src/routes/admin/index.ts`
**Steps:**
- [ ] `GET /api/admin/tenants/:slug/audit` → `admin.audit:read` → filterable log with three sections: Payments (billing events), Tier History (tier changes: date/prev/new/actor), Services Log (per-month metered consumption from `usage_counters`: ocr_uploads/ai_chat_tokens/mailing_credits/storage/email_deliveries). Accept query filters `section`, `from`, `to`, `page`.
- [ ] `GET /api/admin/tenants/:slug/roles` → `admin.tenants:read` → `listTenantRolesAdmin` (permission matrix data).
- [ ] `PATCH /api/admin/tenants/:slug/roles/:id` → `admin.tenants:write` → edit custom role's permission set (reject if `is_system_role`); zod-validate permission keys against `permissions` table.
- [ ] `POST /api/admin/tenants/:slug/roles` → `admin.tenants:write` → create custom role (name + permission keys).
- [ ] `DELETE /api/admin/tenants/:slug/roles/:id` → `admin.tenants:write` → delete custom role; reject if any membership still references it (409: reassign users first); reject system roles.
- [ ] System roles (OWNER/ADMIN/MEMBER/VIEWER/CONTRACTOR) are view-only from admin plane.
**Schema / Interfaces:**
```
GET    /api/admin/tenants/:slug/audit?section&from&to&page → { payments, tierHistory, servicesLog }
GET    /api/admin/tenants/:slug/roles      → AdminTenantRoleRow[]
PATCH  /api/admin/tenants/:slug/roles/:id  body {permissions: string[]}  → 200
POST   /api/admin/tenants/:slug/roles      body {name, permissions}      → 201
DELETE /api/admin/tenants/:slug/roles/:id  → 204 | 409 (role in use / system role)
```
**Acceptance:**
- [ ] Audit endpoint returns the three sections; services log reads from `usage_counters` per month.
- [ ] Editing or deleting a system role returns 409/403; custom role edits validate permission keys.
- [ ] Deleting a role still assigned to a membership returns 409.

### Task 10: Admin-plane roles API routes
**Blocks:** 19  ·  **Blocked by:** 2, 6
**Files:**
- Create: `apps/zync-api/src/routes/admin/admin-roles.ts`
- Create: `packages/db/src/queries/admin-roles.ts`
- Modify: `apps/zync-api/src/routes/admin/index.ts`
**Steps:**
- [ ] Queries: `listAdminRoles(db)`, `createAdminRole(db,{name,permissions})`, `updateAdminRole(db,id,{permissions})`, `deleteAdminRole(db,id)` (reject system roles; reject if any `admin_users.role_id` references it).
- [ ] `GET /api/admin/roles` → `admin.roles:manage` → `listAdminRoles`.
- [ ] `PATCH /api/admin/roles/:id` → `admin.roles:manage` → update permissions; reject `is_system_role`; validate each key ∈ `ADMIN_PERMISSIONS`.
- [ ] `POST /api/admin/roles` → `admin.roles:manage` → create; validate keys.
- [ ] `DELETE /api/admin/roles/:id` → `admin.roles:manage` → delete custom admin role; 409 if in use or system.
- [ ] Audit every mutation.
**Schema / Interfaces:**
```
GET    /api/admin/roles      → AdminRoleRow[]                           (admin.roles:manage)
PATCH  /api/admin/roles/:id  body {permissions: AdminPermission[]}      (admin.roles:manage)
POST   /api/admin/roles      body {name, permissions}                   (admin.roles:manage)
DELETE /api/admin/roles/:id  → 204 | 409                                (admin.roles:manage)
```
**Acceptance:**
- [ ] Permission keys outside `ADMIN_PERMISSIONS` → 400.
- [ ] System admin roles cannot be edited/deleted; in-use roles return 409.

### Task 11: Admin tax-rates + reports/analytics API routes
**Blocks:** 18  ·  **Blocked by:** 1, 3, 6
**Files:**
- Create: `apps/zync-api/src/routes/admin/tax-rates.ts`
- Create: `apps/zync-api/src/routes/admin/reports.ts`
- Modify: `apps/zync-api/src/routes/admin/index.ts`
**Steps:**
- [ ] `GET /api/admin/tax-rates?country=IL` → `admin.tenants:read` (tax changes are SUPER_ADMIN-gated on write) → return non-VAT `tax_rates` grouped by country+type plus the read-only VAT section from `listVatRatesForAdmin`.
- [ ] `POST /api/admin/tax-rates` → gate on SUPER_ADMIN (only role holding all permissions; enforce by requiring `admin.roles:manage` AND `admin.billing:write`, or add an explicit `admin.tax:write` check — use SUPER_ADMIN membership check via `resolveAdminPermissions` containing the full set) → `addTaxRate` with zod validation: `taxType ∈ ADMIN_TAX_TYPES`, `rate` 0–1, `effectiveFrom` valid date (future allowed), `thresholdIls` required for `personal_bracket_1..5`. Enforce overlap/immutability rules from Task 3. If `taxType` resolves to a VAT add, route to upstream `vat_rates` instead.
- [ ] `GET /api/admin/reports` → `admin.analytics:read` → platform-level reports (counts/sums; stub aggregation surface that downstream admin-reports-analytics spec extends).
- [ ] `GET /api/admin/analytics` → `admin.analytics:read` → platform analytics.
- [ ] Audit `POST /tax-rates` (actor, action, country/type/effective_from, IP).
**Schema / Interfaces:**
```
GET  /api/admin/tax-rates?country=IL → { vat: VatRateRow[], rates: Record<TaxType, TaxRateRow[]> }
POST /api/admin/tax-rates  body {countryCode, taxType, rate, effectiveFrom, thresholdIls?, notes?} → 201 | 409
GET  /api/admin/reports   → platform report payload   (admin.analytics:read)
GET  /api/admin/analytics → platform analytics payload (admin.analytics:read)
```
**Acceptance:**
- [ ] Non-SUPER_ADMIN POST to `/api/admin/tax-rates` → 403.
- [ ] Adding a bracket without `thresholdIls` (brackets 1–5) → 400; duplicate effective_from → 409.
- [ ] GET groups rates by country+type and includes a read-only VAT section.

### Task 12: zync-admin app scaffold (Vite + Worker + admin auth client + shell)
**Blocks:** 14, 15, 16, 17, 18, 19  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-admin/package.json`
- Create: `apps/zync-admin/vite.config.ts`
- Create: `apps/zync-admin/wrangler.toml`
- Create: `apps/zync-admin/src/main.tsx`
- Create: `apps/zync-admin/src/App.tsx`
- Create: `apps/zync-admin/src/lib/admin-api.ts`
- Create: `apps/zync-admin/src/components/AdminShell.tsx`
- Create: `apps/zync-admin/src/components/AdminAuthGuard.tsx`
- Modify: `pnpm-workspace.yaml` / `turbo.json` (register app)
**Steps:**
- [ ] Scaffold Vite React SPA; add `@zync/ui`, `@zync/types`, `@zync/auth` workspace deps.
- [ ] `wrangler.toml`: Worker serving static assets to `admin.zync.is`; same-origin fetch to `zync-api` for `/api/admin/*` (cookies scoped to `admin.zync.is`, `SameSite=Strict`).
- [ ] `admin-api.ts`: typed fetch client; sends credentials, sets `Origin: https://admin.zync.is` implicitly via browser; throws on 401 → redirect `/admin/login`, 403 → toast forbidden.
- [ ] `AdminAuthGuard`: on mount calls an admin `me`/session check; if session invalid or `totp_verified !== true`, redirect to `/admin/login`. (Login/TOTP flow itself is upstream foundation-auth-rbac `POST /api/admin/auth/*`.)
- [ ] `AdminShell`: left nav matching spec (Overview, Reports, Analytics, Tenants, Roles, Tax Rates) using `@zync/ui` primitives; wrap in `LocaleProvider` + `Toaster`; apply `useDirection` for RTL.
- [ ] react-router routes for `/admin/overview`, `/admin/reports`, `/admin/analytics`, `/admin/tenants`, `/admin/tenants/:slug/*`, `/admin/roles`, `/admin/tax-rates`, `/admin/login`.
- [ ] Add CSP meta/headers: restrict to self + `api.zync.is`; no inline scripts.
**Acceptance:**
- [ ] `pnpm --filter zync-admin build` produces a Worker bundle.
- [ ] Unauthenticated visit to any admin route redirects to `/admin/login`.
- [ ] Nav renders all six sections; cookie scope is `admin.zync.is` with `SameSite=Strict`.

### Task 13: Admin app TanStack Query hooks
**Blocks:** 14, 15, 16, 17, 18, 19  ·  **Blocked by:** 12
**Files:**
- Create: `apps/zync-admin/src/hooks/use-admin-stats.ts`
- Create: `apps/zync-admin/src/hooks/use-admin-tenants.ts`
- Create: `apps/zync-admin/src/hooks/use-admin-tenant-detail.ts`
- Create: `apps/zync-admin/src/hooks/use-admin-roles.ts`
- Create: `apps/zync-admin/src/hooks/use-admin-tax-rates.ts`
**Steps:**
- [ ] Query hooks wrapping `admin-api.ts` for each route group; mutation hooks for freeze/unfreeze/approve, role CRUD, add-tax-rate with optimistic invalidation.
- [ ] Centralize query keys; on mutation success invalidate the relevant list query and toast success.
**Schema / Interfaces:**
```ts
export function useAdminStats(): UseQueryResult<{stats: PlatformStats; recentActivity: AdminAuditRow[]}>
export function useAdminTenants(params: AdminTenantListParams): UseQueryResult<PaginatedResponse<AdminTenantRow>>
export function useAdminTenantDetail(slug: string): UseQueryResult<AdminTenantDetail>
export function useFreezeUser(slug: string): UseMutationResult<void, Error, {userId: string; reason: string}>
export function useAdminTaxRates(country: string): UseQueryResult<{vat: VatRateRow[]; rates: Record<TaxType, TaxRateRow[]>}>
```
**Acceptance:**
- [ ] Each hook fetches its route and exposes loading/error/data.
- [ ] Freeze mutation invalidates the tenant-users query on success.

### Task 14: Overview page
**Blocks:** —  ·  **Blocked by:** 7, 13
**Files:**
- Create: `apps/zync-admin/src/pages/OverviewPage.tsx`
**Steps:**
- [ ] Render six stat cards via `StatCard`: total active users, pending approvals, active 30d, frozen users, open invitations, tenants-by-tier breakdown (bar/pie).
- [ ] Recent activity feed: last 20 system events from `useAdminStats().recentActivity`.
- [ ] Empty/error/loading states via `EmptyState` / `ErrorPage` / `Skeleton`.
- [ ] All strings via `t('admin.overview.*')`; respect `prefers-reduced-motion` on chart animation.
**Acceptance:**
- [ ] Stat cards display live values; feed shows up to 20 rows; loading shows skeletons.

### Task 15: Tenants list page
**Blocks:** —  ·  **Blocked by:** 7, 13
**Files:**
- Create: `apps/zync-admin/src/pages/TenantsListPage.tsx`
**Steps:**
- [ ] `DataTable` with columns: Name, Slug, Tier, Users, Join date, LTV, Status.
- [ ] Sort + filter (tier/join date/status), search (name/slug), `DataTablePagination`.
- [ ] Row click → navigate `/admin/tenants/:slug/overview`.
- [ ] i18n keys; `aria-sort` on sortable headers; keyboard-navigable rows.
**Acceptance:**
- [ ] Filters and search update the query; pagination works; row click navigates to detail.

### Task 16: Tenant detail — Overview tab + tab shell
**Blocks:** —  ·  **Blocked by:** 7, 13
**Files:**
- Create: `apps/zync-admin/src/pages/TenantDetailLayout.tsx`
- Create: `apps/zync-admin/src/pages/tenant/TenantOverviewTab.tsx`
**Steps:**
- [ ] `Tabs` shell with Overview / Audit / User Management / Roles, routed by sub-path (`/admin/tenants/:slug/{overview,audit,users,roles}`), `Breadcrumb` to tenants list.
- [ ] Overview tab: tenant metadata (name, slug, join date, tier, country, LTV), tier-history timeline, active/frozen/pending counts.
- [ ] `aria` roles on tabs; RTL-aware layout.
**Acceptance:**
- [ ] Tabs switch sub-routes; overview shows metadata + tier timeline + counts.

### Task 17: Tenant detail — Audit tab + User Management tab
**Blocks:** —  ·  **Blocked by:** 8, 9, 13
**Files:**
- Create: `apps/zync-admin/src/pages/tenant/TenantAuditTab.tsx`
- Create: `apps/zync-admin/src/pages/tenant/TenantUsersTab.tsx`
**Steps:**
- [ ] Audit tab: filterable log with Payments / Tier History / Services Log sections; filter by section + date range.
- [ ] User Management tab: `DataTable` (Name, Email, Role, Status, Last login, Joined) at 50/page; row actions Approve / Freeze / Unfreeze / Revoke invitation.
- [ ] Freeze action opens `Dialog` capturing a required freeze reason; submit → `useFreezeUser`.
- [ ] Confirm dialogs for destructive/revoke actions; toasts on success/failure; `aria-live` on result.
**Acceptance:**
- [ ] Freeze modal requires a reason and on submit the user row flips to frozen.
- [ ] Audit tab renders all three sections with working section + date filters.

### Task 18: Tax Rates page
**Blocks:** —  ·  **Blocked by:** 11, 13
**Files:**
- Create: `apps/zync-admin/src/pages/TaxRatesPage.tsx`
**Steps:**
- [ ] Grouped table by country → tax type; within group reverse-chronological. VAT section read-only (from `vat_rates`) with an "Add VAT rate" button that writes to upstream `vat_rates`; non-VAT types use `tax_rates`.
- [ ] Each row: Tax type, Rate (%), Effective from, Notes, Added by (admin email), Added at.
- [ ] "Add rate" `Dialog`: Country dropdown (Israel v1), Tax type dropdown (`ADMIN_TAX_TYPES`), Rate (%), Effective from (date, future allowed), Threshold (₪, bracket types only), Notes.
- [ ] Client validation mirrors server: threshold required for brackets 1–5; past/today rates are immutable (no edit/delete affordance on them); future overlap rejected with toast on 409.
- [ ] Page visible only to SUPER_ADMIN (hide nav item + guard route); strings i18n; ₪ formatting per Hebrew locale.
**Acceptance:**
- [ ] Rates render grouped by country+type, reverse-chronological; VAT section is read-only with its own add path.
- [ ] Add-rate modal validates threshold-for-bracket and future-date rules; 409 surfaces a toast.
- [ ] Non-SUPER_ADMIN cannot see or open the Tax Rates page.

### Task 19: Roles pages (tenant role matrix + admin role matrix)
**Blocks:** —  ·  **Blocked by:** 9, 10, 13
**Files:**
- Create: `apps/zync-admin/src/pages/tenant/TenantRolesTab.tsx`
- Create: `apps/zync-admin/src/pages/AdminRolesPage.tsx`
**Steps:**
- [ ] Tenant Roles tab: permission matrix (rows = roles, columns = tenant permission keys, checkboxes). Edit custom roles, add custom role, delete custom role (confirm: reassign users first). System roles view-only.
- [ ] Admin Roles page (`/admin/roles`): same matrix UI for `ADMIN_PERMISSIONS`; default roles SUPER_ADMIN/SUPPORT/BILLING; edit/create/delete custom admin roles. System admin roles view-only.
- [ ] Checkbox grid keyboard-accessible (`role="grid"`, `aria-checked`); RTL column order respects direction; toasts on save.
**Acceptance:**
- [ ] Tenant matrix saves custom-role permission changes; system roles are read-only.
- [ ] Admin matrix manages `admin_roles`; deleting an in-use or system role is blocked with a clear message.

### Task 20: i18n keys + accessibility/RTL pass
**Blocks:** —  ·  **Blocked by:** 14, 15, 16, 17, 18, 19
**Files:**
- Modify: `packages/ui/src/i18n/en.json`
- Modify: `packages/ui/src/i18n/he.json`
- Modify: admin page components (replace any literal strings)
**Steps:**
- [ ] Add `admin.*` keys for every admin string to both `en.json` and `he.json`; any count-bearing key gets all 5 Hebrew CLDR plural forms (`zero/one/two/many/other`).
- [ ] Verify all admin pages use logical CSS (`ms-/me-`), `dir`-aware via `useDirection`; tables have `aria-sort`, dialogs have labelled controls and focus traps, charts honor `prefers-reduced-motion`.
- [ ] Confirm no hardcoded English in JSX (governance rule); run typed-key codegen.
**Acceptance:**
- [ ] No literal user-facing strings remain in admin JSX; all keys present in both locales.
- [ ] Count keys define 5 Hebrew plural forms; RTL renders correctly under `lang="he"`.
