# Contractor Time Portal — Implementation Plan

**Spec:** docs/specs/2026-05-31-contractor-portal.md  ·  **Slug:** contractor-portal  ·  **Wave:** 8
**Depends on:** contractor-payouts, foundation-auth-rbac, projects-module, time-management

## Goal
Deliver a dedicated, sidebar-less portal at `/contractor-portal/` where external contractors authenticate via magic link, log their own time against assigned projects, edit/delete their pending entries, and view their payout-bill history read-only. Contractors have no `users` row; auth is a 30-day contractor-scoped JWT minted on magic-link redemption. Submitted entries land in the existing `time_entries` table with `source = 'contractor_portal'` and an `approval_status` driven by the tenant's contractor-approval flag, feeding the unchanged spec-21 payout-bill flow.

## Architecture
- **New table (this spec owns exactly one):** `contractor_portal_sessions` — stores SHA-256 hashes of magic-link tokens with expiry/used-at, FK to upstream `contractors(id)`.
- **Consumes upstream, no DDL re-issued:** `contractors`, `contractor_assignments`, `payout_bills` (from contractor-payouts); `time_entries` (from time-management — columns `contractor_id`, `source`, `approval_status`, `started_at`, `stopped_at`, `duration_seconds`, `billable` already exist); `tasks`, `projects` (projects-module); `tenant_settings.contractor_require_time_approval` (owned by spec 148, NOT in our depends_on — read defensively, treat absent/NULL as `true`).
- **Auth reuses foundation-auth-rbac infra:** the underlying jose signer + `JWT_SECRET` binding, plus `generateOpaqueToken`, `hashToken`, `timingSafeEqual`, `sendEmail`. Contractor JWT is a DISTINCT payload (`ContractorSessionPayload`) and middleware (`contractorAuthMiddleware`) — it must NOT overload `SessionPayload`/`signSession`/`authMiddleware`, which carry a permissions array and a `users` sub.
- **Two surfaces:** contractor-scoped API routes under `/contractor-portal/api/*` (in zync-api, guarded by `contractorAuthMiddleware`) and staff-scoped extensions under `/api/contractors/:id/portal-invite*` (guarded by existing `authMiddleware` + `requirePermission('payouts:write')`). The portal React UI is a lazy module in zync-app under a dedicated minimal layout.
- **Data flow:** staff clicks "Send portal invite" on `/contractors/:id` → `POST /api/contractors/:id/portal-invite` mints opaque token (24h TTL), stores hash in `contractor_portal_sessions`, emails the link → contractor opens `/contractor-portal/redeem?token=...` → server hashes + timing-safe-compares, marks `used_at`, mints 30-day contractor JWT in httpOnly cookie → portal routes call `/contractor-portal/api/*`.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): contractor + staff routes, magic-link redemption, JWT mint/verify, time-entry CRUD, CSV export. Bindings: `DB` (Neon via Hyperdrive), `KV`, `JWT_SECRET` (env secret), `STORAGE` (not required here).
- **apps/zync-app** (Vite + React, react-router v7, TanStack Query v5, Zustand): portal UI module, lazy-loaded, dedicated layout.
- **packages/auth**: `ContractorSessionPayload` type, `signContractorSession`, `verifyContractorSession`, `contractorAuthMiddleware`. Reuses `packages/auth/src/crypto.ts` helpers (`generateOpaqueToken`, `hashToken`, `timingSafeEqual`).
- **packages/db**: Drizzle schema for `contractor_portal_sessions`; query helpers under `packages/db/src/queries` (bound via `tenantQuery`/contractor-scoped helpers). Migration via drizzle-kit (packages/db owns schema).
- **packages/types**: shared `ContractorSessionPayload`, `ContractorPortalProfile`, `ContractorTimeEntryInput` types.
- **i18n**: `@zync/config` translations; RTL/Hebrew for ₪ amounts, dates, CSV headers (mirror contractor-payouts Hebrew export).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 8a | 1 | packages/db schema + migration | No (foundation for all) |
| 8b | 2, 3 | packages/auth (JWT + middleware), packages/types | 2 and 3 parallel after 1 |
| 8c | 4, 5 | zync-api staff invite routes, zync-api contractor API routes | 4 and 5 parallel after 2,3 |
| 8d | 6 | zync-api CSV export | After 5 |
| 8e | 7, 8, 9 | zync-app portal layout/auth, portal pages, staff invite UI | 7 before 8; 9 parallel |
| 8f | 10 | i18n strings, a11y, CSP, tests | After 7,8,9 |

