# Tenant Onboarding Wizard

**Date:** 2026-05-31
**Status:** Draft
**Depends on:** `foundation-auth-rbac`, `settings-module`, `module-management`, `system-communications-notifications`
**Referenced by:** `foundation-auth-rbac` (post-signup redirect), `app-shell` (resume banner), `settings-module` (shared business info fields)

---

## Overview

A full-screen, linear 5-step wizard shown to new tenants immediately after account creation. Collects the minimum required configuration to make the workspace operational: business identity, module selection, outbound email, and team setup.

The wizard runs at `app.zync.is/onboarding` outside the normal app shell (no sidebar, no header). It is gated to OWNER and ADMIN roles only. Non-admin users who log in before an owner completes onboarding are sent directly to the main app.

Onboarding state is persisted after each step so the wizard can be resumed if abandoned.

---

## Trigger & Guard Logic

Onboarding is a **soft gate**, not a hard wall: new OWNER/ADMIN users enter the wizard on authenticated entry, but may choose **Finish later** to reach the dashboard and use the app for the rest of the session. A persistent resume banner nags until `onboarding_completed = true`. Soft gate (user decision): onboarding is resumable, not a hard wall — banner + Finish-later make the dashboard reachable while incomplete.

### Entry points

1. **Post-signup redirect:** `foundation-auth-rbac` signup flow issues JWT and redirects to `app.zync.is/onboarding` (step 1) for the new tenant owner.
2. **Re-entry guard (client, app shell):** On authenticated entry into the normal app shell (any route except `/onboarding`), if:
   - `tenant.onboarding_completed = false`
   - User role is `OWNER` or `ADMIN`
   - Session deferral flag is **unset** (`sessionStorage` key `onboarding:deferred`)
   - → redirect to `app.zync.is/onboarding?step={tenant.onboarding_step}`

   If the user clicked **Finish later** in the wizard this session, the deferral flag is set and the guard does **not** redirect for the rest of the session — they use the app freely. On the next fresh login (new browser session), the deferral is gone and the redirect applies again until completion.

3. **Finish later (wizard escape):** Steps 1–4 show a **Finish later** control in the wizard header (top-right). Clicking it sets the session deferral flag and navigates to `/dashboard`. Step 5 uses **Go to dashboard** instead (completes onboarding).

### Non-admin bypass

If `tenant.onboarding_completed = false` and the authenticated user's role is not `OWNER` or `ADMIN`, the wizard is skipped entirely and the user enters the normal app shell.

### Dashboard resume banner

While `tenant.onboarding_completed = false` and the user is OWNER or ADMIN, a persistent banner appears at the top of every app-shell page (including after **Finish later** — the banner does **not** depend on the deferral flag):

```
┌────────────────────────────────────────────────────────────────────┐
│  ⚠  Finish setting up your workspace  →  Continue setup            │
└────────────────────────────────────────────────────────────────────┘
```

"Continue setup" links to `app.zync.is/onboarding?step={tenant.onboarding_step}`.

Once `onboarding_completed = true`: no redirect, no banner, ever.

---

## Route & Layout

**Route:** `/onboarding` (and `/onboarding?step=N`, N = 1–5)

**Layout:** Full-screen. No app sidebar, no header shell. White background with centered content column (max-width 680px). Logo top-left (Zync logo or tenant logo once uploaded).

### Progress bar (top of page, all steps)

```
  ┌──────────────────────────────────────────────────────────┐
  │  ●━━━━━━━━━━━●━━━━━━━━━━━●━━━━━━━━━━━●━━━━━━━━━━━○       │
  │  Business     Modules     Email       Team      Done      │
  │  Info                     Setup      Invite               │
  └──────────────────────────────────────────────────────────┘
       ● = completed    ● current    ○ = not yet reached
```

Step labels: Business Info / Modules / Email Setup / Team Invite / Done

---

## Step Definitions

### Step 1 — Business Info

**Required. Cannot be skipped.**

```
┌──────────────────────────────────────────────────────────────┐
│  Step 1 of 5 — Business Info                                 │
│                                                              │
│  Business name *                                             │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                                                      │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  Business type *                                             │
│  ○ עוסק מורשה   ○ חברה בע"מ   ○ עוסק פטור                  │
│                                                              │
│  Tax ID / ח.פ. *                                             │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                                                      │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  Logo  (optional — shown on invoices and portal)             │
│  ┌───────────┐                                               │
│  │  Upload   │  PNG/JPG, max 2MB                            │
│  └───────────┘                                               │
│                                                              │
│  Phone  (optional)                                           │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                                                      │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  Address  (optional)                                         │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                                                      │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│                                     [ Continue → ]          │
└──────────────────────────────────────────────────────────────┘
```

