# Staff Portal Detail — Implementation Plan

**Spec:** docs/specs/2026-05-31-staff-portal-detail.md  ·  **Slug:** staff-portal-detail  ·  **Wave:** 10
**Depends on:** app-shell, foundation-auth-rbac, settings-module, system-communications-notifications, tenant-portals

## Goal
Turn `app.zync.is` into the role-aware staff portal: a per-role sidebar visibility matrix driven by `usePermission`, a personal `/profile` page (details, preferences, password, avatar, active sessions), a `/my-work` landing page for MEMBER and CONTRACTOR roles, hard CONTRACTOR route/data restrictions (route allow-list + explicit project/KB grants), and the public invite-acceptance flow. No new application is created — this spec pins the concrete UI/RBAC surface on top of the existing app shell and auth foundation.

## Architecture
The portal is the existing `zync-app` (Vite+React) plus `zync-www` (Astro) for the pre-auth invite flow, served by the Hono API worker. Sidebar items are conditionally rendered with the `usePermission` hook (from `@zync/auth`); each item declares a minimum permission. CONTRACTOR gets a stripped sidebar plus a client `RouteGuard` that renders `ForbiddenPage` (from `error-empty-states`) for non-allow-listed routes, backstopped by `requirePermission` on every API route. Profile reads/writes `users` + `user_preferences` (PK `(user_id, tenant_id)`, canonical from `foundation-auth-rbac`); `locale` and `timezone` are foundation-owned columns, consumed here (no ALTER). Active Sessions is backed by the canonical `refresh_tokens` table extended (per settings-module foundation delta) with `user_agent`, `ip`, `last_seen_at`; revoke sets `revoked_at` and pushes the token hash to the auth KV blocklist. My Work queries `tasks` (`assignee_id = current user`) grouped by `projects`, and `time_entries` (`user_id`, today, by `started_at`). Avatar upload uses an R2 presigned PUT via the `STORAGE` binding. Invite acceptance hashes the raw token (`hashToken`), looks up `invitations` by `token_hash`, and on accept creates a `tenant_memberships` row and issues a JWT pair (`signSession`). Two new tables — `contractor_project_assignments` and `kb_space_contractor_access` — provide defense-in-depth data isolation for contractors, joined against `tasks`/`projects` and `kb_spaces` respectively.

Upstream tables consumed: `users`, `user_preferences`, `tenant_memberships`, `invitations`, `refresh_tokens`, `roles`, `role_permissions`, `permissions`, `tenants`, `projects`, `tasks`, `time_entries`, `kb_spaces`, `tenant_modules`.
Upstream exports consumed: `usePermission`/`requirePermission`, `authMiddleware`, `signSession`, `hashToken`, `hashPassword`, `verifyPassword`, `buildSessionPayload`, `RoleId`, `UserRole`, `SessionPayload`, `STORAGE`, `RATELIMIT_KV`, `ForbiddenPage`/`ErrorPage`, `getEnabledModuleIds`/`useModuleEnabled`, `Button`, `Input`, `Select`, `Form`, `FormField`, `Avatar`, `Card`, `Dialog`, `Sheet`, `EmptyState`, `Toast`/`toast`, `Skeleton`, `useDirection`, `LocaleProvider`, `serializeTask`.

