# Staff Portal Detail

**Spec:** 38  
**Date:** 2026-05-31  
**Status:** Draft  
**Depends on:** `tenant-portals`, `foundation-auth-rbac`, `app-shell`, `system-communications-notifications`, `settings-module`, `error-empty-states`  
**Referenced by:** `notification-center` (spec 35), `search-completeness` (spec 37)

---

## Overview

The Staff Portal is not a separate application. It is `app.zync.is` with RBAC enforcement controlling what each staff member can see and do. This spec defines the exact visibility rules per role, the profile and preferences UI, the CONTRACTOR-specific restrictions, the My Work page for limited roles, and the invite acceptance flow.

The canonical roles are defined in `foundation-auth-rbac`. This spec pins their concrete UI surface.

---

## 1. Role-Based Sidebar Visibility Matrix

The sidebar structure (from `app-shell`) has four groups: **Workspace**, **Business**, **Financials**, **Resources**, plus **Settings** and **Profile** at the bottom. Each nav item is conditionally rendered using the `usePermission` hook. If the user has no permission in a module, the nav item is hidden entirely.

The table below maps every nav item to the minimum permission required and shows which roles can see it.

### Required permissions per nav item

| Nav Group | Nav Item | Permission Required | OWNER | ADMIN | MEMBER | VIEWER | CONTRACTOR |
|-----------|----------|--------------------:|:-----:|:-----:|:------:|:------:|:----------:|
| — | **My Work** (`/my-work`) | _(always shown for MEMBER + CONTRACTOR)_ | — | — | Yes | — | Yes |
| Workspace | Dashboard | `projects:read` OR `tasks:read` | Yes | Yes | Yes | Yes | — |
| Workspace | Projects | `projects:read` | Yes | Yes | Yes | Yes | — |
| Workspace | Tasks | `tasks:read` | Yes | Yes | Yes | Yes | Yes* |
| Workspace | Time Tracking | `time:read` OR `time:track` | Yes | Yes | Yes | Yes | Yes |
| Workspace | Calendar | `calendar:read` | Yes | Yes | Yes | Yes | — |
| Workspace | Analytics | `reports:read` | Yes | Yes | — | Yes | — |
| Business | Customers | `customers:read` | Yes | Yes | — | Yes | — |
| Business | Marketing | `marketing:read` | Yes | Yes | — | Yes | — |
| Business | Support Center | `tickets:read` | Yes | Yes | — | Yes | — |
| Financials | Invoices | `invoices:read` | Yes | Yes | — | Yes | — |
| Financials | Expenses | `expenses:read` | Yes | Yes | — | Yes | — |
| Financials | Contractor Payouts | `payouts:read` | Yes | Yes | — | Yes | — |
| Financials | Billing | `billing:read` | Yes | — | — | — | — |
| Resources | Knowledge Base | `kb:read` | Yes | Yes | Yes | Yes | Yes† |
| Resources | Reports | `reports:read` | Yes | Yes | — | Yes | — |
| — | Settings | `settings:read` | Yes | Yes | — | — | — |
| — | Profile (`/profile`) | _(always visible)_ | Yes | Yes | Yes | Yes | Yes |

**Notes:**
- `*` CONTRACTOR Tasks link goes to `/my-work` (tasks assigned to them only) rather than the full board.
- `†` Knowledge Base visible to CONTRACTOR only if the `kb` module is enabled by the tenant admin.
- VIEWER has read-only access; write actions (buttons, forms) are hidden or disabled within each module.
- Dashboard is hidden for CONTRACTOR because they have no projects:read permission.
- The **My Work** link is injected at the top of the sidebar for MEMBER and CONTRACTOR roles, above the Workspace group.

### Permission sets per built-in role (canonical)