**Fields:**

| Field | Required | Notes |
|-------|----------|-------|
| Business name | Yes | `tenants.name` |
| Business type | Yes | עוסק מורשה / חברה בע"מ / עוסק פטור |
| Tax ID / ח.פ. | Yes | Stored in `tenants.settings` JSONB |
| Logo | No | R2 upload. `tenants.settings.logo_url` |
| Phone | No | `tenants.settings.phone` |
| Address | No | Multi-line. `tenants.settings.address` |

**Validation:** Business name, business type, and Tax ID must be non-empty to enable "Continue". Inline error messages shown on submit attempt.

**On Continue:** `PATCH /api/onboarding/progress` with `{ step: 1, data: { business_info } }`. Writes to `tenants.name` and `tenants.settings` JSONB (same fields as `PATCH /api/settings/business`). Sets `onboarding_step = 2`. Advances to step 2.

Logo upload calls `POST /api/onboarding/logo` → presigned R2 URL flow (same as `settings-module`).

---

### Step 2 — Choose Your Modules

**Optional. "Skip for now" keeps all modules enabled.**

```
┌──────────────────────────────────────────────────────────────┐
│  Step 2 of 5 — Choose Your Modules                           │
│  Enable the tools your team needs. You can change this       │
│  later in Settings.                                          │
│                                                              │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐ │
│  │  CRM       [✓] │  │ Customers  [✓] │  │ Time Mgmt  [✓] │ │
│  │ Manage client  │  │ Contact &      │  │ Timers,        │ │
│  │ support tickets│  │ account mgmt   │  │ idle detect    │ │
│  └────────────────┘  └────────────────┘  └────────────────┘ │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐ │
│  │ Projects   [✓] │  │  Tasks     [✓] │  │ Invoices   [✓] │ │
│  │ Fixed/hourly/  │  │ Kanban, list,  │  │ Tax invoices,  │ │
│  │ retainer jobs  │  │ timeline, Gantt│  │ proforma       │ │
│  └────────────────┘  └────────────────┘  └────────────────┘ │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐ │
│  │ Expenses   [✓] │  │  Billing   [✓] │  │ Calendar   [✓] │ │
│  │ OCR receipts,  │  │ Recurring      │  │ Google/Outlook │ │
│  │ VAT reports    │  │ payments       │  │ 2-way sync     │ │
│  └────────────────┘  └────────────────┘  └────────────────┘ │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐ │
│  │ Marketing  [✓] │  │Knowledge   [✓] │  │Contractor  [✓] │ │
│  │ Leads, catalog,│  │Base            │  │Payouts         │ │
│  │ campaigns      │  │ Internal wiki  │  │ Hours & payouts│ │
│  └────────────────┘  └────────────────┘  └────────────────┘ │
│                                                              │
│  ← Back           [ Skip for now ]   [ Continue → ]         │
└──────────────────────────────────────────────────────────────┘
```

**Modules (12 user-facing):**

| Module | Slug | Default |
|--------|------|---------|
| CRM | `crm` | enabled |
| Customers | `customers` | enabled |
| Time Management | `time-management` | enabled |
| Projects | `projects` | enabled |
| Tasks | `tasks` | enabled |
| Invoices | `invoices` | enabled |
| Expenses | `expenses` | enabled |
| Billing | `billing` | enabled |
| Calendar | `calendar` | enabled |
| Marketing | `marketing` | enabled |
| Knowledge Base | `knowledge-base` | enabled |
| Contractor Payouts | `contractor-payouts` | enabled |

The `system` module is never shown in the wizard (always on, transparent to users).

**Dependency warnings:**

When a user attempts to disable a module that other enabled modules depend on, show an inline warning banner beneath that card:

```
  ⚠ Disabling Invoices will also disable: Billing, Contractor Payouts
```

Dependency map (hard dependencies — disabling parent forces disable of children):

| Module disabled | Forces disable |
|-----------------|---------------|
| Customers | CRM |
| Projects | Tasks, Time Management, Contractor Payouts |
| Invoices | Billing, Contractor Payouts |
| Tasks | (none) |
| Time Management | (none) |

