# Tenant Onboarding Wizard — Implementation Plan

**Spec:** docs/specs/2026-05-31-onboarding.md  ·  **Slug:** onboarding  ·  **Wave:** 10
**Depends on:** foundation-auth-rbac, module-management, settings-module, system-communications-notifications

## Goal
Deliver a full-screen, linear 5-step onboarding wizard at `app.zync.is/onboarding` shown to new tenant owners/admins immediately after signup. It collects the minimum configuration to make a workspace operational: business identity, module selection, outbound email adapter, and team invites. Onboarding state is persisted after every step so an abandoned wizard can be resumed, and middleware + a dashboard resume banner enforce completion for OWNER/ADMIN while letting non-admin users bypass into the app immediately.

## Architecture
The wizard plugs into existing upstream systems rather than introducing parallel logic:

- **Persistence flag:** `onboarding_completed BOOLEAN` on the `tenants` table is owned by `home-dashboard` (wave 7) — read/written here. This plan adds one new column, `onboarding_step INT`. All wizard step data lives in already-existing stores (no new wizard "state" table). The drizzle `tenants` schema lives in `@zync/db`.
- **Step 1 (Business Info):** Writes `tenants.name` and `tenants.settings` JSONB using the exact same write path as settings-module `PATCH /api/settings/business`. Logo is a presigned R2 PUT (binding `STORAGE`), URL persisted to `tenants.settings.logo_url`.
- **Step 2 (Modules):** Reuses module-management. Reads enabled set, applies dependency cascade via `getCascadeDisables` / `canEnable` from `packages/modules`, and persists with `setModuleStates` into `tenant_modules` (same effect as `PATCH /api/settings/modules/:moduleId`). `MODULE_MANIFEST` / `ModuleId` drive the card grid.
- **Step 3 (Email):** Stores SMTP credentials encrypted in the upstream `adapter_credentials` table via `saveAdapterCredential` / `encryptCredential` from `@zync/notifications`. "Skip" persists nothing and the system Resend `EmailNotificationAdapter` remains the tenant's outbound path. SMTP test uses `POST /api/onboarding/email/test` (no persistence). Gmail/Outlook send deferred to the email-integration epic + Settings>Integrations — Gmail-send requires Google CASA audit, Outlook needs Azure app registration; onboarding offers SMTP or system-default (Resend) so tenants can send immediately.
- **Step 4 (Team Invites):** Creates standard `invitations` rows via the foundation-auth-rbac invitation flow, fire-and-forget over `QUEUE`. Reuses `getMaxTeamMembers(tenant.tier)` cap semantics.
- **Step 5 (Done):** Sets `tenants.onboarding_completed = true`, `onboarding_step = 5`, redirects to `/dashboard`.
- **Guard + banner:** `authMiddleware` (foundation-auth-rbac) gains an onboarding redirect check; a `OnboardingResumeBanner` component is consumed by app-shell on dashboard pages. Wizard routes are OWNER/ADMIN-only via `requirePermission`/role check; non-admins bypass.

Upstream tables consumed: `tenants` (extended), `tenant_modules`, `adapter_credentials`, `invitations`, `users`, `tenant_memberships`, `roles`.
Upstream exports consumed: `authMiddleware`, `requirePermission`, `getMaxTeamMembers`, `createDb`/`tenantQuery`, `setModuleStates`, `getCascadeDisables`, `canEnable`, `MODULE_MANIFEST`, `ModuleId`, `getEnabledModuleIds`, `saveAdapterCredential`, `encryptCredential`, `EmailNotificationAdapter`, `sendEmail`, `TenantTier`, `UserRole`/`RoleId`, UI primitives (`Button`, `Input`, `Radio`, `Checkbox`, `Card`, `Progress`, `Form`, `FormField`, `FormLabel`, `FormError`, `Select`, `Toast`/`toast`).

