# Settings Module — Implementation Plan

**Spec:** docs/specs/2026-05-30-settings-module.md  ·  **Slug:** settings-module  ·  **Wave:** 9
**Depends on:** billing-module, calendar-module, foundation-auth-rbac, invoices-adapters, system-communications-notifications, system-i18n

## Goal
Deliver the tenant settings hub and user profile: business profile/organization preferences, a locale/i18n page, an app-store-style integrations hub (invoicing, payments, communications, calendar, support), and a user profile with personal details, security (password change, active sessions, 2FA entry point), notification preferences, and a secure change-email flow. Settings are the single place to configure cross-module behavior. This spec owns the settings shell, sidebar navigation manifest, the business/locale/integrations/profile API endpoints, and a `users` schema delta for change-email; all other settings data lives in tables owned by upstream modules.

## Architecture
The module is a thin orchestration + UI layer over existing upstream tables — **no new tables** except a 3-column `ALTER TABLE users` delta for the change-email flow.

Data sources it consumes (all upstream-owned; reference by exact name, never redefine):
- `tenants.settings JSONB` — business profile (`business_name`, `business_type`, `tax_id`, `address`, `logo_url`, emails/phone/website) + org preferences (`idle_threshold_min`, `auto_pause_idle`, `default_billing_type`, `overtime_billing`, `invoice_vat_default`, `invoice_currency`, `fiscal_year_start`) + locale (`app_language`, `date_format`, `currency_display`, `week_start`). Plus existing scalar columns `tenants.country_code`, `tenants.default_currency`, `tenants.default_timezone`.
- `user_preferences` (foundation-auth-rbac) — columns: `notification_channels JSONB` (authoritative per-user email/telegram delivery prefs, read by `NotificationAdapter.canDeliver()`), `locale`, `timezone`, `default_currency`, `sidebar_collapsed`. The profile Notifications tab reads/writes `notification_channels`; profile Personal-Details writes `timezone`/`locale`. Use the existing `updateUserPreferencesSchema` and `PATCH /api/user/preferences` where field overlap allows; this spec's `/api/profile/notifications` is a typed projection over `notification_channels`.
- `adapter_credentials` (invoices-adapters): `id UUID PK, tenant_id UUID, adapter_id TEXT, credentials BYTEA (AES-256-GCM via INTEGRATION_ENCRYPTION_KEY), created_at, updated_at, UNIQUE(tenant_id, adapter_id)`. Connect/disconnect via `saveAdapterCredential` / `loadAdapterCredential` / `encryptCredential` / `decryptCredential`.
- `integration_sync_logs` (invoices-adapters): per-adapter sync history; the integration card's status badge + last-sync + items-synced are **derived** from the latest `integration_sync_logs` row (the credentials table has no status column).
- `calendar_connections` (calendar-module): `provider ('google'|'outlook')`, `selected_calendar_id`, `sync_enabled`. Calendar cards link to the calendar OAuth start endpoints; full OAuth/sync owned by calendar-module.
- `user_sessions` (session-security spec 122): `id, tenant_id, user_id, token_hash, device_name, ip_address, country_code, created_at, last_active_at, expires_at, revoked_at, revoked_reason`. Profile → Security reads/revokes from this table; never `refresh_tokens`.
- Payment adapters (billing-module): Morning Pay, Isracard Direct Debit, Upay, iCount Pay — credentials in `adapter_credentials`; connect = enter creds → `adapter.charge`-capable test → save encrypted.

Cross-cutting (preserve verbatim): change-email and password endpoints use `timingSafeEqual` for token/password comparison (no string `===`), opaque random tokens stored only as `SHA256` (`generateOpaqueToken` + `hashToken`), CSP-compliant pages, `aria` roles + RTL/Hebrew support on every page, `prefers-reduced-motion` honored on card transitions. All write routes require Zod validation; all table reads go through `tenantQuery`/`systemQuery` helpers (no raw Drizzle from routes); `requirePermission` guards each endpoint.