## Tech Stack
- **Apps:** `zync-app` (Vite + React, TanStack Router/Query) for in-app surfaces; `zync-www` (Astro + React island) for `/invite/accept`.
- **API:** Hono on Cloudflare Workers (`apps/zync-api`), Drizzle ORM against Neon Postgres via Hyperdrive.
- **Packages:** `@zync/auth` (permissions hook, route allow-list, middleware), `@zync/db` (Drizzle schema + queries), `@zync/ui` (form/table primitives), `@zync/types`.
- **Cloudflare bindings:** `STORAGE` (R2, avatar uploads), `RATELIMIT_KV` (session blocklist + invite/password rate limits), Hyperdrive (`DB`).
- **Validation:** Zod schemas in every route (`require-zod-validation-in-routes`). All DB access via `tenantQuery`/`systemQuery` helpers (`no-raw-drizzle-from-routes`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 10a — schema | 1 | `packages/db/src/schema/*`, migrations | No (blocks all) |
| 10b — auth/RBAC core | 2, 3 | `packages/auth/src/permissions.ts`, `hooks.ts`, `middleware.ts` | Tasks 2,3 parallel after 1 |
| 10c — profile API | 4, 5, 6 | `apps/zync-api/src/routes/me.*.ts` | Parallel after 1 |
| 10d — my-work API | 7 | `apps/zync-api/src/routes/me.tasks.ts`, `me.time.ts` | Parallel after 1 |
| 10e — invite API | 8 | `apps/zync-api/src/routes/invite.ts` | Parallel after 1 |
| 10f — contractor grants API | 9 | `apps/zync-api/src/routes/contractor-grants.ts` | Parallel after 1 |
| 10g — sidebar + guard UI | 10, 11 | `zync-app/src/components/Sidebar.tsx`, `routes/_layout.tsx` | After 2,3 |
| 10h — profile UI | 12, 13, 14 | `zync-app/src/pages/ProfilePage.tsx`, components | After 4,5,6 |
| 10i — my-work UI | 15 | `zync-app/src/pages/MyWorkPage.tsx`, components | After 7,10 |
| 10j — invite UI | 16 | `zync-www` island | After 8 |
| 10k — onboarding redirect | 17 | `zync-app/src/routes/index.tsx` | After 10,15 |

## Tasks

### Task 1: Schema — user_preferences delta, refresh_tokens session columns, contractor grant tables
**Blocks:** 2, 4, 5, 6, 7, 8, 9  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/auth.ts` (user_preferences, refresh_tokens)
- Create: `packages/db/src/schema/contractor.ts`
- Modify: `packages/db/src/schema/index.ts` (export new tables)
- Create: `packages/db/migrations/0XXX_staff_portal_detail.sql`
**Steps:**
- [ ] No `user_preferences` DDL: `locale` (`TEXT 'he'|'en'`, nullable-inherit) and `timezone` (`TEXT NOT NULL DEFAULT 'Asia/Jerusalem'`) are both owned by `foundation-auth-rbac` — consumed, not altered here.
- [ ] Add `user_agent`, `ip`, `last_seen_at` to `refresh_tokens` (settings-module foundation delta; idempotent `ADD COLUMN IF NOT EXISTS`).
- [ ] Create `contractor_project_assignments` and `kb_space_contractor_access` tables.
- [ ] Mirror all columns in the Drizzle schema; export `contractorProjectAssignments`, `kbSpaceContractorAccess`.
- [ ] Add indexes for the active-sessions and contractor-scope lookups.
**Schema / Interfaces:**
```sql
-- user_preferences: NO DDL — locale and timezone are both owned by foundation-auth-rbac.
--   locale    TEXT ('he'|'en'), nullable-inherit: NULL = tenant default (tenants.settings.app_language)
--   timezone  TEXT NOT NULL DEFAULT 'Asia/Jerusalem'
-- Consumed here, not altered.

-- refresh_tokens delta (Active Sessions UI source; canonical session table)
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;

CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_active
  ON refresh_tokens (user_id, tenant_id) WHERE revoked_at IS NULL;

-- 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)
);
CREATE INDEX idx_cpa_tenant_contractor
  ON contractor_project_assignments (tenant_id, contractor_user_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_by UUID NOT NULL REFERENCES users(id),
  granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, space_id)
);
```
**Acceptance:**
- [ ] Migration applies cleanly on a fresh Neon branch; re-running is a no-op (idempotent ADDs).
- [ ] `user_preferences.locale` resolves to the tenant default when `NULL`; `user_preferences.timezone` is `NOT NULL` with the foundation default — both consumed unchanged.
- [ ] Drizzle `pnpm db:generate` produces no diff after migration (schema matches DB).

### Task 2: Permissions data — built-in role permission sets + route allow-list
**Blocks:** 3, 10, 11  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/permissions.ts`
- Modify: `packages/db/src/seed/permissions.ts` (consumed by `seedPermissions`/`seedSystemRoles`)
**Steps:**
- [ ] Ensure the permission catalog seeded by `seedPermissions` contains every permission string in the spec matrix (add any missing: `tasks:assign`, `time:manage`, `payouts:read`, `payouts:manage`, `kb:write`, `marketing:read`, `tickets:read`, `users:freeze`, `users:delete`, `webhooks:manage`).
- [ ] Make `seedSystemRoles` assign the canonical per-role permission sets exactly as the matrix below (OWNER, ADMIN, MEMBER, VIEWER, CONTRACTOR).
- [ ] Export `CONTRACTOR_ALLOWED_ROUTES` and `isRouteAllowedForRole(path, role)` from `permissions.ts`.
- [ ] `isRouteAllowedForRole` returns `true` for any non-CONTRACTOR role (those use the normal permission check); for CONTRACTOR it matches the allow-list with prefix semantics.
**Schema / Interfaces:**
```ts
// packages/auth/src/permissions.ts
export const CONTRACTOR_ALLOWED_ROUTES = [
  '/my-work',
  '/time',
  '/kb',
  '/profile',
  '/profile/notifications',
  '/invite/accept',
] as const

export function isRouteAllowedForRole(path: string, role: UserRole): boolean {
  if (role !== 'CONTRACTOR') return true
  return CONTRACTOR_ALLOWED_ROUTES.some(
    (allowed) => path === allowed || path.startsWith(allowed + '/'),
  )
}

// Canonical built-in role permission matrix (seeded by seedSystemRoles).
// CONTRACTOR's tasks:read / time:read are scoped to own rows at query time.
export const ROLE_PERMISSIONS: Record<UserRole, readonly string[]> = {
  OWNER: [
    'tasks:read','tasks:write','tasks:assign','projects:read','projects:write',
    'time:read','time:track','time:manage','customers:read','invoices:read',
    'expenses:read','payouts:read','payouts:manage','billing:read','billing:manage',
    'kb:read','kb:write','reports:read','marketing:read','tickets:read',
    'calendar:read','settings:read','settings:write','users:read','users:invite',
    'users:manage','users:freeze','users:delete','webhooks:manage',
  ],
  ADMIN: [
    'tasks:read','tasks:write','tasks:assign','projects:read','projects:write',
    'time:read','time:track','time:manage','customers:read','invoices:read',
    'expenses:read','payouts:read','payouts:manage','kb:read','kb:write',
    'reports:read','marketing:read','tickets:read','calendar:read',
    'settings:read','settings:write','users:read','users:invite','users:manage',
  ],
  MEMBER: [
    'tasks:read','tasks:write','tasks:assign','projects:read',
    'time:read','time:track','kb:read','calendar:read',
  ],
  VIEWER: [
    'tasks:read','projects:read','time:read','customers:read','invoices:read',
    'expenses:read','payouts:read','kb:read','reports:read','marketing:read',
    'tickets:read','calendar:read',
  ],
  CONTRACTOR: [
    'tasks:read','time:read','time:track','kb:read',
  ],
}
```
**Acceptance:**
- [ ] After seed, querying `role_permissions` for each system role returns exactly the sets above.
- [ ] `isRouteAllowedForRole('/customers','CONTRACTOR') === false`; `isRouteAllowedForRole('/kb/space-x','CONTRACTOR') === true`; `isRouteAllowedForRole('/customers','ADMIN') === true`.