| Permission | OWNER | ADMIN | MEMBER | VIEWER | CONTRACTOR |
|-----------|:-----:|:-----:|:------:|:------:|:----------:|
| `tasks:read` | Yes | Yes | Yes | Yes | Yes (own only) |
| `tasks:write` | Yes | Yes | Yes | — | — |
| `tasks:assign` | Yes | Yes | Yes | — | — |
| `projects:read` | Yes | Yes | Yes | Yes | — |
| `projects:write` | Yes | Yes | — | — | — |
| `time:read` | Yes | Yes | Yes | Yes | Yes (own only) |
| `time:track` | Yes | Yes | Yes | — | Yes |
| `time:manage` | Yes | Yes | — | — | — |
| `customers:read` | Yes | Yes | — | Yes | — |
| `invoices:read` | Yes | Yes | — | Yes | — |
| `expenses:read` | Yes | Yes | — | Yes | — |
| `payouts:read` | Yes | Yes | — | Yes | — |
| `billing:read` | Yes | — | — | — | — |
| `billing:manage` | Yes | — | — | — | — |
| `kb:read` | Yes | Yes | Yes | Yes | Yes (if module on) |
| `kb:write` | Yes | Yes | — | — | — |
| `reports:read` | Yes | Yes | — | Yes | — |
| `marketing:read` | Yes | Yes | — | Yes | — |
| `tickets:read` | Yes | Yes | — | Yes | — |
| `calendar:read` | Yes | Yes | Yes | Yes | — |
| `settings:read` | Yes | Yes | — | — | — |
| `settings:write` | Yes | Yes | — | — | — |
| `users:read` | Yes | Yes | — | — | — |
| `users:invite` | Yes | Yes | — | — | — |
| `users:manage` | Yes | Yes | — | — | — |
| `users:freeze` | Yes | — | — | — | — |
| `users:delete` | Yes | — | — | — | — |
| `payouts:manage` | Yes | Yes | — | — | — |
| `webhooks:manage` | Yes | — | — | — | — |

---

## 2. My Profile Page (`/profile`)

Accessible to all roles. Linked from the user avatar at the bottom of the sidebar and from the header avatar dropdown.

### Route

```
/profile
```

### Layout

Two-column layout (sidebar + content area). Single scrollable page with sections divided by `<hr>` separators.

```
┌──────────────────────────────────────────────────────────────────────────┐
│  SIDEBAR                │  PROFILE CONTENT                               │
│  (shared app shell)     │                                                │
│                         │  ┌────────────────────────────────────────┐    │
│                         │  │  [Avatar 96px]  Display Name           │    │
│                         │  │                 email@example.com      │    │
│                         │  │                 [Change Avatar]        │    │
│                         │  └────────────────────────────────────────┘    │
│                         │                                                │
│                         │  ─── Personal Details ──────────────────────  │
│                         │  Display Name  [________________]              │
│                         │  Email         email@example.com  (read-only) │
│                         │  Phone         [________________]              │
│                         │                                                │
│                         │  ─── Preferences ───────────────────────────  │
│                         │  Language     ( Hebrew )  ( English )         │
│                         │  Timezone     [Asia/Jerusalem          ▾]     │
│                         │                                                │
│                         │  ─── Change Password ───────────────────────  │
│                         │  Current password  [______________]           │
│                         │  New password      [______________]           │
│                         │  Confirm           [______________]           │
│                         │                    [Save password]            │
│                         │                                                │
│                         │  ─── Active Sessions ───────────────────────  │
│                         │  (see Security sub-section below)             │
│                         │                                                │
│                         │                          [Save changes]       │
└──────────────────────────────────────────────────────────────────────────┘
```

### Components

**Avatar Upload**
- Triggers file picker (jpg / png / webp only; max 5 MB enforced client-side and API-side)
- Upload via `POST /api/me/avatar` → presigned R2 URL → client uploads directly to R2 → on success saves `avatar_url` via `PATCH /api/me/profile`
- Avatar preview shown immediately (optimistic update)
- Remove avatar option: sets `avatar_url = null` → falls back to initials avatar

**Personal Details**
- Display Name: text input, max 100 chars, required
- Email: read-only text. To change, user clicks "Change email" link → verification flow: new email entered, confirmation sent to new address, on click link the email is updated. Multi-tenant username uniqueness validated server-side.
- Phone: optional, text input