## Tasks

### Task 1: `contractor_portal_sessions` table + Drizzle schema + migration
**Blocks:** 2, 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/contractor-portal.ts`
- Modify: `packages/db/src/schema/index.ts` (export new table)
- Create: `packages/db/migrations/<timestamp>_contractor_portal_sessions.sql`
**Steps:**
- [ ] Define the `contractor_portal_sessions` Drizzle table with the canonical Postgres dialect (UUID PK, UUID FK to `contractors`, `TIMESTAMPTZ`).
- [ ] Add the `idx_cps_token` index on `token_hash`.
- [ ] Export the table + inferred row type from `packages/db/src/schema/index.ts`.
- [ ] Generate the SQL migration with drizzle-kit; verify it emits the exact DDL below.
- [ ] Do NOT alter `time_entries` — `contractor_id`, `source` (`'contractor_portal'` already in the enum), and `approval_status` are base columns owned by `time-management` (wave 5, in this plan's deps) and already present.
**Schema / Interfaces:**
```sql
CREATE TABLE contractor_portal_sessions (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  contractor_id UUID NOT NULL REFERENCES contractors(id) ON DELETE CASCADE,
  token_hash    TEXT NOT NULL,             -- SHA-256 hex of magic-link token
  expires_at    TIMESTAMPTZ NOT NULL,
  used_at       TIMESTAMPTZ,              -- NULL = not yet used; set atomically on first redemption
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_cps_token ON contractor_portal_sessions(token_hash);
```
```ts
// packages/db/src/schema/contractor-portal.ts (Drizzle)
export const contractorPortalSessions = pgTable('contractor_portal_sessions', {
  id: uuid('id').primaryKey().defaultRandom(),
  contractorId: uuid('contractor_id').notNull().references(() => contractors.id, { onDelete: 'cascade' }),
  tokenHash: text('token_hash').notNull(),
  expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
  usedAt: timestamp('used_at', { withTimezone: true }),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({ tokenIdx: index('idx_cps_token').on(t.tokenHash) }))
export type ContractorPortalSession = typeof contractorPortalSessions.$inferSelect
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon; `contractor_portal_sessions` exists with the FK, the `idx_cps_token` index, and `used_at` nullable.
- [ ] No migration in this spec touches `time_entries`.

### Task 2: Contractor JWT — payload type, sign/verify, `contractorAuthMiddleware`
**Blocks:** 5  ·  **Blocked by:** 1
**Files:**
- Create: `packages/auth/src/contractor-session.ts`
- Create: `packages/auth/src/middleware/contractor-auth.ts`
- Modify: `packages/auth/src/index.ts` (export new symbols)
- Modify: `packages/types/src/index.ts` (export `ContractorSessionPayload`)
**Steps:**
- [ ] Define `ContractorSessionPayload` distinct from `SessionPayload` — no `permissions` array, `role` fixed to `'contractor'`.
- [ ] Implement `signContractorSession(payload, secret)` and `verifyContractorSession(token, secret)` reusing the same jose HS256 signer and `JWT_SECRET` env binding used by `signSession`/`verifySession` (do NOT call `signSession` — payload shape differs).
- [ ] Session duration: 30 days. On every authenticated request the middleware re-issues a fresh 30-day cookie (renew-on-use) when the current token's remaining TTL is under 7 days.
- [ ] Implement `contractorAuthMiddleware`: read JWT from the `zync_contractor` httpOnly cookie, verify signature/exp, attach `{ contractorId, tenantId }` to the Hono context (`c.set('contractor', ...)`); 401 on missing/invalid/expired. Enforce `Origin` is `https://app.zync.is` on state-mutating methods (POST/PATCH/DELETE), mirroring the auth-rbac CSRF rule.
- [ ] Export `ContractorSessionPayload`, `signContractorSession`, `verifyContractorSession`, `contractorAuthMiddleware` from package barrels.
**Schema / Interfaces:**
```ts
// packages/types/src/index.ts
export interface ContractorSessionPayload {
  sub: string          // contractor UUID
  tenantId: TenantId   // tenant UUID
  role: 'contractor'
  iat: number
  exp: number
}

// packages/auth/src/contractor-session.ts
export function signContractorSession(
  payload: Omit<ContractorSessionPayload, 'iat' | 'exp' | 'role'>,
  secret: string,
): Promise<string>   // HS256, exp = now + 30d, role = 'contractor'
export function verifyContractorSession(
  token: string,
  secret: string,
): Promise<ContractorSessionPayload>   // throws on invalid/expired

// packages/auth/src/middleware/contractor-auth.ts
export const contractorAuthMiddleware: MiddlewareHandler  // sets c.var.contractor = { contractorId, tenantId }
```
**Acceptance:**
- [ ] A valid contractor JWT passes; expired/tampered/absent tokens → 401.
- [ ] `SessionPayload` and `signSession` are untouched; contractor payload carries no permissions array.
- [ ] State-mutating portal requests with a wrong/absent `Origin` are rejected.

### Task 3: Shared contractor-portal types
**Blocks:** 5, 8  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/contractor-portal.ts`
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Define `ContractorPortalProfile` (contractor identity + assigned projects with their tasks).
- [ ] Define `ContractorTimeEntryInput` (the POST/PATCH body) and `ContractorTimeEntryRow` (list response, includes `approval_status` and computed hours).
- [ ] Define `ContractorPayoutBillRow` (read-only bill summary).
- [ ] Export all from the package barrel.
**Schema / Interfaces:**
```ts
export interface ContractorPortalProfile {
  contractorId: string
  name: string
  tenantName: string
  tenantLogoUrl: string | null
  projects: Array<{ id: string; name: string; tasks: Array<{ id: string; title: string }> }>
}
export interface ContractorTimeEntryInput {
  project_id: string
  task_id?: string
  date: string            // 'YYYY-MM-DD'
  duration_min: number    // > 0
  notes?: string
}
export interface ContractorTimeEntryRow {
  id: string
  project_id: string
  project_name: string
  task_id: string | null
  date: string
  duration_min: number
  hours: number           // duration_min / 60, 2dp
  notes: string | null
  approval_status: 'auto_approved' | 'pending' | 'approved' | 'rejected' | 'locked'
}
export interface ContractorPayoutBillRow {
  id: string
  period_start: string
  period_end: string
  amount: number
  currency: string
  status: 'DRAFT' | 'SENT' | 'APPROVED' | 'PAID' | 'VOID'
  paid_at: string | null
}
```
**Acceptance:**
- [ ] Types import cleanly into zync-api and zync-app with no circular dependency.

### Task 4: Staff API — send portal invite + status
**Blocks:** 9  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/contractors-portal-invite.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount under existing `/api/contractors`)
- Create: `packages/db/src/queries/contractor-portal-sessions.ts`
**Steps:**
- [ ] `POST /api/contractors/:id/portal-invite`: guard with `authMiddleware` + `requirePermission('payouts:write')`; validate the contractor belongs to the caller's tenant (tenant-scoped query).
- [ ] Generate an opaque token via `generateOpaqueToken()`; compute `hashToken(token)` (SHA-256 hex); INSERT a `contractor_portal_sessions` row with `expires_at = now() + 24h`, `used_at = NULL`.
- [ ] Send email via `sendEmail` to `contractors.email` with the redemption link `https://app.zync.is/contractor-portal/redeem?token=<plaintext>` (plaintext token only in the email — never stored). 400 if the contractor has no email.
- [ ] `GET /api/contractors/:id/portal-invite/status`: return `{ hasPortalAccess, lastSeen }` — `hasPortalAccess = true` if any session row for the contractor has been used (`used_at IS NOT NULL`); `lastSeen` = MAX(`used_at`). Guard with `payouts:read`.
- [ ] Implement query helpers `createPortalSession`, `getPortalAccessStatus` in the new queries file (zod-validated inputs assumed pre-validated at route layer; no raw drizzle from routes).
**Schema / Interfaces:**
```ts
// packages/db/src/queries/contractor-portal-sessions.ts
export function createPortalSession(db: Db, args: { tenantId: string; contractorId: string; tokenHash: string; expiresAt: Date }): Promise<{ id: string }>
export function getPortalAccessStatus(db: Db, args: { tenantId: string; contractorId: string }): Promise<{ hasPortalAccess: boolean; lastSeen: string | null }>
export function findValidPortalSession(db: Db, tokenHash: string): Promise<{ id: string; contractorId: string; tenantId: string; expiresAt: Date; usedAt: Date | null } | null>
export function markPortalSessionUsed(db: Db, sessionId: string): Promise<void>
// POST /api/contractors/:id/portal-invite
// GET  /api/contractors/:id/portal-invite/status
```
**Acceptance:**
- [ ] Invite without `payouts:write` → 403; cross-tenant contractor id → 404.
- [ ] Token stored hashed; plaintext appears only in the outbound email.
- [ ] Status returns `hasPortalAccess:false` before redemption, `true` with a `lastSeen` after.

### Task 5: Contractor API — redeem, me, time CRUD, bills
**Blocks:** 6, 8  ·  **Blocked by:** 2, 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/contractor-portal-api.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount `/contractor-portal/api/*` + `/contractor-portal/redeem`)
- Create: `packages/db/src/queries/contractor-portal-time.ts`
- Create: `apps/zync-api/src/validation/contractor-portal.ts` (zod schemas)
**Steps:**
- [ ] `GET /contractor-portal/redeem?token=...` (no auth): hash the token, look up via `findValidPortalSession`; reject with 401 if not found, expired, or `used_at` already set on a single-use redemption beyond renewal window. Use `timingSafeEqual` when comparing the stored hash. On success, `markPortalSessionUsed` (set `used_at = now()`), mint a 30-day contractor JWT via `signContractorSession`, set the `zync_contractor` httpOnly cookie (`HttpOnly; Secure; SameSite=Strict; domain=.zync.is; Max-Age=2592000; Path=/contractor-portal`), and 302-redirect to `/contractor-portal/`.
- [ ] All `/contractor-portal/api/*` routes guarded by `contractorAuthMiddleware`.
- [ ] `GET /contractor-portal/api/me`: return `ContractorPortalProfile` — contractor name, tenant name + logo, and assigned projects (rows in `contractor_assignments` for this contractor) with each project's `tasks`.
- [ ] `GET /contractor-portal/api/time?month=YYYY-MM&approval_status=...`: return this contractor's `time_entries` (filtered by `contractor_id = JWT.sub`, tenant-scoped) mapped to `ContractorTimeEntryRow`. `duration_min = ROUND(duration_seconds/60)`, `hours = duration_min/60` (2dp).
- [ ] `POST /contractor-portal/api/time`: zod-validate body; verify `project_id` is an active assignment for this contractor and `task_id` (if given) belongs to that project. INSERT into `time_entries` with: `contractor_id = JWT.sub`, `user_id = NULL`, `source = 'contractor_portal'`, `project_id`, `task_id`, `description = notes`, `billable = true`, `started_at = <date>T00:00 in tenant timezone`, `stopped_at = started_at + duration_min minutes`, `duration_seconds = duration_min * 60`. Set `approval_status` from the tenant flag (see below).
- [ ] **`approval_status` resolution:** read `tenant_settings.contractor_require_time_approval` via `tenantQuery` (the column is owned by `time-management`, wave 5, which is in this plan's deps, so it always exists; default to `true` if the singleton ROW is absent). When `true` → insert `approval_status = 'pending'`; when `false` → `approval_status = 'auto_approved'` (matches column default).
- [ ] `PATCH /contractor-portal/api/time/:id`: load entry; 404 if not owned by JWT contractor; **403/409 unless `approval_status = 'pending'`** (server-side enforcement — never trust client). Allow editing `project_id`/`task_id` (re-validate assignment), `date`, `duration_min`, `notes`; recompute `started_at`/`stopped_at`/`duration_seconds`.
- [ ] `DELETE /contractor-portal/api/time/:id`: same ownership + `approval_status = 'pending'` guard; hard-delete the row.
- [ ] `GET /contractor-portal/api/bills`: read-only list of `payout_bills` WHERE `contractor_id = JWT.sub`, mapped to `ContractorPayoutBillRow`, newest first.
- [ ] All query logic lives in `packages/db/src/queries/contractor-portal-time.ts`; routes only validate + call helpers.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/validation/contractor-portal.ts
export const createTimeEntrySchema = z.object({
  project_id: z.string().uuid(),
  task_id: z.string().uuid().optional(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  duration_min: z.number().int().positive(),
  notes: z.string().max(2000).optional(),
})
export const updateTimeEntrySchema = createTimeEntrySchema.partial()

// packages/db/src/queries/contractor-portal-time.ts
export function getContractorProfile(db: Db, args: { tenantId: string; contractorId: string }): Promise<ContractorPortalProfile>
export function listContractorTimeEntries(db: Db, args: { tenantId: string; contractorId: string; month?: string; approvalStatus?: string }): Promise<ContractorTimeEntryRow[]>
export function createContractorTimeEntry(db: Db, args: { tenantId: string; contractorId: string; input: ContractorTimeEntryInput; approvalStatus: 'pending' | 'auto_approved'; startedAt: Date; stoppedAt: Date }): Promise<{ id: string }>
export function updateContractorTimeEntry(db: Db, args: { tenantId: string; contractorId: string; entryId: string; patch: Partial<ContractorTimeEntryInput>; startedAt?: Date; stoppedAt?: Date }): Promise<void>  // throws if entry.approval_status != 'pending'
export function deleteContractorTimeEntry(db: Db, args: { tenantId: string; contractorId: string; entryId: string }): Promise<void>  // throws if approval_status != 'pending'
export function listContractorBills(db: Db, args: { tenantId: string; contractorId: string }): Promise<ContractorPayoutBillRow[]>
export function resolveContractorApprovalRequirement(db: Db, tenantId: string): Promise<boolean>  // defensive: missing column/NULL => true

// Routes:
// GET    /contractor-portal/redeem
// GET    /contractor-portal/api/me
// GET    /contractor-portal/api/time
// POST   /contractor-portal/api/time
// PATCH  /contractor-portal/api/time/:id
// DELETE /contractor-portal/api/time/:id
// GET    /contractor-portal/api/bills
```
**Acceptance:**
- [ ] Redeeming a valid unexpired token sets the cookie and redirects; expired/unknown token → 401.
- [ ] POST creates a `time_entries` row with `user_id=NULL`, `contractor_id` set, `source='contractor_portal'`, satisfying the `user_or_contractor` CHECK.
- [ ] With the approval flag absent or `true`, new entries are `pending`; with `false`, `auto_approved`.
- [ ] PATCH/DELETE on a non-`pending` entry returns 409 and does not mutate.
- [ ] A contractor cannot read another contractor's entries or bills (tenant + contractor scoped).
- [ ] Posting against a non-assigned project is rejected.

### Task 6: Contractor time-log CSV export
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-api/src/routes/contractor-portal-api.ts`
- Create: `packages/db/src/queries/contractor-portal-export.ts`
**Steps:**
- [ ] Add `GET /contractor-portal/api/time/export.csv?month=YYYY-MM` guarded by `contractorAuthMiddleware`.
- [ ] Stream a CSV of the contractor's own entries: columns Date, Project, Task, Hours, Status, Notes. Header row localized (Hebrew when tenant locale is `he`), matching the RTL + Hebrew export convention used by contractor-payouts.
- [ ] Set `Content-Type: text/csv; charset=utf-8` and a `Content-Disposition` filename including the month. Prepend a UTF-8 BOM so Hebrew renders in Excel.
**Acceptance:**
- [ ] Export returns only the requesting contractor's rows for the month.
- [ ] Hebrew header row + BOM present when tenant locale is Hebrew.

### Task 7: Portal layout + auth bootstrap (zync-app)
**Blocks:** 8  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/modules/contractor-portal/ContractorPortalLayout.tsx`
- Create: `apps/zync-app/src/modules/contractor-portal/api.ts` (TanStack Query hooks)
- Create: `apps/zync-app/src/modules/contractor-portal/index.tsx` (lazy module entry + routes)
- Modify: `apps/zync-app/src/routes/index.tsx` (register lazy `/contractor-portal/*` route, eager-load outside the authenticated app shell)
**Steps:**
- [ ] Build a minimal layout: tenant logo + name header, "Hello, {contractor name}", no sidebar, no global app-shell nav.
- [ ] The portal is a separate route tree gated by the contractor cookie (not the staff `zync_session`): on mount, call `GET /contractor-portal/api/me`; on 401 redirect to a "link expired — ask for a new invite" page.
- [ ] Implement TanStack Query hooks: `useContractorProfile`, `useContractorTime(month, status)`, `useCreateContractorTime`, `useUpdateContractorTime`, `useDeleteContractorTime`, `useContractorBills`.
- [ ] Apply `prefers-reduced-motion` to any transitions; ensure layout direction follows tenant locale (`dir="rtl"` for Hebrew).
**Schema / Interfaces:**
```ts
export function useContractorProfile(): UseQueryResult<ContractorPortalProfile>
export function useContractorTime(month: string, status?: string): UseQueryResult<ContractorTimeEntryRow[]>
export function useCreateContractorTime(): UseMutationResult<{ id: string }, Error, ContractorTimeEntryInput>
export function useUpdateContractorTime(): UseMutationResult<void, Error, { id: string; patch: Partial<ContractorTimeEntryInput> }>
export function useDeleteContractorTime(): UseMutationResult<void, Error, string>
export function useContractorBills(): UseQueryResult<ContractorPayoutBillRow[]>
```
**Acceptance:**
- [ ] `/contractor-portal/*` renders without the staff app shell/sidebar and is reachable without a staff session.
- [ ] A 401 from `/me` routes the contractor to the expired-link page.
- [ ] Hebrew tenants render RTL.

### Task 8: Portal pages — Dashboard, Log Time, History, Payout Bills
**Blocks:** 10  ·  **Blocked by:** 3, 5, 7
**Files:**
- Create: `apps/zync-app/src/modules/contractor-portal/DashboardPage.tsx`
- Create: `apps/zync-app/src/modules/contractor-portal/LogTimePage.tsx`
- Create: `apps/zync-app/src/modules/contractor-portal/TimeHistoryPage.tsx`
- Create: `apps/zync-app/src/modules/contractor-portal/PayoutBillsPage.tsx`
**Steps:**
- [ ] **Dashboard:** "This week" summary (hours logged, pending-review count from `useContractorTime`), recent activity list (date, project — task, hours, status badge), and a "Log new time" button.
- [ ] **Log Time form:** Project select (only assigned projects from profile), Task select (tasks on the chosen project), Date picker, Duration as hours + minutes inputs, Notes textarea, Cancel + "Submit hours". On submit, combine hours+minutes → `duration_min` and POST. Disable submit while pending; show validation + server errors.
- [ ] **Time Log History:** month filter, table (Date, Project, Hours, Status), totals row (Total / Approved / Pending). Edit + delete affordances only on rows where `approval_status === 'pending'`; deletes confirm before firing. "Export CSV" links to `/contractor-portal/api/time/export.csv?month=...`.
- [ ] **Payout Bills:** read-only list (period label, amount in ₪ with locale formatting, status / paid date). No mutating controls.
- [ ] All money rendered with the tenant currency + locale; all dates via the shared locale date formatter.
- [ ] Tables use `role="table"`/proper semantic markup; status badges have accessible text labels (not color-only); forms have associated `<label>`s and `aria-invalid`/`aria-describedby` on errors.
**Acceptance:**
- [ ] Project/Task selects show only assigned projects and their tasks.
- [ ] Edit/Delete appear only for `pending` entries; the server still rejects non-pending mutations (defense in depth).
- [ ] Totals row sums match the entry list; CSV link downloads the filtered month.
- [ ] Payout bills view exposes no mutation controls.

### Task 9: Staff "Send portal invite" UI on contractor detail
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-app/src/modules/contractors/ContractorDetailPage.tsx` (owned by contractor-management-ui; add the portal-access panel)
- Create: `apps/zync-app/src/modules/contractors/PortalAccessPanel.tsx`
**Steps:**
- [ ] Add a "Portal access" panel to `/contractors/:id`: query `GET /api/contractors/:id/portal-invite/status`.
- [ ] When `hasPortalAccess === false`: show "Not enabled" + a "Send portal invite" button → `POST /api/contractors/:id/portal-invite`; on success toast "Invite sent".
- [ ] When `hasPortalAccess === true`: show "Active — last seen {lastSeen}".
- [ ] Gate the button behind the caller's `payouts:write` permission (hide/disable if absent); the server enforces it regardless.
**Acceptance:**
- [ ] Panel reflects status pre/post redemption.
- [ ] Send invite is hidden/disabled for users lacking `payouts:write`.

### Task 10: i18n strings, CSP, accessibility pass, route tests
**Blocks:** —  ·  **Blocked by:** 7, 8, 9
**Files:**
- Modify: `packages/config/src/translations/en.ts`, `packages/config/src/translations/he.ts`
- Modify: `apps/zync-app/src/security/csp.ts` (or app-shell CSP config) to cover `/contractor-portal/`
- Create: `apps/zync-api/test/contractor-portal.test.ts`
- Create: `apps/zync-app/test/contractor-portal.e2e.ts`
**Steps:**
- [ ] Add EN + HE translation keys for all portal labels (dashboard, log time, history, bills, invite, expired-link), including the CSV header strings.
- [ ] Ensure the `/contractor-portal/` surface is covered by the app CSP (no inline scripts; same policy as the rest of zync-app).
- [ ] Run an a11y check on portal forms/tables (labels, roles, focus order, color-independent status); fix violations.
- [ ] API tests: invite permission gate; token hash + single-use redemption; approval-flag-driven `pending` vs `auto_approved`; PATCH/DELETE 409 on non-pending; cross-contractor isolation; CSV scoping.
- [ ] E2E: redeem link → dashboard → log time → see pending entry → edit → delete; verify RTL render for `he`.
**Acceptance:**
- [ ] No untranslated string keys; Hebrew portal renders RTL end-to-end.
- [ ] CSP applies on `/contractor-portal/` with no inline-script violations.
- [ ] API + E2E tests pass.