### Task 3: usePermission hook + nav-item permission map
**Blocks:** 10  ·  **Blocked by:** 2
**Files:**
- Modify: `packages/auth/src/hooks.ts`
**Steps:**
- [ ] Implement/confirm `usePermission(permission: string): boolean` reading the current session's permission list from auth context.
- [ ] Add `usePermissionAny(perms: string[]): boolean` for OR-conditions (Dashboard = `projects:read` OR `tasks:read`; Time Tracking = `time:read` OR `time:track`).
- [ ] Export `NAV_PERMISSION_MAP` mapping each sidebar item id to its required permission(s), transcribed from the matrix.
**Schema / Interfaces:**
```ts
// packages/auth/src/hooks.ts
export function usePermission(permission: string): boolean
export function usePermissionAny(permissions: string[]): boolean

export type NavRequirement =
  | { kind: 'always' }
  | { kind: 'perm'; perm: string }
  | { kind: 'anyPerm'; perms: string[] }
  | { kind: 'module'; moduleId: string; perm: string } // KB for CONTRACTOR

export const NAV_PERMISSION_MAP: Record<string, NavRequirement> = {
  myWork:          { kind: 'always' }, // visibility further gated to MEMBER/CONTRACTOR in Sidebar
  dashboard:       { kind: 'anyPerm', perms: ['projects:read','tasks:read'] },
  projects:        { kind: 'perm', perm: 'projects:read' },
  tasks:           { kind: 'perm', perm: 'tasks:read' },
  timeTracking:    { kind: 'anyPerm', perms: ['time:read','time:track'] },
  calendar:        { kind: 'perm', perm: 'calendar:read' },
  analytics:       { kind: 'perm', perm: 'reports:read' },
  customers:       { kind: 'perm', perm: 'customers:read' },
  marketing:       { kind: 'perm', perm: 'marketing:read' },
  support:         { kind: 'perm', perm: 'tickets:read' },
  invoices:        { kind: 'perm', perm: 'invoices:read' },
  expenses:        { kind: 'perm', perm: 'expenses:read' },
  payouts:         { kind: 'perm', perm: 'payouts:read' },
  billing:         { kind: 'perm', perm: 'billing:read' },
  kb:              { kind: 'module', moduleId: 'kb', perm: 'kb:read' },
  reports:         { kind: 'perm', perm: 'reports:read' },
  settings:        { kind: 'perm', perm: 'settings:read' },
  profile:         { kind: 'always' },
}
```
**Acceptance:**
- [ ] `usePermission('billing:read')` is `true` only for OWNER session.
- [ ] `usePermissionAny(['time:read','time:track'])` is `true` for CONTRACTOR.