**Preferences**
- Language: segmented control — `עברית` (he) | `English` (en). Default: `he`. Triggers `i18n.changeLanguage()` immediately on selection (preview); saved on form submit.
- Timezone: searchable select (all IANA timezones). Default: `Asia/Jerusalem`. Affects due date rendering, calendar display, and time entry timestamps shown in UI.

**Change Password**
- Current password (required if user has a password set)
- New password: min 8 chars, at least one digit
- Confirm new password: must match
- Submitted separately via `POST /api/me/change-password` — not part of the profile PATCH
- Success: inline success message. Error: inline error per field.

**Active Sessions** (sub-section of `/profile`)
- Lists active sessions from `user_sessions` table (spec 122 `session-security`)
- Columns: Device / Browser (from `device_name`), IP, Last seen, Created
- "Revoke" button per session (except current session)
- "Revoke all other sessions" button
- API: `GET /api/user/sessions`, `DELETE /api/user/sessions/:sessionId`

### API

```
PATCH /api/me/profile
  Body: { display_name?, phone?, locale?, timezone?, avatar_url? }
  Auth: any authenticated staff member
  Returns: updated user + user_preferences

POST  /api/me/avatar
  Body: { content_type: 'image/jpeg' | 'image/png' | 'image/webp', size: number }
  Returns: { upload_url: string, avatar_url: string }
  Validates: size <= 5_242_880 bytes; content_type in allowed set

POST  /api/me/change-password
  Body: { current_password: string, new_password: string }
  Returns: 200 OK | 400 (wrong current) | 422 (too weak)

GET   /api/user/sessions
  Returns: session[] (id, device_name, ip_address, country_code, created_at, last_active_at, is_current)
  Source: user_sessions table (spec 122)

DELETE /api/user/sessions/:sessionId
  Revokes the specified session (sets revoked_at, adds to KV blocklist)

DELETE /api/user/sessions
  Revokes all sessions except current
```

### Schema delta

No schema delta — both columns this page reads/writes are owned by `foundation-auth-rbac`:

- `user_preferences.locale` — `TEXT` (`'he' | 'en'`), nullable-inherit: `NULL` = use the tenant default (`tenants.settings.app_language`, owned by `settings-module`). This is the user's app-language override. Consumed, not re-added.
- `user_preferences.timezone` — `TEXT NOT NULL DEFAULT 'Asia/Jerusalem'`. Consumed, not re-added.

The profile read path resolves the effective language as `user_preferences.locale ?? tenants.settings.app_language ?? 'he'`; `timezone` is always concrete.

---

## 3. Notification Preferences

Notification preferences are fully specced in **spec 97 (`notification-preferences`)** at `/profile/notifications`. That spec owns the full type taxonomy, role-based row filtering, digest mode, and API. This section is intentionally removed to avoid duplication.

Route: `/profile/notifications` (canonical per spec 97).

---

## 4. My Work Page (`/my-work`)

Shown for MEMBER and CONTRACTOR roles. Replaces the full-module dashboard as their primary landing page. The sidebar Tasks item for CONTRACTOR links here instead of `/tasks`.

### Route

```
/my-work
```

### Layout

```
┌──────────────────────────────────────────────────────────────────────────┐
│  SIDEBAR                │  MY WORK                                       │
│  [My Work] ◀ active     │                                                │
│                         │  ┌── Today's Time ─────────────────────────┐  │
│                         │  │  [▶ Start timer]   Today: 3h 42m        │  │
│                         │  │  ─────────────────────────────────────  │  │
│                         │  │  09:00  Project Alpha – Design review   │  │
│                         │  │  10:30  Project Beta  – Dev standup     │  │
│                         │  └─────────────────────────────────────────┘  │
│                         │                                                │
│                         │  ┌── My Tasks ─────────────────────────────┐  │
│                         │  │  Sort: [Due date ▾]  Filter: [Status ▾] │  │
│                         │  │                                         │  │
│                         │  │  ▼ Project Alpha                        │  │
│                         │  │    [ ] Design mockups        Due: Jun 2 │  │
│                         │  │    [✓] Kickoff doc            Completed │  │
│                         │  │                                         │  │
│                         │  │  ▼ Project Beta                         │  │
│                         │  │    [ ] API integration       Due: Jun 5 │  │
│                         │  │    [ ] Write unit tests     No due date │  │
│                         │  │                                         │  │
│                         │  │  ▼ No Project                           │  │
│                         │  │    [ ] Review PRD             Due: Jun 1│  │
│                         │  └─────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────────┘
```