## Tech Stack
- API: Hono routes in `apps/zync-api` (Cloudflare Workers), Drizzle over Neon Postgres via Hyperdrive. Bindings: `STORAGE` (R2, for logo/avatar upload), `INTEGRATION_ENCRYPTION_KEY` (secret), `QUEUE` (manual sync trigger), `KV`/`RATELIMIT_KV` (rate limit), email via `sendEmail` (`@zync/notifications`).
- UI: Vite+React in `apps/zync-app`. Uses `@zync/ui` primitives (Card, Tabs, Sheet, Table, Form, Input, Select, Switch, Badge, Button, Dialog, Toast, EmptyState, ErrorState, Avatar, Breadcrumb). Data via react-query hooks. `LocaleProvider`/`useDirection` (system-i18n) for RTL.
- Packages: `@zync/auth` (hashPassword, verifyPassword, generateOpaqueToken, hashToken, requirePermission, authMiddleware, signSession), `@zync/db` (createDb, tenantQuery, systemQuery), `@zync/types`, `@zync/config`, `@zync/notifications` (sendEmail, createNotification), `@zync/ui`.
- Firebase Admin SDK call for email sync on verify (calendar-module/auth patterns).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 9a — schema + shared | 1 (users delta), 2 (settings types/schemas), 3 (settings shell + nav manifest) | migrations, `packages/types`, `apps/zync-app/settings/_shell` | T1 & T2 parallel; T3 after T2 |
| 9b — business/locale API+UI | 4 (business API), 5 (locale API), 6 (business UI), 7 (locale UI) | `apps/zync-api/routes/settings`, `apps/zync-app/settings` | T4,T5 parallel; T6 after T4; T7 after T5 |
| 9c — integrations | 8 (integrations list/detail API), 9 (connect/sync/disconnect API), 10 (integrations hub UI), 11 (integration card detail UI) | `apps/zync-api/routes/settings/integrations`, `apps/zync-app/settings/integrations` | T8 before T9; T10 after T8; T11 after T8/T9 |
| 9d — profile | 12 (profile core API), 13 (password API), 14 (sessions API), 15 (notifications API), 16 (change-email API), 17 (profile UI tabs) | `apps/zync-api/routes/profile`, `apps/zync-app/profile` | T12–T16 parallel after T1; T17 after all |
| 9e — wiring | 18 (sidebar nav integration), 19 (permissions seed verify + tests) | `apps/zync-app/_shell`, route registration | after 9b–9d |

## Tasks

### Task 1: `users` change-email schema delta
**Blocks:** 16, 17  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/migrations/0XXX_settings_users_pending_email.sql`
- Modify: `packages/db/src/schema/users.ts` (Drizzle table — add columns)
**Steps:**
- [ ] Add migration adding three nullable columns to existing `users` table (do not recreate the table).
- [ ] Mirror the columns in the Drizzle `users` schema definition.
- [ ] Add a partial index on `pending_email_token` for fast verify lookups.
**Schema / Interfaces:**
```sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS pending_email TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS pending_email_token TEXT;        -- SHA256(token), never raw
ALTER TABLE users ADD COLUMN IF NOT EXISTS pending_email_expires_at TIMESTAMPTZ;

CREATE INDEX IF NOT EXISTS idx_users_pending_email_token
  ON users (pending_email_token)
  WHERE pending_email_token IS NOT NULL;
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon; `users` gains the three columns + index, no data loss.

### Task 2: Settings types & Zod schemas
**Blocks:** 3, 4, 5, 6, 7, 8, 12, 15, 16  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/settings.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define TS interfaces for the JSONB shapes stored in `tenants.settings` (business + org-prefs + locale) and the notification-prefs projection.
- [ ] Define Zod schemas for every write endpoint body; export both the schema and inferred type.
- [ ] Define enums as TS string-literal unions matching the spec verbatim.
**Schema / Interfaces:**
```ts
export type BusinessType = 'osek_murshe' | 'company_ltd' | 'osek_patur'; // עוסק מורשה / חברה בע"מ / עוסק פטור
export type DefaultBillingType = 'fixed' | 'hourly' | 'retainer';
export type InvoiceCurrency = 'ILS' | 'USD' | 'EUR';
export type FiscalYearStart = 'january' | 'april';
export type IdleThresholdMin = 5 | 10 | 15 | 30;
export type AppLanguage = 'he' | 'en';
export type DateFormat = 'DD/MM/YYYY' | 'YYYY-MM-DD';
export type CurrencyDisplay = 'symbol' | 'code'; // ₪ vs ILS
export type WeekStart = 'sunday' | 'monday';

export interface TenantBusinessSettings {
  business_name: string;
  business_type: BusinessType;
  tax_id: string;              // ח.פ. / ע.מ.
  address: string;             // multi-line
  logo_url: string | null;     // R2 key/URL
  primary_email: string;
  phone: string | null;
  website: string | null;
  support_email: string;       // default support@{slug}.zync.is
}
export interface TenantOrgPrefs {
  idle_threshold_min: IdleThresholdMin;   // default 10
  auto_pause_idle: boolean;               // default true
  default_billing_type: DefaultBillingType; // default 'hourly'
  overtime_billing: boolean;              // default false
  invoice_vat_default: number | null;     // null = current rate from vat_rates
  invoice_currency: InvoiceCurrency;      // default 'ILS'
  fiscal_year_start: FiscalYearStart;     // default 'january'
}
export interface TenantLocaleSettings {
  app_language: AppLanguage;
  country: 'IL';                          // only option V1
  date_format: DateFormat;
  currency_display: CurrencyDisplay;
  week_start: WeekStart;
}
// Notification preferences projection over user_preferences.notification_channels JSONB.
// The stored column shape (foundation-auth-rbac) is: {"email": EventKey[], "telegram": EventKey[]}
// — arrays of event keys opted-IN per delivery channel. There is NO 'inapp' array: in-app
// delivery is unconditional (every notification creates an in-app record via createNotification),
// so the UI's in-app column is always-on/read-only. This projection maps the 6 spec events to the
// 'email' membership; 'telegram' is carried through untouched (managed by the Telegram bot spec).
export type NotificationEventKey =
  | 'task_assigned' | 'task_status_changed' | 'invoice_updated'
  | 'ticket_updated' | 'approval_invitation' | 'timer_auto_paused';
export interface NotificationChannels { email: NotificationEventKey[]; telegram: NotificationEventKey[]; }
export interface NotificationPref { inapp: true; email: boolean; } // inapp always true (read-only)
export type NotificationPrefsMap = Record<NotificationEventKey, NotificationPref>;

export const updateBusinessSchema: ZodSchema<Partial<TenantBusinessSettings>>;   // all fields optional, trimmed
export const updateOrgPrefsSchema: ZodSchema<Partial<TenantOrgPrefs>>;
export const updateLocaleSchema:   ZodSchema<Partial<TenantLocaleSettings>>;
export const updateProfileSchema:  ZodSchema<{ display_name?: string; avatar_url?: string|null; phone?: string|null; timezone?: string }>;
export const changePasswordSchema: ZodSchema<{ current_password: string; new_password: string }>; // new_password min 12
export const requestEmailChangeSchema: ZodSchema<{ new_email: string; current_password: string }>;
export const updateNotificationPrefsSchema: ZodSchema<Partial<NotificationPrefsMap>>;
export const connectIntegrationSchema: ZodSchema<{ credentials: Record<string,string> }>;
```
**Acceptance:**
- [ ] All schemas exported from `@zync/types`; enums match spec table values; package builds.