### Task 4: Profile API — PATCH /api/me/profile + avatar presign + change-password
**Blocks:** 12, 13  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/me.profile.ts`
- Modify: `apps/zync-api/src/index.ts` (mount routes under `authMiddleware`)
**Steps:**
- [ ] `PATCH /api/me/profile`: zod-validate body, update `users.display_name`/`users.phone`/`users.avatar_url` and `user_preferences.locale`/`user_preferences.timezone` for `(user_id, tenant_id)`; return joined user + preferences. (`locale`/`timezone` are also writable via the canonical `PATCH /api/user/preferences`/`updateUserPreferencesSchema` owned by `dark-light-theme`; same columns, distinct surfaces.)
- [ ] `POST /api/me/avatar`: validate `content_type ∈ {image/jpeg,image/png,image/webp}` and `size <= 5_242_880`; mint an R2 presigned PUT against `STORAGE` for key `avatars/{tenantId}/{userId}/{uuid}.{ext}`; return `{ upload_url, avatar_url }`. Client PUTs the file then calls PATCH with `avatar_url`.
- [ ] `POST /api/me/change-password`: require `current_password` (when a hash exists) → `verifyPassword`; enforce new password ≥8 chars + ≥1 digit; `hashPassword` and persist; return 200 / 400 (wrong current) / 422 (too weak). Rate-limit via `RATELIMIT_KV`.
- [ ] Email change: `POST /api/me/email/change-request` (sends verification link to the new address via `sendEmail`, stores a pending token) and `POST /api/me/email/confirm` (validates token, enforces multi-tenant uniqueness of `users.email`, updates email across memberships). Hebrew-default copy.
**Schema / Interfaces:**
```ts
const profilePatchSchema = z.object({
  display_name: z.string().min(1).max(100).optional(),
  phone: z.string().max(40).optional(),
  locale: z.enum(['he','en']).nullable().optional(),  // null = inherit tenant default
  timezone: z.string().optional(),     // IANA tz id
  avatar_url: z.string().url().nullable().optional(),
})
const avatarSchema = z.object({
  content_type: z.enum(['image/jpeg','image/png','image/webp']),
  size: z.number().int().positive().max(5_242_880),
})
const changePasswordSchema = z.object({
  current_password: z.string().optional(),
  new_password: z.string().min(8).regex(/\d/, 'must contain a digit'),
})
// PATCH /api/me/profile  -> { user, preferences }
// POST  /api/me/avatar   -> { upload_url: string, avatar_url: string }
// POST  /api/me/change-password -> 200 | 400 | 422
```
**Acceptance:**
- [ ] Oversized avatar (>5 MB) or wrong content-type returns 422 without minting a URL.
- [ ] Wrong current password returns 400; weak new password returns 422; valid change returns 200 and re-login works with the new password.
- [ ] PATCH persists `locale`/`timezone` to `user_preferences` and is scoped to the caller's `(user_id, tenant_id)`.

### Task 5: Active Sessions API — list + revoke (refresh_tokens backed)
**Blocks:** 14  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/me.sessions.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `GET /api/user/sessions`: list the caller's non-revoked `refresh_tokens` rows; map to `{ id, device_name, ip_address, country_code, created_at, last_active_at, is_current }`. `device_name` derived from `user_agent`; `last_active_at` from `last_seen_at`; `is_current` true when the row's `token_hash` matches the hash of the caller's presented refresh token.
- [ ] `DELETE /api/user/sessions/:sessionId`: set `revoked_at = now()` on the row (must belong to caller) and push its `token_hash` to the `RATELIMIT_KV` blocklist; reject revoking the current session with 400.
- [ ] `DELETE /api/user/sessions`: revoke all the caller's sessions except the current one; blocklist each.
- [ ] All three enforce ownership (`refresh_tokens.user_id = session.userId AND tenant_id = session.tenantId`).
**Schema / Interfaces:**
```ts
type SessionDTO = {
  id: string
  device_name: string | null
  ip_address: string | null
  country_code: string | null
  created_at: string
  last_active_at: string | null
  is_current: boolean
}
// GET    /api/user/sessions          -> SessionDTO[]
// DELETE /api/user/sessions/:sessionId -> 204 | 400 (current) | 404
// DELETE /api/user/sessions          -> { revoked: number }
```
**Acceptance:**
- [ ] Listing returns only the caller's non-revoked sessions with exactly one `is_current: true`.
- [ ] Revoking a session sets `revoked_at` and the token is rejected on next refresh (KV blocklist hit).
- [ ] Attempting to revoke the current session via `:sessionId` returns 400.

### Task 6: i18n/timezone read path — locale + timezone on session
**Blocks:** 12  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/auth/src/middleware.ts` (or session builder used by `buildSessionPayload`)
- Modify: `apps/zync-api/src/routes/auth.signup.ts` (new-user defaults)
**Steps:**
- [ ] Load `user_preferences.locale` and `user_preferences.timezone` when building the session/context. Resolve the effective language as `locale ?? tenants.settings.app_language ?? 'he'` (locale is nullable-inherit); `timezone` is concrete. i18n middleware and timestamp rendering read these scalar columns (not JSONB).
- [ ] On new-user signup and invite-accept (Task 8), leave `user_preferences.locale` `NULL` (inherits the tenant default); `timezone` falls to its `NOT NULL DEFAULT 'Asia/Jerusalem'` when the INSERT omits it.
- [ ] Expose the resolved `locale`/`timezone` in the `GET /api/auth/me` response payload.
**Acceptance:**
- [ ] `GET /api/auth/me` returns the resolved `locale` and `timezone` for the caller.
- [ ] A brand-new user row has `locale=NULL` (resolves to the tenant default) and `timezone='Asia/Jerusalem'`.