## Tech Stack
- **API:** Hono on Cloudflare Workers (`apps/zync-api`), Drizzle ORM against Neon Postgres via Hyperdrive. Zod validation on every route (`require-zod-validation-in-routes`).
- **App:** Vite + React (`apps/zync-app`), React Router route `/onboarding`, react-query for state, `@zync/ui` primitives, `@zync/modules` manifest, i18n (`@zync/config` `translations` / `useDirection` for RTL Hebrew).
- **Packages:** `@zync/db` (tenants schema extension + queries), `@zync/notifications` (adapter credential save), `@zync/modules` (dependency helpers).
- **Bindings:** `STORAGE` (R2, logo upload), `QUEUE` (invite emails + invite send), `DB`/Hyperdrive (Postgres), `RATE_LIMITER_AUTH` (reused for email-test throttle).
- **Cross-cutting:** CSP-safe (no inline handlers in popup bridge), timing-safe nothing-secret leakage on email test, `aria` roles on progress bar + cards, RTL Hebrew business-type radios, `prefers-reduced-motion` on step transitions.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | `packages/db` tenants schema + migration | No (blocks all) |
| B — server core | 2, 3, 4 | `apps/zync-api` onboarding routes, guard middleware hook | 3 & 4 parallel after 2 |
| C — server side-effects | 5, 6, 7 | logo presign, email test, invites | parallel after 2 |
| D — client shell | 8, 9 | wizard layout, progress bar, route guard | 9 after 8 |
| E — client steps | 10, 11, 12, 13, 14 | step 1–5 components | parallel after 8/9 |
| F — integration | 15, 16 | resume banner (app-shell), i18n strings | parallel after 8 |

## Tasks

### Task 1: Extend `tenants` schema with onboarding columns
**Blocks:** 2,3,5,8  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/tenants.ts`
- Create: `packages/db/migrations/00XX_onboarding_tenant_columns.sql`
**Steps:**
- [ ] Add `onboardingCompleted` and `onboardingStep` to the drizzle `tenants` table definition.
- [ ] Generate/author the SQL migration adding both columns with defaults.
- [ ] Export no new table — these are columns on the existing `tenants` table; ensure existing `tenants` type re-exports include the new fields.
**Schema / Interfaces:**
```sql
-- onboarding_completed is owned by home-dashboard (wave 7) on the tenants table —
-- read/written here, NOT re-added. onboarding_step is unique to this plan.
ALTER TABLE tenants
  ADD COLUMN IF NOT EXISTS onboarding_step      INTEGER NOT NULL DEFAULT 0;
