# Admin Dashboard (System Admin Control Plane)

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-design-system`, `foundation-auth-rbac`, `system-i18n`  
**Referenced by:** `audit-compliance`

---

## Overview

Separate control plane at `admin.zync.is` for Zync SYSTEM_ADMIN staff. Completely isolated from tenant data — no tenant RBAC, separate session type, separate app deployment. Manages tenants, system roles, user health, and platform stats.

---

## Deployment

- Separate Vite + React SPA deploying to `admin.zync.is` (Cloudflare Workers)
- OR: same `zync-app` bundle with route guard (`session.type === 'admin'`), served at admin subdomain

Decision: separate app (`apps/zync-admin/`) to keep admin bundle isolated and surface-area minimal. Shares `packages/ui`, `packages/types`, `packages/auth`.

---

## Authentication

- Admin login at `admin.zync.is/login`
- Email + password + TOTP (required — see `foundation-auth-rbac`)
- Session cookie scoped to `admin.zync.is` (not `.zync.is` parent domain)
- No tenant JWT — `session.type = 'admin'`, `session.tid = null`
- All admin API routes: `requireAdminSession()` middleware

---

## Navigation

```
Admin Shell
├── Overview    /admin/overview
├── Reports     /admin/reports
├── Analytics   /admin/analytics
├── Tenants     /admin/tenants
├── Roles       /admin/roles
└── Tax Rates   /admin/tax-rates
```

---

## Overview Page (`/admin/overview`)

Stats cards:
- Total users (active)
- Pending approvals (across all tenants)
- Active users (logged in last 30d)
- Frozen users
- Open invitations
- Total tenants by tier (pie/bar breakdown)

Recent activity feed: last 20 system-level events (tenant provisioned, user frozen, tier change, etc.) from audit log.

---

## Tenants (`/admin/tenants`)

Table: all tenants.  
Columns: Name, Slug, Tier, Users (count), Join date, LTV (sum of payments), Status (active/suspended).  
Sort + filter: by tier, join date, status.  
Search: tenant name / slug.

Click row → `/admin/tenants/:slug`

---

## Tenant Detail (`/admin/tenants/:slug`)

### Tab: Overview (`/admin/tenants/:slug/overview`)

- Tenant metadata: name, slug, join date, tier, country, LTV
- Tier history timeline (from audit)
- Active user count, frozen user count, pending approvals

### Tab: Audit (`/admin/tenants/:slug/audit`)

Filterable log. Sections:

**Payments** — all billing events for this tenant (invoice paid, subscription created/canceled/changed, refund)

**Tier History** — all tier changes with date, previous tier, new tier, actor

**Services Log** — metered service consumption per month:
- AI OCR uses
- AI chat tokens
- Mailing credits sent
- Storage used (R2)
- Email deliveries

### Tab: User Management (`/admin/tenants/:slug/users`)

Table: all users in tenant.  
Columns: Name, Email, Role, Status, Last login, Joined.

Actions:
- Approve pending user
- Freeze user (modal: enter freeze reason → stored on `tenant_memberships.freeze_reason`)
- Unfreeze user
- Revoke invitation (for pending invites)

Pagination: 50 per page.

### Tab: Roles (`/admin/tenants/:slug/roles`)

- List all roles for this tenant (system + custom)
- Permission matrix: rows = roles, columns = permission keys, checkboxes
- Edit custom roles (add/remove permissions)
- Add new custom role
- Delete custom role (confirm: reassign users first)
- System roles (OWNER, ADMIN, MEMBER, VIEWER, CONTRACTOR): view-only from admin plane — tenant admins manage these

---

## Tax Rates (`/admin/tax-rates`)

Global configuration for all statutory tax rates by country + type. Same methodology as VAT rates: DB table with effective dates, no code deploy needed when rates change.

### Table View

Grouped by country, then by tax type. Within each group: rates listed in reverse chronological order (most recent first).

```
Israel (IL)
├── VAT (מע"מ)                        → links to vat_rates table entries
├── Corporate Income Tax (מס חברות)   → current: 23% (from 2018-01-01)
├── Personal Income — Bracket 1       → 10% on income up to ₪81,480/yr
├── Personal Income — Bracket 2       → 14% on income ₪81,480–₪116,760/yr
├── Personal Income — Bracket 3       → 20% on income ₪116,760–₪187,440/yr
├── Personal Income — Bracket 4       → 31% on income ₪187,440–₪260,520/yr
├── Personal Income — Bracket 5       → 35% on income ₪260,520–₪557,580/yr
├── Personal Income — Bracket 6       → 47% on income above ₪557,580/yr
└── Withholding Default (ניכוי מס)    → 25% (standard default when no certificate)
```

Each row: Tax type, Rate, Effective from, Notes, Added by (admin user), Added at.

### Add Rate

"Add rate" button → modal:
- Country (dropdown — Israel v1)
- Tax type (dropdown from predefined list: `vat`, `corporate_income`, `personal_bracket_1`…`6`, `withholding_default`)
- Rate (%)
- Effective from (date picker — can be a future date)
- Threshold (₪, for bracket types only) — income ceiling for this bracket; NULL for highest bracket
- Notes (free text, e.g. "Source: פקודת מס הכנסה תיקון 2025")

Validations:
- Effective from must not overlap with an existing rate of same type (future entries allowed)
- Cannot delete or edit a rate that's already in the past (immutable historical record; add a new entry to correct)
- VAT rates: added here sync to `vat_rates` table (single source of truth)

### Data Model

```sql
tax_rates (
  id UUID PRIMARY KEY,
  country_code TEXT NOT NULL,             -- 'IL'
  tax_type TEXT NOT NULL,                 -- 'corporate_income' | 'personal_bracket_1'…'6' | 'withholding_default'
  rate NUMERIC(6,4) NOT NULL,            -- decimal fraction e.g. 0.23 for 23%
  effective_from DATE NOT NULL,
  threshold_ils NUMERIC,                  -- bracket ceiling in ILS/yr; NULL for highest bracket
  notes TEXT,
  created_by UUID NOT NULL,              -- references admin_users.id
  created_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE (country_code, tax_type, effective_from)
)
```

> `vat_rates` table (spec 4) remains separate — VAT rates pre-date this admin UI and have their own lookup function. The Tax Rates admin page can display `vat_rates` entries in the VAT section (read-only from `vat_rates` table), with an "Add VAT rate" button that writes to `vat_rates` instead of `tax_rates`.

### Rate Lookup Helper

```ts
// packages/db/src/queries/tax.ts
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)
}
```

Used by: `reports-analytics` (income tax estimate), `contractor-payouts` (withholding default).

### Permissions

`SUPER_ADMIN` only — tax rate changes affect all tenants' compliance reports.

---

## Roles (`/admin/roles`)

System admin roles — manage permissions for Zync staff accounts.

Same permission matrix UI but for admin-plane 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`