### Task 7: My Work API — GET /api/me/tasks + GET /api/me/time-entries/today
**Blocks:** 15  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/me.work.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `GET /api/me/tasks`: filter `tasks` to `tenant_id = session.tenantId AND assignee_id = session.userId`. For CONTRACTOR, additionally restrict to `project_id IN (SELECT project_id FROM contractor_project_assignments WHERE contractor_user_id = session.userId AND tenant_id = session.tenantId)` OR `project_id IS NULL` when the task is directly assigned (defense-in-depth). Support `sort ∈ {due_date,priority,created_at,status}` (default `due_date`), `status ∈ {open,in_progress,done,all}`, `projectId`. Group by project (null project → "No Project"). Serialize with `serializeTask`.
- [ ] `GET /api/me/time-entries/today`: return `time_entries` where `user_id = session.userId AND tenant_id = session.tenantId` and `started_at` falls within "today" in the user's `timezone`; include the running entry (`stopped_at IS NULL`); sort by `started_at`; compute total accumulated `duration_seconds`.
- [ ] Both routes are scoped purely by `tasks:read`/`time:read` plus the own-row filter; no extra permission needed.
**Schema / Interfaces:**
```ts
const myTasksQuery = z.object({
  sort: z.enum(['due_date','priority','created_at','status']).default('due_date'),
  status: z.enum(['open','in_progress','done','all']).default('open'),
  projectId: z.string().uuid().optional(),
})
// GET /api/me/tasks -> { groups: { projectId: string|null, projectName: string|null, tasks: TaskObject[] }[] }
// GET /api/me/time-entries/today -> { entries: TimeEntry[], running: TimeEntry|null, totalSeconds: number }
```
**Acceptance:**
- [ ] A CONTRACTOR only receives tasks assigned to them whose project is in their grant set; tasks in non-granted projects are absent even when assigned.
- [ ] `status=open` excludes done tasks; `status=all` includes them; default sort is by due date with overdue first.
- [ ] Today's entries respect the user's timezone boundary and include the active (running) entry.