The warning is informational inline text. The user must explicitly confirm by clicking the toggle again or by clicking "Continue" with the warning visible. On confirm, dependent modules are also toggled off.

**Skip behavior:** "Skip for now" button keeps all modules enabled and advances to step 3.

**On Continue:** `PATCH /api/onboarding/progress` with `{ step: 2, data: { modules: { [slug]: boolean } } }`. Writes to `tenant_modules` table (same as `2026-05-31-module-management` `PATCH /api/modules`). Sets `onboarding_step = 3`.

---

### Step 3 — Set Up Email

**Optional. Skipping uses Resend fallback.**

```
┌──────────────────────────────────────────────────────────────┐
│  Step 3 of 5 — Set Up Email                                  │
│  Configure outbound email for invoices, notifications, and   │
│  client communications.                                      │
│                                                              │
│  Email provider                                              │
│  ○  SMTP (custom mail server)                                │
│  ○  Skip — use Zync system email                             │
│                                                              │
│  ─ ─ ─ ─ ─ ─  SMTP fields (shown when SMTP selected) ─ ─ ─  │
│  Host                          Port                          │
│  ┌──────────────────────┐    ┌──────┐                        │
│  │                      │    │ 587  │                        │
│  └──────────────────────┘    └──────┘                        │
│  Username                                                    │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                                                      │   │
│  └──────────────────────────────────────────────────────┘   │
│  Password                                                    │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  ••••••••••                                          │   │
│  └──────────────────────────────────────────────────────┘   │
│  [ Test connection ]                                         │
│  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
│                                                              │
│  ℹ  Skipping? Zync will send emails from                    │
│     noreply@zync.is via our mail service.                   │
│     You can configure this later in                         │
│     Settings → Integrations.                                │
│                                                              │
│  ← Back                               [ Continue → ]        │
└──────────────────────────────────────────────────────────────┘
```

**Options:**

| Selection | Behavior |
|-----------|----------|
| SMTP | Show host/port/username/password fields. "Test connection" button calls `POST /api/onboarding/email/test`. Credentials stored encrypted in `integration_credentials`. |
| Skip | No credentials stored. System Resend adapter used for all tenant outbound 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.

**SMTP fields:**

| Field | Required when SMTP | Default |
|-------|--------------------|---------|
| Host | Yes | — |
| Port | Yes | 587 |
| Username | Yes | — |
| Password | Yes | — |
| TLS | Auto-detect (STARTTLS on 587, TLS on 465) | — |

**On Continue:** `PATCH /api/onboarding/progress` with `{ step: 3, data: { email_adapter: 'smtp' | 'skip' } }`. Sets `onboarding_step = 4`.

---

### Step 4 — Invite Your Team

**Optional. "Skip for now" advances without sending invites.**

```
┌──────────────────────────────────────────────────────────────┐
│  Step 4 of 5 — Invite Your Team                              │
│  Add teammates now or skip and invite from Settings later.   │
│                                                              │
│  Email address                        Role                   │
│  ┌────────────────────────────────┐  ┌──────────────────┐   │
│  │ colleague@company.com          │  │ Admin          ▾ │   │
│  └────────────────────────────────┘  └──────────────────┘   │
│  ┌────────────────────────────────┐  ┌──────────────────┐   │
│  │                                │  │ Staff          ▾ │   │
│  └────────────────────────────────┘  └──────────────────┘   │
│  + Add another                                               │
│                                                              │
│  ← Back        [ Skip for now ]       [ Send Invites → ]     │
└──────────────────────────────────────────────────────────────┘
```

**Role options (dropdown per row):** Admin / Member / Viewer / Contractor

**Behavior:**
- Rows with empty email are ignored on submit.
- Invalid email format shows inline error on that row.
- "+ Add another" appends a new empty row.
- Duplicate emails within the form show inline warning.
- Invites are sent non-blocking (fire-and-forget via queue). The wizard does not wait for delivery confirmation before advancing.
- Invite mechanism reuses `foundation-auth-rbac` invitation flow (`POST /api/invitations`).

**On Send Invites / Skip:** `PATCH /api/onboarding/progress` with `{ step: 4, data: { invites_sent: N } }`. Sets `onboarding_step = 5`. Advances immediately.

---

### Step 5 — You're Set Up