Default system admin roles: `SUPER_ADMIN` (all), `SUPPORT` (tenants read + users read), `BILLING` (tenants read + billing read/write).

---

## API Routes (all under `/api/admin/*`, require admin session)

```
GET  /api/admin/stats                         → platform overview stats
GET  /api/admin/tenants                       → paginated tenant list
GET  /api/admin/tenants/:slug                 → tenant detail
GET  /api/admin/tenants/:slug/users           → tenant user list
POST /api/admin/tenants/:slug/users/:id/approve
POST /api/admin/tenants/:slug/users/:id/freeze   body: { reason: string }
POST /api/admin/tenants/:slug/users/:id/unfreeze
GET  /api/admin/tenants/:slug/audit           → audit log (filterable)
GET  /api/admin/tenants/:slug/roles           → tenant roles
PATCH /api/admin/tenants/:slug/roles/:id      → edit role permissions
POST /api/admin/tenants/:slug/roles           → create role
DELETE /api/admin/tenants/:slug/roles/:id     → delete role

GET  /api/admin/roles                         → admin roles
PATCH /api/admin/roles/:id
POST /api/admin/roles
DELETE /api/admin/roles/:id

GET  /api/admin/reports                       → platform-level reports
GET  /api/admin/analytics                     → platform analytics

GET  /api/admin/tax-rates                     → list all tax rates (grouped by country + type)
POST /api/admin/tax-rates                     → add new rate entry
```

---

## Data Model

```sql
-- Admin users (separate from tenant users)
admin_users (
  id UUID PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  totp_secret TEXT,             -- AES-256 encrypted
  role_id UUID NOT NULL,        -- references admin_roles
  status TEXT DEFAULT 'active', -- 'active' | 'suspended'
  created_at TIMESTAMPTZ
)

admin_roles (
  id UUID PRIMARY KEY,
  name TEXT NOT NULL,
  permissions TEXT[] NOT NULL,
  is_system_role BOOLEAN DEFAULT false
)

tax_rates (
  id UUID PRIMARY KEY,
  country_code TEXT NOT NULL,
  tax_type TEXT NOT NULL,      -- 'corporate_income' | 'personal_bracket_1'…'6' | 'withholding_default'
  rate NUMERIC(6,4) NOT NULL,
  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 DEFAULT now(),
  UNIQUE (country_code, tax_type, effective_from)
)
```

---

## Security

- All admin routes: `requireAdminSession()` + `requireAdminPermission(perm)` middleware
- Admin session cookie: `domain=admin.zync.is`, `SameSite=Strict` (not Lax — stricter for admin)
- No admin route accepts `tenantId` from request — always resolves from URL path param (`:slug`)
- Admin audit log: every admin API mutation logged (actor, action, target, timestamp, IP)

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Separate app | `apps/zync-admin/` | Isolated bundle, smaller attack surface, independent deploys |
| Admin cookie scope | `admin.zync.is` only | Tighter scope than app cookies; admin sessions must not leak to tenant app |
| Admin RBAC | Separate `admin_roles` table | Admin permissions are different in kind from tenant permissions |
| Tenant user freeze | Stored `freeze_reason` + revoke all refresh tokens | Immediate effect + audit trail |