-- onboarding_step values: 0=not started, 1=business info, 2=modules,
-- 3=email, 4=invites, 5=complete. Integer (not enum) for forward-compat.
```
```ts
// packages/db/src/schema/tenants.ts (onboardingCompleted is declared by home-dashboard; add only:)
onboardingStep:      integer('onboarding_step').notNull().default(0),
```
**Acceptance:**
- [ ] Migration applies cleanly on a Neon branch; `tenants` rows default to `false` / `0`.
- [ ] Drizzle `select` returns `onboardingCompleted` and `onboardingStep` typed as boolean/number.

### Task 2: `GET /api/onboarding/state` route
**Blocks:** 8,10,11,12,13,14  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/onboarding.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router at `/api/onboarding`)
- Create: `apps/zync-api/src/routes/onboarding.schema.ts` (Zod schemas, shared by all onboarding routes)
**Steps:**
- [ ] Add `authMiddleware` to the router; add a role guard that returns 403 unless the session role is `OWNER` or `ADMIN`.
- [ ] Load the tenant row (`onboarding_completed`, `onboarding_step`, `name`, `settings`) via `tenantQuery`.
- [ ] Derive `data.business_info` from `tenants.settings` (business_name/type/tax_id/logo_url/phone/address), `data.modules` from `getEnabledModuleIds(tenantId)` mapped to the 12 user-facing slugs, `data.email_adapter` from presence of an email adapter credential in `adapter_credentials` (else `null`), `data.invites_sent` from a count of `invitations` created during this onboarding (count of tenant invitations).
- [ ] Return the response shape verbatim.
**Schema / Interfaces:**
```ts
// onboarding.schema.ts
export const onboardingBusinessTypeEnum = z.enum(['osek_morshe', 'company_ltd', 'osek_patur']);
export interface OnboardingState {
  onboarding_completed: boolean;
  onboarding_step: number; // 0–5
  data: {
    business_info: {
      business_name?: string; business_type?: 'osek_morshe' | 'company_ltd' | 'osek_patur';
      tax_id?: string; logo_url?: string; phone?: string; address?: string;
    };
    modules: Record<string, boolean>; // 12 user-facing slugs
    email_adapter: 'smtp' | 'gmail' | 'outlook' | 'skip' | null;
    invites_sent: number;
  };
}
// GET /api/onboarding/state -> 200 OnboardingState | 403
```
**Acceptance:**
- [ ] OWNER/ADMIN receive populated state reflecting persisted data; MEMBER/VIEWER/CONTRACTOR receive 403.
- [ ] For a pristine tenant: `onboarding_step=0`, `email_adapter=null`, `invites_sent=0`, all 12 modules `true`.

### Task 3: `PATCH /api/onboarding/progress` route — sequence guard + dispatch
**Blocks:** 10,11,12,13,14  ·  **Blocked by:** 1,2
**Files:**
- Modify: `apps/zync-api/src/routes/onboarding.ts`
- Modify: `apps/zync-api/src/routes/onboarding.schema.ts`
**Steps:**
- [ ] Validate the body with a discriminated Zod schema keyed on `step`.
- [ ] Enforce sequence: reject with `400` if `step` is neither the current `onboarding_step` nor `current+1` (no forward jumps > 1).
- [ ] Reject with `409` if `onboarding_completed` is already `true`.
- [ ] Reject with `403` if caller role is not OWNER/ADMIN.
- [ ] Run the per-step side effect (delegated to Tasks 4/5/6/7 helpers) inside a single transaction with audit (`require-audit-in-transaction`).
- [ ] After side effect, set `onboarding_step = max(current, step)` (advance), and if `complete:true` and `step==5`, set `onboarding_completed = true`.
- [ ] Return `{ ok: true, onboarding_step: N }`.
**Schema / Interfaces:**
```ts
export const onboardingProgressSchema = z.discriminatedUnion('step', [
  z.object({ step: z.literal(1), complete: z.literal(false).optional(), data: z.object({
    business_name: z.string().min(1),
    business_type: onboardingBusinessTypeEnum,
    tax_id: z.string().min(1),
    logo_url: z.string().url().optional(),
    phone: z.string().optional(),
    address: z.string().optional(),
  }) }),
  z.object({ step: z.literal(2), complete: z.literal(false).optional(),
    data: z.object({ modules: z.record(z.string(), z.boolean()) }) }),
  z.object({ step: z.literal(3), complete: z.literal(false).optional(),
    data: z.object({ email_adapter: z.enum(['smtp','gmail','outlook','skip']) }) }),
  z.object({ step: z.literal(4), complete: z.literal(false).optional(),
    data: z.object({ invites_sent: z.number().int().min(0) }) }),
  z.object({ step: z.literal(5), complete: z.literal(true), data: z.object({}).strict() }),
]);
// PATCH /api/onboarding/progress -> 200 { ok: true, onboarding_step: number }
//   | 400 out-of-sequence | 403 not owner/admin | 409 already completed
```
**Acceptance:**
- [ ] Posting `step:3` when `onboarding_step=1` returns `400`.
- [ ] Posting any step after completion returns `409`.
- [ ] Successful step 5 with `complete:true` flips `onboarding_completed` to `true`.

### Task 4: Step 1 side effect — write business info to `tenants`
**Blocks:** 10  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/routes/onboarding.ts`
- Create: `apps/zync-api/src/services/onboarding-business.ts`
**Steps:**
- [ ] Implement `applyBusinessInfo(db, tenantId, data)` that writes `tenants.name = business_name` and merges the business fields into `tenants.settings` JSONB.
- [ ] Reuse the exact same settings write path/helper used by settings-module `PATCH /api/settings/business` so onboarding is an alias (single source of truth — do not duplicate the merge logic).
- [ ] Persist `logo_url` into `tenants.settings.logo_url` when present.
**Schema / Interfaces:**
```ts
// tenants.settings JSONB additions (canonical keys, internal enum values)
// { business_name, business_type: 'osek_morshe'|'company_ltd'|'osek_patur',
//   tax_id, logo_url?, phone?, address? }
export function applyBusinessInfo(
  db: Db, tenantId: string,
  data: { business_name: string; business_type: 'osek_morshe'|'company_ltd'|'osek_patur';
          tax_id: string; logo_url?: string; phone?: string; address?: string },
): Promise<void>;
```
**Acceptance:**
- [ ] After step 1, `tenants.name` equals `business_name` and `tenants.settings` contains all six fields.
- [ ] A later `GET /api/settings/business` returns the same values (shared store, no divergence).