**Terminal step. Sets onboarding complete.**

```
┌──────────────────────────────────────────────────────────────┐
│                                                              │
│              ✓  Your workspace is ready.                     │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  Business      Acme Freelance Services               │   │
│  │  Modules       10 of 12 enabled                      │   │
│  │  Team invites  3 sent                                │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│                    [ Go to dashboard ]                       │
│                                                              │
└──────────────────────────────────────────────────────────────┘
```

**Summary card fields:**
- Business name (from step 1)
- Modules: "N of 12 enabled"
- Team invites: "N sent" (or "None sent" if step 4 was skipped)

**On "Go to dashboard":** `PATCH /api/onboarding/progress` with `{ step: 5, complete: true }`. Sets `tenant.onboarding_completed = true`, `tenant.onboarding_step = 5`. Redirects to `app.zync.is/dashboard`.

Back button is **not shown** on step 5.

---

## Navigation Rules

| Step | Back button | Skip button | Other chrome | CTA label |
|------|-------------|-------------|--------------|-----------|
| 1 | Hidden | — | Finish later (header) | Continue |
| 2 | Shown | "Skip for now" | Finish later (header) | Continue |
| 3 | Shown | — (Skip is a radio option) | Finish later (header) | Continue |
| 4 | Shown | "Skip for now" | Finish later (header) | Send Invites |
| 5 | Hidden | — | — | Go to dashboard |

Back navigates to the previous step without re-saving data. The already-persisted step data is not cleared on back navigation.

---

## Incomplete Onboarding / Resume

If a user navigates away or closes the browser during the wizard:

1. Progress is persisted via the `PATCH /api/onboarding/progress` call made at each step completion.
2. On next login (any login), the client guard checks `onboarding_completed` + role + session deferral → redirects to `app.zync.is/onboarding?step={onboarding_step}` unless the user deferred this session via **Finish later**.
3. The step in the URL param is validated server-side. If `onboarding_step = 0` (no step completed), wizard starts at step 1.
4. The dashboard resume banner (see Trigger section) is shown on all app-shell pages until `onboarding_completed = true`, whether or not the user deferred onboarding this session.

---

## Data Model

### `tenants` table additions

```sql
-- onboarding_completed is owned by home-dashboard (spec) on tenants — consumed, not re-added.
ALTER TABLE tenants
  ADD COLUMN IF NOT EXISTS onboarding_step      INT     NOT NULL DEFAULT 0;
```

`onboarding_step` values: 0 = not started, 1 = business info saved, 2 = modules saved, 3 = email saved, 4 = invites sent, 5 = complete.

### `tenants.settings` JSONB (additions for onboarding)

Fields written during step 1 (same structure as `settings-module`):

```jsonc
{
  "business_name": "Acme Freelance Services",   // also written to tenants.name
  "business_type": "עוסק מורשה",               // | "חברה בע\"מ" | "עוסק פטור"
  "tax_id": "123456789",
  "logo_url": "{app_base_url}/api/public/tenant-logo/{tid}/logo.png",
  "phone": "+972-50-0000000",
  "address": "Rothschild 22, Tel Aviv"
}
```

### `tenant_modules` table (via `2026-05-31-module-management`)

Onboarding step 2 writes module enabled/disabled state using the same `PATCH /api/modules` endpoint defined in the module management spec. No additional schema beyond what that spec defines.

### `integration_credentials` table (via `system-communications-notifications`)

Email adapter credentials from step 3 are stored in the existing `integration_credentials` table (AES-256 encrypted). No schema additions required.

### `invitations` table (via `foundation-auth-rbac`)

Step 4 invites are standard `Invitation` records. No schema additions.

---

## API Endpoints

### `GET /api/onboarding/state`

Returns current onboarding state for the authenticated tenant.

**Auth:** OWNER or ADMIN role required. 403 otherwise.

**Response:**

```jsonc
{
  "onboarding_completed": false,
  "onboarding_step": 2,
  "data": {
    "business_info": { /* step 1 saved fields */ },
    "modules": { "crm": true, "billing": false, ... },
    "email_adapter": "smtp" | "skip" | null,
    "invites_sent": 3
  }
}
```

---

### `PATCH /api/onboarding/progress`

Saves step data and advances `onboarding_step`.

**Auth:** OWNER or ADMIN role required.

**Request body:**