### My Tasks section

- **Scope**: Tasks where `assignee_id = current_user_id` AND `tenant_id = current_tenant_id`
- **CONTRACTOR scope**: Same filter — contractors only see tasks assigned to them
- **Grouping**: By project. Tasks with no project grouped under "No Project" / "ללא פרויקט"
- **Sort options**: Due date (default), Priority, Created date, Status
- **Filter options**: Status (Open / In Progress / Done / All), Project
- **Completed tasks**: Shown in project group, greyed, collapsed by default (toggle "Show completed")
- **Task row**: Checkbox (marks done), task title (links to task detail), project badge, due date chip (red if overdue), priority indicator
- **Clicking a task**: Opens task detail panel (slide-over) — same component used in full tasks board, rendered with MEMBER/CONTRACTOR permission scope (no reassign, no delete)

### Today's Time section

- Shown only if the `time-management` module is enabled for the tenant
- Displays today's time entries for the current user
- Running timer shown at top with elapsed time; Start/Stop button
- Entries list: start time, project + task label, duration
- CONTRACTOR: shows only their own entries (same as MEMBER — no difference here)
- Total today shown as accumulated duration

### API

```
GET /api/me/tasks
  Query: ?sort=due_date|priority|created_at|status
         &status=open|in_progress|done|all
         &projectId=uuid
  Returns: Task[] grouped by project, filtered to assignee = current user
  Scope enforced server-side (tenant_id + assignee_id)

GET /api/me/time-entries/today
  Returns: TimeEntry[] for today (user's timezone), sorted by started_at
  Includes running entry if active
```

---

## 5. CONTRACTOR-Specific Restrictions

### Permitted modules

A CONTRACTOR may access only:

| Module | Condition |
|--------|-----------|
| My Work (`/my-work`) | Always |
| Time Tracking (`/time`) | Always |
| Knowledge Base (`/kb`) | Only if `kb` module is enabled for the tenant |
| Profile (`/profile`) | Always |
| Notification Preferences (`/profile/notifications`) | Always |

All other routes are forbidden.

### Sidebar for CONTRACTOR

```
Sidebar
├── Logo / tenant branding
├── [My Work]            ← injected at top
├── Workspace
│   ├── [Tasks]          ← links to /my-work (alias)
│   └── [Time Tracking]
├── Resources
│   └── [Knowledge Base] ← only if module enabled
└── Profile (bottom avatar)
```

No Business group, no Financials group, no Settings, no Analytics, no Dashboard.

### Route guard

A React `<RequirePermission>` component wraps every protected route. For CONTRACTOR access to a forbidden route:

1. Server-side: API responds 403 for any endpoint outside permitted scope
2. Client-side: Route renders `<ForbiddenPage>` (403 page from `error-empty-states` spec) with message "אין לך גישה לעמוד זה" / "You don't have access to this page"
3. Direct URL access: `/customers`, `/invoices`, `/expenses`, `/projects`, `/marketing`, `/reports`, `/settings`, `/billing` all render the 403 page for CONTRACTOR

### Implementation

```ts
// packages/auth/src/permissions.ts

const CONTRACTOR_ALLOWED_ROUTES = [
  '/my-work',
  '/time',
  '/kb',
  '/profile',
  '/profile/notifications',
  '/invite/accept',
]

export function isRouteAllowedForRole(
  path: string,
  role: BuiltinRole
): boolean {
  if (role !== 'CONTRACTOR') return true // other roles use standard permission check
  return CONTRACTOR_ALLOWED_ROUTES.some(
    (allowed) => path === allowed || path.startsWith(allowed + '/')
  )
}
```