### Task 5: `POST /api/onboarding/logo` — presigned R2 upload URL
**Blocks:** 10  ·  **Blocked by:** 1,2
**Files:**
- Modify: `apps/zync-api/src/routes/onboarding.ts`
- Create: `apps/zync-api/src/services/onboarding-logo.ts`
**Steps:**
- [ ] Validate `{ filename, content_type, file_size_bytes }` with Zod; allow only `image/png`, `image/jpeg` (SVG dropped — matches portal stored-XSS block policy).
- [ ] Generate a presigned PUT URL on the `STORAGE` R2 binding under key `tenants/{tenantId}/logo.<ext>`; enforce 2MB max via `Content-Length`-constrained presign (`file_size_bytes` added so the 2MB cap is enforced server-side; client size alone is bypassable).
- [ ] Return `{ upload_url, logo_url }` where `logo_url` is the permanent R2 CDN URL.
- [ ] OWNER/ADMIN only.
**Schema / Interfaces:**
```ts
export const onboardingLogoSchema = z.object({
  filename: z.string().min(1),
  content_type: z.enum(['image/png','image/jpeg']),
  file_size_bytes: z.number().int().min(1).max(2 * 1024 * 1024),
});
// POST /api/onboarding/logo -> 200 { upload_url: string; logo_url: string }
```
**Acceptance:**
- [ ] Returns a working presigned PUT URL scoped to the tenant key prefix.
- [ ] Disallowed content types return `400`; upload of >2MB is rejected by R2.

### Task 6: `POST /api/onboarding/email/test` — SMTP connectivity check (no persistence)
**Blocks:** 12  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/onboarding.ts`
- Create: `apps/zync-api/src/services/onboarding-email-test.ts`
**Steps:**
- [ ] Validate `{ host, port, username, password }` with Zod.
- [ ] Attempt an SMTP handshake/auth (STARTTLS on 587, implicit TLS on 465 — auto-detect by port) without sending mail and without writing to `adapter_credentials`.
- [ ] Apply `RATE_LIMITER_AUTH` to throttle brute attempts; never echo the password back in errors.
- [ ] Return `{ ok: true }` or `{ ok: false, error }` (generic error text, no credential leakage).
- [ ] OWNER/ADMIN only.
**Schema / Interfaces:**
```ts
export const onboardingEmailTestSchema = z.object({
  host: z.string().min(1), port: z.number().int().min(1).max(65535),
  username: z.string().min(1), password: z.string().min(1),
});
// POST /api/onboarding/email/test -> 200 { ok: true } | { ok: false, error: string }
```
**Acceptance:**
- [ ] Valid creds return `{ ok: true }`; invalid creds return `{ ok: false, error }` and persist nothing.
- [ ] Password never appears in any response or log line.

### Task 7: Step 3 & Step 4 side effects — email credential save + bulk invites
**Blocks:** 12,13  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/routes/onboarding.ts`
- Create: `apps/zync-api/src/services/onboarding-email-adapter.ts`
- Create: `apps/zync-api/src/services/onboarding-invites.ts`
**Steps:**
- [ ] `applyEmailAdapter(db, tenantId, adapter, creds)`: for `smtp` encrypt+store SMTP creds in `adapter_credentials` via `saveAdapterCredential`/`encryptCredential`; for `gmail`/`outlook` store OAuth tokens already obtained from the popup flow; for `skip` store nothing (system Resend `EmailNotificationAdapter` remains active).
- [ ] `sendOnboardingInvites(db, tenantId, invites[])`: create `invitations` rows (email + role_id) reusing the foundation-auth-rbac invitation flow, enforcing `getMaxTeamMembers(tenant.tier)` cap; enqueue invite emails on `QUEUE` fire-and-forget (do not await delivery).
- [ ] Map UI role labels to system roles: Admin→ADMIN, Member→MEMBER, Viewer→VIEWER, Contractor→CONTRACTOR.
- [ ] Step 4 `data.invites_sent` is recorded for the summary; rows with empty email are skipped client-side, server ignores duplicates.
**Schema / Interfaces:**
```ts
export function applyEmailAdapter(
  db: Db, tenantId: string,
  adapter: 'smtp'|'gmail'|'outlook'|'skip',
  creds?: { host: string; port: number; username: string; password: string }
        | { access_token: string; refresh_token: string },
): Promise<void>;

export function sendOnboardingInvites(
  db: Db, tenantId: string,
  invites: Array<{ email: string; role: 'ADMIN'|'MEMBER'|'VIEWER'|'CONTRACTOR' }>,
): Promise<{ sent: number }>;
```
**Acceptance:**
- [ ] SMTP creds land encrypted in `adapter_credentials`; `skip` writes nothing.
- [ ] Invites create `invitations` rows and enqueue emails without blocking the response.
- [ ] Invites beyond the tier `getMaxTeamMembers` cap are rejected (402-equivalent surfaced to UI).