### Task 8: Invite acceptance API — validate / accept / accept-new-user / resend
**Blocks:** 16  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/invite.ts`
- Modify: `apps/zync-api/src/routes/users.ts` (resend endpoint)
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `GET /api/invite/validate?token=`: `hashToken(raw)` → look up `invitations` by `token_hash`; compute status `valid|expired|invalid|already_accepted` from `accepted_at IS NULL` and `expires_at > now()`; resolve `isNewUser` by checking `users.email`; return `{ tenantName, roleName, email, isNewUser, status }`. No raw token echoed back.
- [ ] `POST /api/invite/accept` (existing user): require an authenticated session whose email matches `invitations.email`. On match: insert `tenant_memberships (user_id, tenant_id, role_id, status='active')`, set `invitations.accepted_at = now()`, issue a fresh JWT (`signSession`/`buildSessionPayload`) with the updated tenant list. Email mismatch → respond with a `mismatch` marker (UI shows "Not you?"). No session → 401 with `login_required` so the client redirects to `/auth/login?next=...`.
- [ ] `POST /api/invite/accept/new-user` (no auth): validate token; enforce password ≥8 chars + ≥1 digit; create `users` (`email` from invite, `password_hash`, `email_verified_at = now()`); insert `tenant_memberships`; set `accepted_at`; issue JWT pair; return `{ access_token, refresh_token, tenantSlug }`. Initialize `user_preferences` defaults (Task 6).
- [ ] `POST /api/users/invitations/:invitationId/resend` (requires `users:invite`): create a NEW `invitations` row (new raw token, `expires_at = now() + interval '7 days'`); leave the old row as audit; send email via `sendEmail`.
- [ ] Rate-limit `validate` and both accept endpoints via `RATELIMIT_KV`; compare token hashes with `timingSafeEqual` semantics (lookup by hash, never string-compare raw tokens).
**Schema / Interfaces:**
```sql
-- Consumed unchanged from foundation-auth-rbac (NO new columns):
-- invitations (id, tenant_id, email, role_id, token_hash, expires_at, accepted_at, created_by, created_at)
-- tenant_memberships (user_id, tenant_id, role_id, status, freeze_reason, created_at)
```
```ts
const acceptSchema = z.object({ token: z.string().min(1) })
const acceptNewUserSchema = z.object({
  token: z.string().min(1),
  display_name: z.string().min(1).max(100),
  password: z.string().min(8).regex(/\d/),
})
// GET  /api/invite/validate -> { tenantName, roleName, email, isNewUser, status }
// POST /api/invite/accept -> { tenantSlug } | 401 login_required | 200 { mismatch:true }
// POST /api/invite/accept/new-user -> { access_token, refresh_token, tenantSlug }
// POST /api/users/invitations/:invitationId/resend -> { invitationId } (requires users:invite)
```
**Acceptance:**
- [ ] Expired token returns `status:'expired'`; already-accepted returns `status:'already_accepted'`; unknown hash returns `status:'invalid'`.
- [ ] New-user accept creates a `users` row with `email_verified_at` set and an active `tenant_memberships` row, and returns a working JWT pair.
- [ ] Resend creates a new row with a future `expires_at`; the old row is untouched and its old token no longer validates.

### Task 9: Contractor grants API — project + KB-space access management
**Blocks:** 15  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/contractor-grants.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `POST /api/contractors/:userId/projects` (requires `users:manage`): insert `contractor_project_assignments (tenant_id, contractor_user_id, project_id, granted_by)`; ignore duplicate (UNIQUE) idempotently.
- [ ] `DELETE /api/contractors/:userId/projects/:projectId` (requires `users:manage`): remove the grant.
- [ ] `GET /api/contractors/:userId/projects` (requires `users:read`): list granted projects.
- [ ] `POST /api/contractors/kb-spaces` and `DELETE /api/contractors/kb-spaces/:spaceId` (requires `kb:write`): manage `kb_space_contractor_access` rows.
- [ ] Provide a query helper `getContractorProjectIds(tenantId, userId)` used by Task 7's My Work scoping.
**Schema / Interfaces:**
```ts
// POST   /api/contractors/:userId/projects { project_id } -> 201
// DELETE /api/contractors/:userId/projects/:projectId -> 204
// GET    /api/contractors/:userId/projects -> { projects: {project_id,project_name,granted_at}[] }
// POST   /api/contractors/kb-spaces { space_id } -> 201
// DELETE /api/contractors/kb-spaces/:spaceId -> 204
export function getContractorProjectIds(tenantId: string, userId: string): Promise<string[]>
```
**Acceptance:**
- [ ] Granting the same project twice is a no-op (UNIQUE honored, no error surfaced).
- [ ] `getContractorProjectIds` returns exactly the granted project ids for the tenant/user.

### Task 10: Sidebar — role-aware visibility + CONTRACTOR My Work injection
**Blocks:** 15, 17  ·  **Blocked by:** 3
**Files:**
- Modify: `zync-app/src/components/Sidebar.tsx`
- Modify: `zync-app/src/components/SidebarNavItem.tsx`
**Steps:**
- [ ] For each nav item, evaluate its `NavRequirement` from `NAV_PERMISSION_MAP` via `usePermission`/`usePermissionAny`; hide the item entirely when the requirement fails.
- [ ] KB item: also gate on `useModuleEnabled('kb')`; for CONTRACTOR render it only when both `kb:read` and the module are on.
- [ ] Inject a **My Work** item at the very top (above Workspace) for MEMBER and CONTRACTOR (`role === 'MEMBER' || role === 'CONTRACTOR'`).
- [ ] For CONTRACTOR, the **Tasks** item links to `/my-work` (alias) instead of `/tasks`; hide the Business, Financials, Settings, Analytics, and Dashboard items (these fall out naturally from the permission checks but assert via the matrix).
- [ ] Mark items active by route; apply `aria-current="page"` on the active item; group headers use `role="group"` with accessible names; preserve `useDirection` RTL ordering.
**Acceptance:**
- [ ] OWNER sees Billing; ADMIN does not. CONTRACTOR sees only My Work, Tasks(→/my-work), Time Tracking, and KB (when enabled), plus Profile.
- [ ] Disabling the `kb` module hides Knowledge Base for all roles.
- [ ] Active nav item carries `aria-current="page"`; sidebar nav has correct DOM order under RTL.

### Task 11: RouteGuard — client-side CONTRACTOR enforcement
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Modify: `zync-app/src/routes/_layout.tsx`
- Modify: `zync-app/src/pages/ForbiddenPage.tsx` (wire to `ErrorPage`/403 from error-empty-states)
**Steps:**
- [ ] Wrap the authenticated `<Outlet />` in a `RouteGuard` that reads `role` (from `useAuth`) and `location.pathname`.
- [ ] When `!isRouteAllowedForRole(pathname, role)`, render `<ForbiddenPage />` (403) instead of the route content — do NOT redirect (avoids loops; Design Decision 8).
- [ ] `ForbiddenPage` shows bilingual copy: "אין לך גישה לעמוד זה" / "You don't have access to this page", with a link back to the role's landing page.
- [ ] Ensure focus moves to the 403 heading on render (a11y) and copy respects `prefers-reduced-motion` for any transition.
**Schema / Interfaces:**
```tsx
function RouteGuard({ children }: { children: ReactNode }) {
  const { role } = useAuth()
  const { pathname } = useLocation()
  if (!isRouteAllowedForRole(pathname, role)) return <ForbiddenPage />
  return <>{children}</>
}
```
**Acceptance:**
- [ ] A CONTRACTOR navigating directly to `/customers`, `/invoices`, `/expenses`, `/projects`, `/marketing`, `/reports`, `/settings`, or `/billing` sees the 403 page (no redirect, no loop).
- [ ] `/my-work`, `/time`, `/kb`, `/profile`, `/profile/notifications` render normally for CONTRACTOR.

### Task 12: ProfilePage — details, preferences, password sections
**Blocks:** —  ·  **Blocked by:** 4, 6
**Files:**
- Create: `zync-app/src/pages/ProfilePage.tsx`
- Modify: `zync-app/src/routes/profile.tsx` (route registration)
**Steps:**
- [ ] Two-column layout (shared app shell sidebar + content), single scrollable page with `<Separator>`/`<hr>` between sections (Personal Details, Preferences, Change Password, Active Sessions).
- [ ] Personal Details: Display Name (text, max 100, required), Email (read-only with a "Change email" link triggering the verification flow), Phone (optional).
- [ ] Preferences: Language segmented control `עברית (he) | English (en)` — call `i18n.changeLanguage()` immediately on selection (preview) and persist on submit; Timezone searchable `Select` over IANA zones, default `Asia/Jerusalem`.
- [ ] Change Password: separate `<Form>` posting to `POST /api/me/change-password`; inline per-field errors; success inline message.
- [ ] Save button PATCHes `/api/me/profile`; optimistic preference update; `toast` on success/error.
- [ ] All inputs labelled (`FormLabel`/`aria`), keyboard-operable; segmented control is a radio group; respect RTL via `useDirection`.
**Acceptance:**
- [ ] Switching Language to English re-renders UI immediately; reloading after Save persists the choice.
- [ ] Saving with an empty Display Name is blocked with an inline error.
- [ ] Change Password section submits independently of the profile PATCH and shows field-level errors on weak/mismatched input.

### Task 13: AvatarUploader — R2 presigned upload + remove
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Create: `zync-app/src/components/AvatarUploader.tsx`
**Steps:**
- [ ] File picker limited to jpg/png/webp; enforce ≤5 MB client-side before requesting a presign.
- [ ] `POST /api/me/avatar` → receive `{ upload_url, avatar_url }`; PUT the file to `upload_url`; on success `PATCH /api/me/profile { avatar_url }`.
- [ ] Optimistic preview of the new avatar; on failure revert and `toast` error.
- [ ] Remove avatar: `PATCH /api/me/profile { avatar_url: null }` → fall back to initials `Avatar`.
- [ ] Provide accessible alt text and a focus-visible upload control.
**Acceptance:**
- [ ] Selecting a >5 MB file is rejected client-side before any network call.
- [ ] Successful upload shows the new avatar immediately and persists after reload; Remove returns to the initials avatar.

### Task 14: SessionsList — active sessions table + revoke controls
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Create: `zync-app/src/components/SessionsList.tsx`
**Steps:**
- [ ] Fetch `GET /api/user/sessions`; render a `Table` with columns Device/Browser (`device_name`), IP, Last seen, Created.
- [ ] Per-row "Revoke" button (hidden/disabled for the current session, badge "This device"); confirm via `Dialog`, then `DELETE /api/user/sessions/:sessionId`; invalidate the query.
- [ ] "Revoke all other sessions" button → `DELETE /api/user/sessions`; confirm + invalidate.
- [ ] Loading `Skeleton`; empty/error states via `EmptyState`/`ErrorState`.
- [ ] Table is keyboard-navigable; action buttons have accessible names; respect RTL column order.
**Acceptance:**
- [ ] The current session is clearly marked and cannot be revoked from its own row.
- [ ] Revoking another session removes it from the list after invalidation.

### Task 15: MyWorkPage — My Tasks + Today's Time sections
**Blocks:** 17  ·  **Blocked by:** 7, 10
**Files:**
- Create: `zync-app/src/pages/MyWorkPage.tsx`
- Create: `zync-app/src/components/MyTasksSection.tsx`
- Create: `zync-app/src/components/TodayTimeSection.tsx`
- Modify: `zync-app/src/routes/my-work.tsx`
**Steps:**
- [ ] Route `/my-work`; landing page for MEMBER and CONTRACTOR.
- [ ] **MyTasksSection**: fetch `GET /api/me/tasks` with Sort (`Due date` default, Priority, Created, Status) and Filter (Status, Project) controls; group rows by project with a "No Project / ללא פרויקט" group; completed tasks greyed, collapsed by default with a "Show completed" toggle. Each row: checkbox (mark done → task update API), title link, project badge, due-date chip (red if overdue), priority indicator. Clicking a row opens the shared task detail slide-over (`Sheet`) rendered with MEMBER/CONTRACTOR scope (no reassign, no delete).
- [ ] **TodayTimeSection**: render only if `useModuleEnabled('time-management')`; fetch `GET /api/me/time-entries/today`; show running timer at top with elapsed time and Start/Stop; list entries (start time, project+task label, duration); show accumulated total.
- [ ] Bilingual labels; reduced-motion-safe timer animation; RTL-aware layout; empty states via `EmptyState`.
**Acceptance:**
- [ ] A MEMBER/CONTRACTOR landing on `/my-work` sees only their assigned tasks grouped by project, with working Sort/Filter and a Show-completed toggle.
- [ ] Today's Time section is absent when the time-management module is disabled and present (with running-timer handling) when enabled.
- [ ] Opening a task shows the slide-over without reassign/delete actions for these roles.

### Task 16: InviteAcceptPage — all token states (zync-www island)
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-www/src/pages/invite/accept.astro`
- Create: `apps/zync-www/src/islands/InviteAccept.tsx`
**Steps:**
- [ ] On load, read `?token=`; call `GET /api/invite/validate`; branch on `status`.
- [ ] **valid + existing user**: show tenant + role + signed-in email; `[Accept Invitation]` → `POST /api/invite/accept`; on `mismatch` show "Not you?" with "Sign in with a different account" (clears session, redirect `/auth/login?next=/invite/accept?token=...`); on `login_required` redirect to login with `next`. On success redirect to `app.zync.is/{tenantSlug}/`.
- [ ] **valid + new user**: Full name + Password + Confirm form; inline validation on blur + submit (≥8 chars, ≥1 digit, confirm matches); `[Create account & join]` → `POST /api/invite/accept/new-user`; store tokens; redirect to `app.zync.is/{tenantSlug}/`.
- [ ] **expired**: "This invitation has expired … valid for 7 days" + `[Back to login]`.
- [ ] **already_accepted**: "You've already joined this workspace" + `[Go to app]`.
- [ ] **invalid**: "Invalid invitation" with back-to-login.
- [ ] Error copy in Hebrew by default / English by browser locale until a preference exists; CSP-compliant island hydration; reduced-motion-safe transitions.
**Acceptance:**
- [ ] Each of the five token states renders its matching screen.
- [ ] New-user submission with a weak/mismatched password shows inline errors and does not submit.
- [ ] Successful acceptance (either flow) lands the user in `app.zync.is/{tenantSlug}/`.

### Task 17: Contractor simplified onboarding redirect + role landing routing
**Blocks:** —  ·  **Blocked by:** 10, 15
**Files:**
- Modify: `zync-app/src/routes/index.tsx` (post-login landing resolver)
**Steps:**
- [ ] Post-login landing resolver: MEMBER and CONTRACTOR → `/my-work`; OWNER/ADMIN/VIEWER → existing Dashboard.
- [ ] Contractor first login (no `display_name`/profile completed) → redirect to `/profile` to complete name/phone/avatar; after first save redirect to `/my-work` (alias `/tasks`).
- [ ] No module-selection or workspace-setup wizard for CONTRACTOR (skip the OWNER onboarding wizard path entirely).
**Acceptance:**
- [ ] A freshly invited CONTRACTOR is sent to `/profile` on first login, then to `/my-work` after saving.
- [ ] MEMBER lands on `/my-work`; OWNER/ADMIN land on the Dashboard.