```jsonc
{
  "step": 1,            // 1–5; must equal current onboarding_step or current+1
  "complete": false,    // true only on step 5
  "data": { /* step-specific payload */ }
}
```

Step-specific `data` shapes:

| Step | Data shape |
|------|-----------|
| 1 | `{ business_name, business_type, tax_id, logo_url?, phone?, address? }` |
| 2 | `{ modules: { [slug: string]: boolean } }` |
| 3 | `{ email_adapter: 'smtp' \| 'skip' }` |
| 4 | `{ invites_sent: number }` |
| 5 | `{}` — `complete: true` must be set |

**Side effects per step:**

| Step | Side effects |
|------|-------------|
| 1 | Writes `tenants.name` + `tenants.settings` JSONB (same as `PATCH /api/settings/business`) |
| 2 | Calls module management internals to enable/disable modules |
| 3 | Stores email adapter credentials in `integration_credentials` (if not skip) |
| 4 | Enqueues invitation emails via `POST /api/invitations` (bulk) |
| 5 | Sets `tenant.onboarding_completed = true` |

**Response:** `{ ok: true, onboarding_step: N }`

**Errors:**
- `400` — step out of sequence (may not jump forward more than 1)
- `403` — caller not OWNER or ADMIN
- `409` — onboarding already completed

---

### `POST /api/onboarding/logo`

Returns a presigned R2 upload URL for the business logo.

**Auth:** OWNER or ADMIN.

**Request:** `{ filename: string, content_type: 'image/png' | 'image/jpeg', file_size_bytes: number }` — `file_size_bytes` added so the 2MB cap is enforced server-side via a Content-Length-bound presign (client size alone is bypassable).

**Response:** `{ upload_url: string, logo_url: string }` — `upload_url` is presigned PUT URL; `logo_url` is the permanent public logo URL to store in settings, served by the strict-guarded API route `GET /api/public/tenant-logo/{tid}/logo.{png|jpg}` (streams the object from the `zync-storage` R2 bucket via the Worker binding; content-type forced from the validated extension + `nosniff`). There is no public R2/CDN host — logos are public brand assets served through this route, every other key shape 403s.

Max size: 2 MB. Allowed types: PNG, JPEG — SVG dropped; matches portal stored-XSS block policy (`BLOCKED_PORTAL_MIME_TYPES`).

---

### `POST /api/onboarding/email/test`

Tests SMTP connectivity with provided credentials. Does not persist anything.

**Auth:** OWNER or ADMIN.

**Request:** `{ host, port, username, password }`

**Response:** `{ ok: true }` or `{ ok: false, error: string }`

---

## Architecture Decisions

| # | Decision | Rationale |
|---|----------|-----------|
| 1 | Wizard lives outside the app shell (no sidebar/header) | Reduces distraction for new users; avoids half-configured sidebar modules appearing broken before setup is complete |
| 2 | Step 1 writes to the same `tenants.settings` JSONB as `PATCH /api/settings/business` | Single source of truth; no data migration needed if user later updates settings; onboarding is an alias for the settings write |
| 3 | Module step uses the same enable/disable logic as `2026-05-31-module-management` | Prevents divergence; module dependency graph maintained in one place |
| 4 | Invites are fire-and-forget (queue) | Slow SMTP should not block the wizard from completing; invite delivery is not time-critical at this moment |
| 5 | Non-admin users bypass wizard entirely | Onboarding is a configuration act, not an intro tour; staff members should be productive immediately without waiting for the owner to finish |
| 6 | `onboarding_step` is an integer not an enum | Forward-compatible if steps are added or reordered in future; step 0 = pristine state is unambiguous |
| 7 | SMTP credentials pass through `POST /api/onboarding/email/test` without persistence | Avoids storing invalid credentials; user must test before saving (UX enforced, not API enforced) |
| 8 | Gmail/Outlook OAuth send deferred from onboarding to email-integration epic + Settings>Integrations | Gmail-send requires Google CASA audit; Outlook needs Azure app registration — neither can go live from onboarding now; SMTP + system Resend cover immediate outbound needs |
| 9 | `onboarding_completed` flag drives both the middleware redirect and the dashboard banner | Single field, no ambiguity; banner and redirect both clear automatically on the same state change |
| 10 | Logo upload uses presigned R2 URL (not API proxy) | Avoids routing binary through Workers CPU budget; R2 signed URL upload is direct and scales without Worker memory pressure |