### Task 8: Onboarding wizard layout shell + progress bar
**Blocks:** 9,10,11,12,13,14  ·  **Blocked by:** 1,2
**Files:**
- Create: `apps/zync-app/src/features/onboarding/OnboardingLayout.tsx`
- Create: `apps/zync-app/src/features/onboarding/OnboardingProgress.tsx`
- Create: `apps/zync-app/src/features/onboarding/useOnboardingState.ts`
- Modify: `apps/zync-app/src/router.tsx` (register `/onboarding` route outside app shell)
**Steps:**
- [ ] Build a full-screen layout (no sidebar/header), centered column max-width 680px, white background, logo top-left (tenant logo if `logo_url` set, else Zync logo).
- [ ] Build `OnboardingProgress` with 5 nodes (Business Info / Modules / Email Setup / Team Invite / Done); `role="progressbar"` with `aria-valuenow`/`aria-valuemax`; states completed/current/not-reached; respects `prefers-reduced-motion` for the connector animation.
- [ ] `useOnboardingState` is a react-query hook calling `GET /api/onboarding/state`; expose `progress` mutation calling `PATCH /api/onboarding/progress` and invalidating state on success.
- [ ] Read `?step=N` from URL; clamp to server `onboarding_step`+1; render the matching step component.
**Schema / Interfaces:**
```ts
export interface OnboardingProgressProps { current: number; /* 1–5 */ }
export function useOnboardingState(): {
  state: OnboardingState | undefined; isLoading: boolean;
  saveProgress: (body: OnboardingProgressBody) => Promise<{ ok: true; onboarding_step: number }>;
};
```
**Acceptance:**
- [ ] `/onboarding` renders without app-shell chrome; progress bar reflects current step with correct aria attributes.
- [ ] Deep-linking `/onboarding?step=3` while server step is 1 redirects/clamps to step 1.

### Task 9: Route guard + non-admin bypass + completion redirect
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Modify: `apps/zync-app/src/features/onboarding/guard.ts` (Create)
- Modify: `apps/zync-api/src/middleware/auth.ts` (add onboarding redirect hint to `/api/auth/me` payload)
**Steps:**
- [ ] Client guard (soft gate): if `onboarding_completed === false` and role ∈ {OWNER, ADMIN}, redirect authenticated app-shell entry to `/onboarding?step={onboarding_step}` **unless** session deferral is set (`sessionStorage` `onboarding:deferred`) or the route is already `/onboarding`.
- [ ] Wizard steps 1–4: **Finish later** in layout header sets deferral and navigates to `/dashboard`; guard does not bounce back for the rest of the session.
- [ ] If `onboarding_completed === false` and role ∉ {OWNER, ADMIN}, allow normal app entry (bypass — no redirect, no banner).
- [ ] Server: extend the `GET /api/auth/me` / session payload surface so the client knows `onboarding_completed` + `onboarding_step` + role without an extra round-trip (do not change `SessionPayload` JWT shape; surface via the me-endpoint body).
- [ ] On step 5 "Go to dashboard", clear local onboarding state and route to `/dashboard`.
**Acceptance:**
- [ ] OWNER landing on `/dashboard` with incomplete onboarding is redirected to the wizard at the saved step (fresh session, no deferral).
- [ ] OWNER who clicks **Finish later** reaches `/dashboard` and is not redirected again this session; resume banner remains visible.
- [ ] MEMBER with incomplete tenant onboarding lands directly in the app, no redirect, no banner.