Client-side route guard in `zync-app/src/routes/_layout.tsx`:

```tsx
function RouteGuard({ children }: { children: ReactNode }) {
  const { role } = useAuth()
  const location = useLocation()

  if (!isRouteAllowedForRole(location.pathname, role)) {
    return <ForbiddenPage />
  }
  return children
}
```

API-side: each module's Hono middleware already calls `requirePermission(c, 'customers:read')` etc. CONTRACTOR has no such permissions, so all non-permitted API calls return 403 naturally without additional code.

---

## 6. Invite Acceptance Flow

### URL

```
zync.is/invite/accept?token={token}
```

The route lives in `zync-www` (not `zync-app`). This matches `foundation-auth-rbac` which routes all pre-auth flows (signup, login, invite) through `zync.is`. After acceptance, the user is redirected to `app.zync.is`.

### States

```
Token states:
  VALID + existing user     → Join Tenant screen
  VALID + new user          → Set Password screen
  EXPIRED                   → Expired Token screen
  INVALID / not found       → Invalid Token screen
  ALREADY_ACCEPTED          → Already Accepted screen
```

#### State: Valid token, existing user

```
┌──────────────────────────────────────────────┐
│              [Zync logo]                     │
│                                              │
│   You've been invited to join               │
│   Acme Corp                                 │
│   as  Member                                │
│                                              │
│   You are signed in as:                     │
│   alex@example.com                          │
│                                              │
│        [Accept Invitation]                   │
│                                              │
│   Not you?  [Sign in with a different       │
│              account]                        │
└──────────────────────────────────────────────┘
```

#### State: Valid token, new user

```
┌──────────────────────────────────────────────┐
│              [Zync logo]                     │
│                                              │
│   You've been invited to join               │
│   Acme Corp  as  Member                     │
│                                              │
│   Create your account                       │
│   ─────────────────────                     │
│   Full name    [________________]           │
│   Password     [________________]           │
│   Confirm      [________________]           │
│                                              │
│          [Create account & join]            │
│                                              │
│   Already have an account?  [Sign in]       │
└──────────────────────────────────────────────┘
```

#### State: Expired token

```
┌──────────────────────────────────────────────┐
│              [Zync logo]                     │
│                                              │
│   This invitation has expired               │
│                                              │
│   Invitations are valid for 7 days.         │
│   Ask your admin to send a new invite.      │
│                                              │
│            [Back to login]                  │
└──────────────────────────────────────────────┘
```

#### State: Already accepted

```
┌──────────────────────────────────────────────┐
│              [Zync logo]                     │
│                                              │
│   You've already joined this workspace.     │
│                                              │
│            [Go to app]                      │
└──────────────────────────────────────────────┘
```

### Token validation logic

1. API: `GET /api/invite/validate?token={token}` — hashes the token, looks up `invitations` by `token_hash`
2. Checks:
   - `accepted_at IS NULL` → not already accepted
   - `expires_at > now()` → not expired
   - If email in `users` table → existing user flow; else → new user flow
3. Returns: `{ tenantName, roleName, email, isNewUser, status: 'valid'|'expired'|'invalid'|'already_accepted' }`

### Accept invitation: existing user

```
POST /api/invite/accept
  Body: { token: string }
  Auth: authenticated session OR token-only (no session — see below)
```

If the current session's email matches the invitation email: creates `TenantMembership` record, marks `invitations.accepted_at = now()`, issues new JWT containing updated tenant list, redirects to `/{tenantSlug}/`.

If the current session's email does NOT match (user signed in as someone else): display "Not you?" UI. Clicking "Sign in with a different account" clears the session and redirects to `/auth/login?next=/invite/accept?token={token}`.

If no session: redirect to `/auth/login?next=/invite/accept?token={token}`.

### Accept invitation: new user

```
POST /api/invite/accept/new-user
  Body: { token: string, display_name: string, password: string }
  Auth: none required
```