### Task 3: Settings shell layout + navigation manifest
**Blocks:** 6, 7, 10, 17, 18  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/features/settings/SettingsShell.tsx`
- Create: `apps/zync-app/src/features/settings/settingsNav.ts` (canonical manifest)
- Modify: `apps/zync-app/src/router.tsx` (register `/settings/*`, `/profile/*`)
**Steps:**
- [ ] Build the canonical `SETTINGS_NAV` manifest: one entry per `/settings/*` route from the spec's Settings Navigation Manifest table (route, label key, owning spec, tier, required-permission). This is the single source the sidebar builder consumes; routes owned by other specs render inside this shell.
- [ ] Implement `SettingsShell` — two-pane layout: left settings sidebar (RTL-aware via `useDirection`), right `<Outlet/>`. Sidebar filters entries by tier (`useTierGate`) and permission (`hasScope`/session scopes); locked-tier rows show a lock affordance routing to upgrade modal.
- [ ] Add `Breadcrumb` + page title region; `aria-current="page"` on the active nav item; keyboard navigable.
**Schema / Interfaces:**
```ts
export interface SettingsNavItem {
  route: string;          // '/settings/business'
  labelKey: string;       // i18n key
  tier: 'all' | 'business_plus' | 'enterprise' | 'white_label';
  permission: 'settings:read' | 'settings:write' | 'users:manage' | null; // null = self
  ownerSpec: string;      // spec number that owns the page (this spec owns the tree)
}
```
Transcribe the canonical manifest verbatim from the spec (this spec OWNS the sidebar tree; routes owned by other specs render inside `SettingsShell`). `tier` maps the spec's Tier column (`All`→`all`, `Business+`→`business_plus`, `Enterprise`→`enterprise`, `White Label`→`white_label`); `permission` is `settings:read` for view-only landing rows, `settings:write`/`users:manage` where the owning page mutates, `null` for self-scoped `/profile*`:
```ts
export const SETTINGS_NAV: SettingsNavItem[] = [
  { route: '/settings/business',               labelKey: 'settings.nav.business',     tier: 'all',           permission: 'settings:read', ownerSpec: '25' }, // Business profile, org prefs, user management
  { route: '/settings/locale',                 labelKey: 'settings.nav.locale',       tier: 'all',           permission: 'settings:read', ownerSpec: '25' }, // Language, country, timezone
  { route: '/settings/plan',                   labelKey: 'settings.nav.plan',         tier: 'all',           permission: 'settings:read', ownerSpec: '33' }, // Subscription plan, billing, upgrade
  { route: '/settings/users',                  labelKey: 'settings.nav.users',        tier: 'all',           permission: 'users:manage',  ownerSpec: '138' }, // Team members, invitations, roles
  { route: '/settings/roles',                  labelKey: 'settings.nav.roles',        tier: 'all',           permission: 'users:manage',  ownerSpec: '163' }, // Custom role editor (OWNER/ADMIN)
  { route: '/settings/modules',                labelKey: 'settings.nav.modules',      tier: 'all',           permission: 'settings:write',ownerSpec: '32' }, // Enable/disable modules, dependency matrix
  { route: '/settings/invoicing',              labelKey: 'settings.nav.invoicing',    tier: 'all',           permission: 'settings:write',ownerSpec: '125' }, // Invoice prefix, VAT, payment terms, late fees
  { route: '/settings/expenses',               labelKey: 'settings.nav.expenses',     tier: 'all',           permission: 'settings:write',ownerSpec: '61' }, // Expense category, auto-approve, OCR
  { route: '/settings/ita',                    labelKey: 'settings.nav.ita',          tier: 'business_plus', permission: 'settings:write',ownerSpec: '165' }, // ITA e-invoice (Shaba) registration
  { route: '/settings/time-tracking',          labelKey: 'settings.nav.timeTracking', tier: 'all',           permission: 'settings:write',ownerSpec: '169' }, // Time rounding, idle, overtime
  { route: '/settings/contractors',            labelKey: 'settings.nav.contractors',  tier: 'all',           permission: 'settings:write',ownerSpec: '148' }, // Contractor portal, approval, withholding
  { route: '/settings/projects',               labelKey: 'settings.nav.projects',     tier: 'all',           permission: 'settings:write',ownerSpec: '151' }, // Project defaults
  { route: '/settings/customers',              labelKey: 'settings.nav.customers',    tier: 'all',           permission: 'settings:write',ownerSpec: '152' }, // Customer defaults
  { route: '/settings/contracts',              labelKey: 'settings.nav.contracts',    tier: 'all',           permission: 'settings:write',ownerSpec: '153' }, // Contract defaults
  { route: '/settings/proposals',              labelKey: 'settings.nav.proposals',    tier: 'all',           permission: 'settings:write',ownerSpec: '96' }, // Proposal defaults
  { route: '/settings/portal',                 labelKey: 'settings.nav.portal',       tier: 'all',           permission: 'settings:write',ownerSpec: '136' }, // Customer portal branding
  { route: '/settings/products',               labelKey: 'settings.nav.products',     tier: 'all',           permission: 'settings:write',ownerSpec: '85' }, // Product/service library, pricing
  { route: '/settings/saved-views',            labelKey: 'settings.nav.savedViews',   tier: 'all',           permission: 'settings:read', ownerSpec: '143' }, // Manage saved list filters/views
  { route: '/settings/integrations',           labelKey: 'settings.nav.integrations', tier: 'all',           permission: 'settings:read', ownerSpec: '25' }, // Integrations hub (app-store layout)
  { route: '/settings/integrations/payments',  labelKey: 'settings.nav.intPayments',  tier: 'all',           permission: 'settings:write',ownerSpec: '49' }, // Payment gateway credentials
  { route: '/settings/integrations/invoicing', labelKey: 'settings.nav.intInvoicing', tier: 'business_plus', permission: 'settings:write',ownerSpec: '127' }, // Invoice adapters
  { route: '/settings/integrations/accounting',labelKey: 'settings.nav.intAccounting',tier: 'business_plus', permission: 'settings:write',ownerSpec: '181' }, // Accountant export (Hashavshevet, 6111)
  { route: '/settings/integrations/smtp',      labelKey: 'settings.nav.intSmtp',      tier: 'business_plus', permission: 'settings:write',ownerSpec: '51' }, // Custom From / SMTP relay
  { route: '/settings/integrations/telegram',  labelKey: 'settings.nav.intTelegram',  tier: 'business_plus', permission: 'settings:write',ownerSpec: '43' }, // Telegram bot token setup
  { route: '/settings/integrations/calendar',  labelKey: 'settings.nav.intCalendar',  tier: 'business_plus', permission: 'settings:write',ownerSpec: '113' }, // Calendar OAuth + sync
  { route: '/settings/integrations/webhooks',  labelKey: 'settings.nav.intWebhooks',  tier: 'white_label',   permission: 'settings:write',ownerSpec: '27' }, // Webhook endpoints, delivery logs
  { route: '/settings/api-keys',               labelKey: 'settings.nav.apiKeys',      tier: 'business_plus', permission: 'settings:write',ownerSpec: '60' }, // API key management, scopes
  { route: '/settings/api',                    labelKey: 'settings.nav.api',          tier: 'business_plus', permission: 'settings:read', ownerSpec: '94' }, // API usage gauges, quota, overage
  { route: '/settings/communications',         labelKey: 'settings.nav.communications',tier: 'all',          permission: 'settings:read', ownerSpec: '25' }, // Notification preferences hub
  { route: '/settings/security',               labelKey: 'settings.nav.security',     tier: 'all',           permission: 'settings:read', ownerSpec: '47' }, // 2FA, backup codes, enforce-2FA
  { route: '/settings/sla',                    labelKey: 'settings.nav.sla',          tier: 'business_plus', permission: 'settings:write',ownerSpec: '145' }, // SLA policy + escalation
  { route: '/settings/crm',                    labelKey: 'settings.nav.crm',          tier: 'all',           permission: 'settings:write',ownerSpec: '22' }, // Lead scoring, pipeline stages
  { route: '/settings/email-templates',        labelKey: 'settings.nav.emailTemplates',tier: 'business_plus',permission: 'settings:write',ownerSpec: '66' }, // HTML email templates per doc type
  { route: '/settings/data',                   labelKey: 'settings.nav.data',         tier: 'all',           permission: 'settings:write',ownerSpec: '54' }, // Data export, GDPR deletion
  { route: '/settings/import',                 labelKey: 'settings.nav.import',       tier: 'business_plus', permission: 'users:manage',  ownerSpec: '40' }, // Bulk CSV/XLSX import (OWNER/ADMIN)
  { route: '/settings/kb',                     labelKey: 'settings.nav.kb',           tier: 'all',           permission: 'users:manage',  ownerSpec: '154' }, // KB workspace config
  { route: '/settings/permissions',            labelKey: 'settings.nav.permissions',  tier: 'business_plus', permission: 'users:manage',  ownerSpec: '121' }, // Field-level permission rules (admin)
  { route: '/settings/white-label',            labelKey: 'settings.nav.whiteLabel',   tier: 'enterprise',    permission: 'settings:write',ownerSpec: '137' }, // Custom domain, white-label branding
  { route: '/settings/audit-log',              labelKey: 'settings.nav.auditLog',     tier: 'business_plus', permission: 'settings:read', ownerSpec: '50' }, // Tenant audit log
  { route: '/settings/ai',                     labelKey: 'settings.nav.ai',           tier: 'business_plus', permission: 'settings:write',ownerSpec: '44' }, // AI personality, prompt, credit usage
  { route: '/profile',                         labelKey: 'settings.nav.profile',      tier: 'all',           permission: null,            ownerSpec: '25' }, // Personal profile
  { route: '/profile/security',                labelKey: 'settings.nav.profileSec',   tier: 'all',           permission: null,            ownerSpec: '25' }, // Password, sessions, trusted devices
  { route: '/profile/notifications',           labelKey: 'settings.nav.profileNotif', tier: 'all',           permission: null,            ownerSpec: '25' }, // Per-channel notification preferences
];
```
**Acceptance:**
- [ ] `/settings/business`, `/settings/locale`, `/settings/integrations`, `/profile` resolve into the shell; sidebar hides rows the user's tier/permission can't access; RTL mirrors correctly.

### Task 4: Business profile API (`/api/settings/business`)
**Blocks:** 6  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/settings/business.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount)
**Steps:**
- [ ] `GET /api/settings/business` — `requirePermission('settings:read')`; return merged `TenantBusinessSettings` + `TenantOrgPrefs` read from `tenants.settings JSONB` (with documented defaults when keys absent). Compute `support_email` default as `support@{tenant.slug}.zync.is` when unset.
- [ ] `PATCH /api/settings/business` — `requirePermission('settings:write')`; validate with `updateBusinessSchema`/`updateOrgPrefsSchema`; shallow-merge into `tenants.settings` JSONB via `tenantQuery` (read-modify-write in one transaction); audit the change (`require-audit-in-transaction`).
- [ ] Logo upload: accept R2 object key (uploaded via presigned `STORAGE` PUT in UI); persist `logo_url`.
**Schema / Interfaces:** consumes `tenants.settings JSONB`, `tenants.slug`; no new table.
**Acceptance:**
- [ ] GET returns defaults for a fresh tenant; PATCH persists and round-trips; non-`settings:write` caller gets 403.

### Task 5: Locale settings API (`/api/settings/locale`)
**Blocks:** 7  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/settings/locale.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount)
**Steps:**
- [ ] `GET /api/settings/locale` — `requirePermission('settings:read')`; return `TenantLocaleSettings` from `tenants.settings` (defaults: he, IL, DD/MM/YYYY, symbol, sunday).
- [ ] `PATCH /api/settings/locale` — `requirePermission('settings:write')`; validate `updateLocaleSchema`; merge into `tenants.settings`; country is fixed `'IL'` in V1 (reject other values). Note that changing country adapter affects VAT/invoice-numbering (document; adapter switch itself owned by config/country-adapter).
- [ ] Audit within transaction.
**Schema / Interfaces:** consumes `tenants.settings JSONB`, `tenants.country_code`.
**Acceptance:**
- [ ] Locale round-trips; `country` other than `IL` rejected with 422.

### Task 6: Business profile UI (`/settings/business`, `/settings/users` link)
**Blocks:** —  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-app/src/features/settings/pages/BusinessPage.tsx`
- Create: `apps/zync-app/src/features/settings/hooks/useBusinessSettings.ts`
**Steps:**
- [ ] Build Business Info form (`Form`+`Input`+`Select`+`Textarea`): name, business-type select (3 IL options), tax ID, multi-line address, primary email/phone/website, support email (read-only default hint).
- [ ] Logo uploader: request presigned R2 PUT, upload, store returned key via PATCH.
- [ ] Organization Preferences section rendering the spec table (idle threshold, auto-pause, default billing type, overtime, VAT default, currency, fiscal year start) with documented defaults.
- [ ] User Management: render a link/section to `/settings/users` (owned by spec 138) — do not reimplement team table here; show note that it is the tenant-admin view.
- [ ] `useBusinessSettings` react-query hook (GET + optimistic PATCH + `toast` on save); reduced-motion-safe.
**Acceptance:**
- [ ] Editing + saving any field persists and survives reload; a11y: labelled inputs, focus order, RTL mirrors.

### Task 7: Locale UI (`/settings/locale`)
**Blocks:** —  ·  **Blocked by:** 3, 5
**Files:**
- Create: `apps/zync-app/src/features/settings/pages/LocalePage.tsx`
- Create: `apps/zync-app/src/features/settings/hooks/useLocaleSettings.ts`
**Steps:**
- [ ] Render the locale table as controls: app language (Hebrew/English), country (Israel, disabled — V1), date format, currency display (₪ / ILS), week start (Sunday/Monday).
- [ ] On app-language change, also offer per-user override note (user profile owns the user-level override via `user_preferences.locale`).
- [ ] Save via `useLocaleSettings` hook; live-preview direction via `useDirection`.
**Acceptance:**
- [ ] Switching language updates the i18n provider; selections persist via PATCH.

### Task 8: Integrations list & detail API
**Blocks:** 9, 10, 11  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/settings/integrations.ts`
- Create: `apps/zync-api/src/lib/integrationCatalog.ts` (static provider catalog)
- Modify: `apps/zync-api/src/routes/index.ts` (mount)
**Steps:**
- [ ] Define a static `INTEGRATION_CATALOG`: groups (Invoicing, Billing/Payments, Communications, Calendar, Support) → providers with `adapter_id`, label, logo, group, min tier, V1-availability (`available` | `coming_soon`). Coming-soon: WhatsApp, Trello/Asana/Jira/Monday/ClickUp (Tasks group, V2).
- [ ] `GET /api/settings/integrations` — `requirePermission('settings:read')`; for each catalog provider, join `adapter_credentials` (connected if row exists) and derive status/last-sync/items from the latest `integration_sync_logs` row. Return grouped cards. Calendar providers additionally reflect `calendar_connections.sync_enabled`/`selected_calendar_id`.
- [ ] `GET /api/settings/integrations/:adapterId` — detail + last 20 `integration_sync_logs` rows (status, items_count, error_message, created_at) ordered desc; 404 unknown adapter.
- [ ] Status derivation: `Connected` (creds exist, latest log success/no log), `Error` (latest log `status='error'`), `Disconnected` (no creds).
**Schema / Interfaces:** reads `adapter_credentials`, `integration_sync_logs` (cols: `id, tenant_id, adapter_id, entity_type, entity_id, direction, status, request_payload, response_payload, error_message, created_at`), `calendar_connections`.
**Acceptance:**
- [ ] List returns every catalog card with correct status badge; detail returns ≤20 ordered logs; coming-soon providers flagged.

### Task 9: Integration connect / sync / disconnect API
**Blocks:** 11  ·  **Blocked by:** 8
**Files:**
- Modify: `apps/zync-api/src/routes/settings/integrations.ts`
**Steps:**
- [ ] `POST /api/settings/integrations/:adapterId/connect` — `requirePermission('settings:write')`; validate `connectIntegrationSchema`; resolve adapter via `getAdapter`; run a connection test (provider-appropriate, e.g. `adapter.getStatus`/balance ping or `adapter.charge` dry-capability); on success `encryptCredential` + `saveAdapterCredential` (writes `adapter_credentials`, `UNIQUE(tenant_id,adapter_id)` upsert). Invoicing/payment adapters gated to Business+ tier via `requireTier`. Reject `coming_soon` adapters with 422.
- [ ] `POST /api/settings/integrations/:adapterId/sync` — `requirePermission('settings:write')`; enqueue manual sync job `QUEUE.send({ type: 'integration.sync', tenantId, adapterId })`; respond 202.
- [ ] `DELETE /api/settings/integrations/:adapterId` — `requirePermission('settings:write')`; delete the `adapter_credentials` row (status → disconnected); audit. For calendar providers, also flip `calendar_connections.sync_enabled=false` (do not delete calendar OAuth tokens here — that flow owned by calendar-module).
- [ ] Telegram connect special case: store bot token encrypted, then register webhook at `POST /api/webhooks/telegram/{tenantId}` (call provider setWebhook).
- [ ] Rate-limit connect/test attempts via `rateLimit` (`RATE_LIMITER_WEBHOOK`/dedicated bucket).
**Schema / Interfaces:** uses `saveAdapterCredential`, `loadAdapterCredential`, `encryptCredential`, `decryptCredential`, `getAdapter`.
**Acceptance:**
- [ ] Connect with bad creds → test fails, nothing stored; good creds → encrypted row written; disconnect clears creds; coming-soon → 422.

### Task 10: Integrations hub UI (`/settings/integrations`)
**Blocks:** —  ·  **Blocked by:** 3, 8
**Files:**
- Create: `apps/zync-app/src/features/settings/pages/IntegrationsPage.tsx`
- Create: `apps/zync-app/src/features/settings/components/IntegrationCard.tsx`
- Create: `apps/zync-app/src/features/settings/hooks/useIntegrations.ts`
**Steps:**
- [ ] App-store grid: cards grouped by category (Invoicing, Billing/Payments, Communications, Calendar, Support, Tasks[V2]). Each `IntegrationCard` shows provider logo, status badge (Connected green / Error red / Disconnected grey via `Badge`), last-sync timestamp, primary action button.
- [ ] Coming-soon providers (WhatsApp, Tasks group) render a disabled "Coming soon" badge.
- [ ] Tier-gated groups (invoice automation Business+) show upgrade affordance via `useUpgradeModal` when locked.
- [ ] `useIntegrations` hook fetches grouped list; status badge has accessible text (not color-only) per a11y.
**Acceptance:**
- [ ] Grid renders all groups with correct badges; locked providers route to upgrade; RTL + reduced-motion respected.

### Task 11: Integration card detail panel
**Blocks:** —  ·  **Blocked by:** 8, 9, 10
**Files:**
- Create: `apps/zync-app/src/features/settings/components/IntegrationDetailSheet.tsx`
**Steps:**
- [ ] Expandable `Sheet`/panel: provider name+logo, status badge, last-sync timestamp, items-synced count, connect form (credentials inputs per provider) OR connected state.
- [ ] "Sync now" button → POST sync endpoint, toast + refresh.
- [ ] Sync logs `Table`: last 20 `integration_sync_logs` (status, items, error, timestamp).
- [ ] "Disconnect" button → DELETE with confirm `Dialog`; on success clears creds, badge → Disconnected.
- [ ] Invoice automation settings (Business+): auto-generate-on-task-status selector, retainer-depleted auto-generate toggle, IL proforma-approval-before-tax toggle, default payment terms (days) — persisted via the invoicing settings owned shape (`InvoiceAutomationSettings`, stored in `tenants.settings`/tenant settings JSONB).
- [ ] Support group: read-only `support@{slug}.zync.is` routing address + auto-create-ticket-from-telegram/email toggles.
**Acceptance:**
- [ ] Connect→test→save, sync-now, disconnect all work end-to-end; logs render; automation toggles persist for Business+ tenants.

### Task 12: Profile core API (`GET/PATCH /api/profile`)
**Blocks:** 17  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/profile/index.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount)
**Steps:**
- [ ] `GET /api/profile` — self; return `display_name`, `email`, `avatar_url`, `phone`, `timezone` (from `users` + `user_preferences`).
- [ ] `PATCH /api/profile` — self; validate `updateProfileSchema`; update name/avatar/phone on `users`, `timezone` on `user_preferences` (upsert by `(user_id, tenant_id)`); avatar uploaded to R2 (`STORAGE`) via presigned PUT, store key.
- [ ] Audit profile changes.
**Schema / Interfaces:** consumes `users`, `user_preferences` (timezone column).
**Acceptance:**
- [ ] Profile round-trips; another user cannot read/patch it (self-scope enforced).

### Task 13: Password change API (`PATCH /api/profile/password`)
**Blocks:** 17  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/profile/password.ts`
**Steps:**
- [ ] `PATCH /api/profile/password` — self; validate `changePasswordSchema` (new ≥12 chars); `verifyPassword(current, users.password_hash)`; on mismatch 401 "Incorrect password." On success `hashPassword(new)` → update `users.password_hash`; revoke all `user_sessions` except current (`revoked_reason='user'`); also sync Firebase password if applicable.
- [ ] Rate-limit via `rateLimit` (`RATE_LIMITER_AUTH`).
- [ ] Audit (security event) inside transaction.
**Schema / Interfaces:** uses `hashPassword`, `verifyPassword`; writes `users`, `user_sessions`.
**Acceptance:**
- [ ] Wrong current password → 401; correct → password updated, other sessions revoked, current session survives.

### Task 14: Active sessions API
**Blocks:** 17  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/routes/profile/sessions.ts`
**Steps:**
- [ ] `GET /api/profile/sessions` — self; list this user's `user_sessions WHERE revoked_at IS NULL`, ordered `created_at DESC`; return `id, device_name, ip_address, country_code, created_at, last_active_at, is_current` (mark current by matching `token_hash`).
- [ ] `DELETE /api/profile/sessions/:sessionId` — self; set `revoked_at=now(), revoked_reason='user'` on the owned row; 403 if session belongs to another user; cannot 404-silently revoke current without confirmation header (allow but flag).
**Schema / Interfaces:** reads/writes `user_sessions` (session-security spec 122 owns the table; this spec only reads/revokes).
**Acceptance:**
- [ ] List shows active sessions with `is_current` flag; revoking a session marks it revoked; cross-user revoke → 403.

### Task 15: Notification preferences API
**Blocks:** 17  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/profile/notifications.ts`
**Steps:**
- [ ] `GET /api/profile/notifications` — self; read `user_preferences.notification_channels` (stored shape `{email: EventKey[], telegram: EventKey[]}`) and project into `NotificationPrefsMap`: `inapp` is always `true` (read-only — in-app records are unconditional); `email[event].email = event ∈ channels.email`. Defaults for a row with no prefs yet (per spec table): email ON for `task_assigned, invoice_updated, ticket_updated, approval_invitation`; email OFF for `task_status_changed, timer_auto_paused`; in-app ON for all.
- [ ] `PATCH /api/profile/notifications` — self; validate `updateNotificationPrefsSchema` (`Partial<NotificationPrefsMap>`, where only `email` is mutable); recompute `notification_channels.email` array from the booleans (add/remove event key), leave `notification_channels.telegram` untouched; upsert `user_preferences` row. This column is authoritative and read by `NotificationAdapter.canDeliver()`.
- [ ] Audit.
**Schema / Interfaces:** consumes `user_preferences.notification_channels JSONB`.
**Acceptance:**
- [ ] Defaults match spec table; opting out of an event's email persists and is honored by delivery layer.

### Task 16: Change-email flow API
**Blocks:** 17  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/routes/profile/email.ts`
**Steps:**
- [ ] `POST /api/profile/email/request` — self; validate `requestEmailChangeSchema`; `verifyPassword(current, password_hash)` (401 "Incorrect password." on fail); check `new_email` not already in `users.email` (409 "That email is already in use."); `generateOpaqueToken()` → store `hashToken(token)` in `users.pending_email_token`, `pending_email=new_email`, `pending_email_expires_at=now()+24h`; `sendEmail` verification link to **new** address with raw token; `sendEmail` "change requested" notice to **old** address. Rate-limit (`RATE_LIMITER_AUTH`).
- [ ] `GET /api/profile/email/verify?token=` — hash received token, look up by `pending_email_token` using `timingSafeEqual` comparison; validate `pending_email_expires_at > now()` and not already used (token cleared); re-check `pending_email` still not registered (TOCTOU); update `users.email=pending_email`; sync Firebase Auth email via Admin SDK; revoke all `user_sessions` except current; clear all three `pending_email*` columns; redirect `/profile?email_changed=1`.
- [ ] Failure copy: expired → "This link expired. Request a new email change from your profile."; already-registered → 409; wrong password → 401.
**Schema / Interfaces:** uses `generateOpaqueToken`, `hashToken`, `timingSafeEqual`, `verifyPassword`, `sendEmail`; writes `users` (`email`, `pending_email*`), `user_sessions`.
**Acceptance:**
- [ ] Full happy path changes email + invalidates other sessions; expired/used/taken/wrong-password all return correct status+copy; token compared with `timingSafeEqual`, stored only as SHA256.

### Task 17: Profile UI (`/profile`, `/profile/security`, `/profile/notifications`)
**Blocks:** —  ·  **Blocked by:** 3, 12, 13, 14, 15, 16
**Files:**
- Create: `apps/zync-app/src/features/profile/ProfilePage.tsx`
- Create: `apps/zync-app/src/features/profile/tabs/PersonalDetailsTab.tsx`
- Create: `apps/zync-app/src/features/profile/tabs/SecurityTab.tsx`
- Create: `apps/zync-app/src/features/profile/tabs/NotificationsTab.tsx`
- Create: `apps/zync-app/src/features/profile/ChangeEmailDialog.tsx`
- Create: `apps/zync-app/src/features/profile/hooks/useProfile.ts`
**Steps:**
- [ ] Tabbed page (`Tabs`): Personal Details, Security, Notifications.
- [ ] Personal Details: display name, avatar (R2 upload), email shown with **[Change email]** link opening `ChangeEmailDialog` (new email + current password → POST request; success banner when `?email_changed=1`), phone, timezone select.
- [ ] Security: change-password form (current + new), active-sessions list (UA, IP, last seen, revoke; `is_current` badge) backed by sessions API, and a 2FA entry-point section linking to spec 47 enroll/disable/backup-codes (do not reimplement 2FA flow — link/embed its components).
- [ ] Notifications: render the 6-event ×(in-app/email) matrix as `Switch` grid; email toggle defaults from settings, per-event opt-out; save via notifications API.
- [ ] All forms: Zod-validated client side, `toast` on success, accessible labels, RTL, reduced-motion.
**Acceptance:**
- [ ] All three tabs functional; change-email dialog drives the flow; success banner shows on redirect; sessions revocable; notification matrix persists.

### Task 18: Sidebar navigation integration
**Blocks:** —  ·  **Blocked by:** 3, 6, 7, 10, 17
**Files:**
- Modify: `apps/zync-app/src/features/app-shell/Sidebar.tsx` (or settings sub-nav consumer)
**Steps:**
- [ ] Wire `SETTINGS_NAV` (Task 3) into the app sidebar's Settings section so all `/settings/*` (this spec + other specs) render inside `SettingsShell`.
- [ ] Filter by tier (`useTierGate`) and permission; locked rows show lock + route to upgrade modal.
- [ ] Ensure routes owned by other specs (e.g. `/settings/plan`, `/settings/modules`) mount inside the shell without this spec owning their pages.
**Acceptance:**
- [ ] Sidebar lists settings routes per manifest; clicking any renders inside the shell; tier/permission filtering correct.

### Task 19: Permissions verification & route tests
**Blocks:** —  ·  **Blocked by:** 4, 5, 8, 9, 12, 13, 14, 15, 16
**Files:**
- Create: `apps/zync-api/test/settings.routes.test.ts`
- Create: `apps/zync-api/test/profile.routes.test.ts`
**Steps:**
- [ ] Verify permission seeds exist (`settings:read`, `settings:write`, `users:manage`) — these are seeded by `seedPermissions` in foundation; assert mapping, do not redefine.
- [ ] Tests: each settings endpoint enforces its required permission (403 without scope); profile endpoints enforce self-scope.
- [ ] Change-email tests cover happy path, expired token, reused token, taken email (TOCTOU 409), wrong password (401), and `timingSafeEqual` usage (no string `===` on token/password).
- [ ] Connect/disconnect tests: bad creds reject, good creds encrypt+store, disconnect clears row.
**Acceptance:**
- [ ] Test suite passes; permission and self-scope guards proven; change-email edge cases covered.