### Task 10: Step 1 component — Business Info
**Blocks:** —  ·  **Blocked by:** 3,4,5,8
**Files:**
- Create: `apps/zync-app/src/features/onboarding/steps/StepBusinessInfo.tsx`
**Steps:**
- [ ] Render business name (required), business type radio group (עוסק מורשה / חברה בע"מ / עוסק פטור mapped to `osek_morshe`/`company_ltd`/`osek_patur`), Tax ID/ח.פ. (required), optional logo upload, optional phone, optional multi-line address.
- [ ] Business-type radios use `Radio` group with `role="radiogroup"`; Hebrew labels render RTL via `useDirection`.
- [ ] Logo "Upload" calls `POST /api/onboarding/logo`, PUTs the file to `upload_url`, stores returned `logo_url`; enforce PNG/JPG ≤2MB client-side and server-side via Content-Length-bound presign.
- [ ] Validate name/type/tax_id non-empty before enabling Continue; show inline `FormError` on submit attempt.
- [ ] On Continue, call `saveProgress({ step:1, data: { business_name, business_type, tax_id, logo_url, phone, address } })`; advance to step 2. No Back button on step 1.
**Acceptance:**
- [ ] Continue disabled until all three required fields valid; inline errors shown on attempt.
- [ ] Logo upload round-trips and persists `logo_url`; step persists and advances to 2.

### Task 11: Step 2 component — Choose Your Modules
**Blocks:** —  ·  **Blocked by:** 3,8
**Files:**
- Create: `apps/zync-app/src/features/onboarding/steps/StepModules.tsx`
- Create: `apps/zync-app/src/features/onboarding/moduleCards.ts`
**Steps:**
- [ ] Render the 12 user-facing module cards (CRM, Customers, Time Management, Projects, Tasks, Invoices, Expenses, Billing, Calendar, Marketing, Knowledge Base, Contractor Payouts) with checkbox toggles, all enabled by default. The `system` module is never shown.
- [ ] On toggle-off attempt, compute cascade via `getCascadeDisables` (from `@zync/modules`); render inline warning banner beneath the card listing forced-disable children. Require a second explicit confirm (re-click toggle) or Continue-with-warning to apply cascade.
- [ ] Map UI slugs to canonical `ModuleId` (e.g. `time-management`→`time_management`, `knowledge-base`→`kb`, `contractor-payouts`→`contractor_payouts`).
- [ ] "Skip for now" keeps all modules enabled and advances. "Continue" calls `saveProgress({ step:2, data:{ modules } })` which persists via `setModuleStates`. Back button shown.
- [ ] Cards have `role` semantics; warning banner uses `role="alert"`.
**Schema / Interfaces:**
```ts
// moduleCards.ts — display metadata for the 12 user-facing cards
export const ONBOARDING_MODULE_SLUGS = [
  'crm','customers','time-management','projects','tasks','invoices',
  'expenses','billing','calendar','marketing','knowledge-base','contractor-payouts',
] as const;
// UI slug -> canonical ModuleId
export const SLUG_TO_MODULE_ID: Record<string, ModuleId> = {
  crm:'crm', customers:'customers', 'time-management':'time_management',
  projects:'projects', tasks:'tasks', invoices:'invoices', expenses:'expenses',
  billing:'billing', calendar:'calendar', marketing:'marketing',
  'knowledge-base':'kb', 'contractor-payouts':'contractor_payouts',
};
// Hard-dependency cascade (matches getCascadeDisables): Customers→CRM;
// Projects→Tasks,TimeManagement,ContractorPayouts; Invoices→Billing,ContractorPayouts.
```
**Acceptance:**
- [ ] Disabling Invoices shows "will also disable: Billing, Contractor Payouts" and cascades on confirm.
- [ ] Skip keeps all 12 enabled; Continue persists the chosen set to `tenant_modules`.

### Task 12: Step 3 component — Set Up Email
**Blocks:** —  ·  **Blocked by:** 3,6,7,8
**Files:**
- Create: `apps/zync-app/src/features/onboarding/steps/StepEmail.tsx`
**Steps:**
- [ ] Render a radio group of providers: SMTP / Skip (use Zync system email). Gmail/Outlook send deferred to the email-integration epic + Settings>Integrations — Gmail-send requires Google CASA audit, Outlook needs Azure app registration; onboarding offers SMTP or system-default (Resend) so tenants can send immediately.
- [ ] When SMTP selected, show host, port (default 587), username, password fields and a "Test connection" button calling `POST /api/onboarding/email/test`; require all four before Continue. TLS auto-detect (STARTTLS 587 / TLS 465) is server-side, not a field.
- [ ] Show the info note about Resend fallback when Skip is selected.
- [ ] On Continue, call `saveProgress({ step:3, data:{ email_adapter } })`. Back button shown; Skip is the radio option (no separate Skip button).
**Acceptance:**
- [ ] SMTP "Test connection" surfaces ok/error; Continue blocked until SMTP fields complete (when SMTP chosen).
- [ ] Skip advances using the system Resend adapter, persisting no credentials.

### Task 13: Step 4 component — Invite Your Team
**Blocks:** —  ·  **Blocked by:** 3,7,8
**Files:**
- Create: `apps/zync-app/src/features/onboarding/steps/StepInvites.tsx`
**Steps:**
- [ ] Render repeatable rows of {email input, role dropdown}. Role options: Admin / Member / Viewer / Contractor.
- [ ] "+ Add another" appends an empty row; empty-email rows are ignored on submit.
- [ ] Validate email format per row (inline error); flag duplicate emails within the form (inline warning).
- [ ] On "Send Invites" or "Skip for now", call `saveProgress({ step:4, data:{ invites_sent: N } })` (N = count of valid rows; 0 on skip). Invites are sent fire-and-forget server-side; do not await delivery. Advance immediately. Back button shown.
**Acceptance:**
- [ ] Invalid email rows show inline errors; duplicates show warnings; empty rows are skipped.
- [ ] Sending advances immediately without waiting for email delivery; `invites_sent` recorded for the summary.
- [ ] Skip advances with `invites_sent: 0`.

### Task 14: Step 5 component — You're Set Up (terminal)
**Blocks:** —  ·  **Blocked by:** 3,8
**Files:**
- Create: `apps/zync-app/src/features/onboarding/steps/StepDone.tsx`
**Steps:**
- [ ] Render success card with summary: Business name (step 1), "N of 12 enabled" (modules), "N sent" / "None sent" (invites).
- [ ] No Back button on step 5.
- [ ] On "Go to dashboard", call `saveProgress({ step:5, complete:true, data:{} })`, then redirect to `/dashboard`. This flips `onboarding_completed = true` server-side, which clears both the middleware redirect and the resume banner.
**Acceptance:**
- [ ] Summary reflects persisted step data accurately (module count derived from enabled set).
- [ ] "Go to dashboard" completes onboarding and lands on `/dashboard`; re-login no longer redirects to the wizard.

### Task 15: Dashboard resume banner (app-shell integration)
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/onboarding/OnboardingResumeBanner.tsx`
- Modify: `apps/zync-app/src/app-shell/AppShell.tsx` (render banner on dashboard pages)
**Steps:**
- [ ] When `onboarding_completed === false` and role ∈ {OWNER, ADMIN}, render a persistent top banner: "⚠ Finish setting up your workspace" with a "Continue setup" link to `/onboarding?step={onboarding_step}`. Banner shows regardless of session deferral (Finish later).
- [ ] Banner hidden for non-admin roles and once `onboarding_completed === true`.
- [ ] Banner uses `role="status"`; the warning icon is decorative (`aria-hidden`); link is keyboard-focusable.
**Schema / Interfaces:**
```ts
export function OnboardingResumeBanner(): JSX.Element | null;
```
**Acceptance:**
- [ ] Banner appears for OWNER/ADMIN on all app-shell pages while onboarding incomplete (including after Finish later); disappears on completion.
- [ ] Banner never appears for MEMBER/VIEWER/CONTRACTOR.

### Task 16: i18n strings (English + Hebrew/RTL)
**Blocks:** —  ·  **Blocked by:** —
**Files:**
- Modify: `packages/config/src/translations/en.json`
- Modify: `packages/config/src/translations/he.json`
**Steps:**
- [ ] Add all onboarding copy keys: step titles/labels, business-type options (Hebrew terms עוסק מורשה / חברה בע"מ / עוסק פטור), field labels, helper text, button labels (Continue / Skip for now / Send Invites / Go to dashboard / Test connection / Add another / Continue setup), dependency-warning template, Resend fallback note, summary lines.
- [ ] Ensure Hebrew strings render RTL via the existing `useDirection`/`LocaleProvider`; verify the business-type radio order and progress labels mirror correctly.
**Acceptance:**
- [ ] All onboarding UI text is keyed; switching locale to Hebrew flips the wizard to RTL with translated labels.
- [ ] No hardcoded user-facing strings remain in onboarding components.