1. Validates token (same as above)
2. Creates `users` record (`email` from invitation, `password_hash` of supplied password, `email_verified_at = now()` — email verified implicitly via invitation link)
3. Creates `TenantMembership` record (`user_id`, `tenant_id`, `role_id` from invitation, `status = 'active'`)
4. Marks `invitations.accepted_at = now()`
5. Issues JWT pair (access + refresh token)
6. Returns `{ access_token, refresh_token }` → client stores, redirects to `/{tenantSlug}/`

### Password requirements (new user)

- Minimum 8 characters
- At least one digit
- Validation inline (on blur + on submit)
- Error messages in Hebrew (default) or English based on browser locale until user sets preference

### Token expiry and re-invite

- Invitations expire after 7 days (`expires_at = created_at + interval '7 days'`)
- Expired invitation → admin must resend via `POST /api/users/invitations/:invitationId/resend` (admin action in Settings → Users)
- Resend creates a new invitation record (new token, new 7-day expiry); does not reuse old token
- Old `invitations` row stays as audit record with `expires_at` in the past

### Schema (from foundation-auth-rbac — no new columns needed)

```sql
invitations (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  email TEXT NOT NULL,
  role_id UUID NOT NULL,
  token_hash TEXT NOT NULL,   -- SHA-256 of raw token
  expires_at TIMESTAMPTZ NOT NULL,
  accepted_at TIMESTAMPTZ,    -- NULL = not yet accepted
  created_by UUID NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
)
```

No schema delta required. The existing `invitations` table supports all flows.

---

## 7. Schema Additions Summary

### `user_preferences` table additions

```sql
-- No DDL here. All columns this page touches are owned by foundation-auth-rbac:
-- user_preferences.locale     TEXT ('he'|'en'), nullable-inherit (NULL = tenant default) ← already defined
-- user_preferences.timezone   TEXT NOT NULL DEFAULT 'Asia/Jerusalem'                     ← already defined
-- user_preferences.notification_channels JSONB                                           ← already defined
-- user_preferences.notification_prefs JSONB                                              ← already defined
```

**Note:** `locale` and `timezone` are scalar columns (not JSONB keys) because they are read on every request (for i18n middleware and timestamp rendering) and benefit from simple column-level resolution. `locale` is nullable-inherit (resolves to the tenant default when `NULL`); `timezone` is concrete (`NOT NULL`).

### `refresh_tokens` table additions (from settings-module foundation delta)

```sql
ALTER TABLE refresh_tokens
  ADD COLUMN IF NOT EXISTS user_agent TEXT,
  ADD COLUMN IF NOT EXISTS ip TEXT,
  ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
```

This is already listed as a foundation delta in `settings-module`. Confirmed here as a dependency for the Active Sessions UI in `/profile`.

---

## 8. Design Decisions

| # | Decision | Rationale |
|---|----------|-----------|
| 1 | `locale` and `timezone` are scalar columns, not JSONB | Avoids JSONB path queries in i18n middleware; `locale` resolves to the tenant default when `NULL`, `timezone` is concrete; both foundation-owned, no new column |
| 2 | Invite acceptance lives at `app.zync.is` not `zync-www` | One fewer redirect after acceptance; user lands directly in app context |
| 3 | Email change via verification flow | Changing email requires confirming ownership of the new address; multi-tenant uniqueness checked at submission time |
| 4 | CONTRACTOR Tasks nav item links to `/my-work` | CONTRACTOR has no `projects:read`; the full board requires project context to be useful. My Work provides scoped, coherent entry point |
| 5 | In-app notification channel cannot be toggled off | Core product signal; disabling would create silent task assignments. All other channels remain user-configurable |
| 6 | Telegram column disabled (not hidden) when not connected | Informs user the channel exists; motivates connection. Hiding creates discovery problem |
| 7 | New user via invitation: `email_verified_at = now()` | Email address was confirmed by the invite link click — clicking a link sent to that address is verification |
| 8 | CONTRACTOR route guard: client-side renders 403, not redirect | Avoids redirect loops; 403 page is informative rather than confusing "you have been redirected" |
| 9 | `My Work` injected at top of sidebar for MEMBER + CONTRACTOR | These roles' primary context is their personal task queue; it should be the first item they see |
| 10 | Notification preferences accessible without `settings:read` | Profile and notification preferences are personal, not organizational settings; access based on authentication, not settings permission |
| 11 | Separate `contractor_project_assignments` table | Rather than using task-assignee data alone, explicit project grants let managers pre-authorize entire projects. CONTRACTOR only sees tasks within granted projects (defense-in-depth for data isolation) |

---

## 9. Component Inventory

| Component | Location | Notes |
|-----------|----------|-------|
| `ProfilePage` | `zync-app/src/pages/ProfilePage.tsx` | Personal details + preferences + password |
| `AvatarUploader` | `zync-app/src/components/AvatarUploader.tsx` | R2 presigned upload flow |
| `SessionsList` | `zync-app/src/components/SessionsList.tsx` | Active sessions table + revoke |
| `NotificationPreferencesPage` | `zync-app/src/pages/NotificationPreferencesPage.tsx` | Per-type channel toggles |
| `MyWorkPage` | `zync-app/src/pages/MyWorkPage.tsx` | Tasks + time sections |
| `MyTasksSection` | `zync-app/src/components/MyTasksSection.tsx` | Grouped task list |
| `TodayTimeSection` | `zync-app/src/components/TodayTimeSection.tsx` | Time entries + timer |
| `ForbiddenPage` | `zync-app/src/pages/ForbiddenPage.tsx` | 403 page (from error-empty-states) |
| `InviteAcceptPage` | `zync-app/src/pages/InviteAcceptPage.tsx` | All invite states |
| `RouteGuard` | `zync-app/src/routes/_layout.tsx` | CONTRACTOR route enforcement |
| `usePermission` | `packages/auth/src/hooks.ts` | Per-permission boolean hook |
| `isRouteAllowedForRole` | `packages/auth/src/permissions.ts` | Route allow-list for CONTRACTOR |

---

## Contractor-Specific Extensions

### New DB Tables

```sql
-- Explicit project access grants for contractors (supplement to task-level assignment)
CREATE TABLE contractor_project_assignments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  contractor_user_id UUID NOT NULL REFERENCES users(id),
  project_id UUID NOT NULL REFERENCES projects(id),
  granted_by UUID NOT NULL REFERENCES users(id),
  granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE(contractor_user_id, project_id)
);

-- Which KB spaces contractors can access (beyond default published articles)
CREATE TABLE kb_space_contractor_access (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  space_id UUID NOT NULL REFERENCES kb_spaces(id),
  granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  granted_by UUID NOT NULL REFERENCES users(id)
);
```

### Contractor Simplified Onboarding Flow

Contractors use a stripped-down onboarding path — no wizard, no module selection, no workspace setup:

1. Contractor invited via email → receives invite link → sets password (new user flow from §6)
2. First login redirects to `/profile` to complete name / phone / avatar
3. After saving profile, redirects to `/tasks` (alias: `/my-work`) — their assigned tasks list
4. No module selection step, no workspace setup wizard (unlike OWNER onboarding which has a multi-step wizard)

### Architecture Decision Addition

The following row is added to the Design Decisions table (§8):

| 11 | Separate `contractor_project_assignments` table | Rather than using task-assignee data alone, explicit project grants let managers pre-authorize entire projects. CONTRACTOR only sees tasks within granted projects (defense-in-depth for data isolation) |

---

## 10. Open Questions (deferred to v2)

- **Telegram connect flow**: The Telegram channel in notification preferences requires a bot link flow. The `system-communications-notifications` spec defines the adapter; the UI connection flow (QR code or `/start` deep link) is in scope for spec 35 (notification-center).
- **Custom roles**: `settings-module` mentions `/settings/roles` for custom roles. The sidebar visibility matrix in this spec covers built-in roles only. Custom roles inherit the permission-based visibility logic automatically via `usePermission` — no special casing needed.
- **Email change**: In scope via a verification flow. Changing email sends a confirmation link to the new address. Once confirmed, the email is updated across all tenant memberships. Multi-tenant uniqueness enforced at submission time.
