# Team Users Settings — Implementation Plan

**Spec:** docs/specs/2026-05-31-team-users-settings.md  ·  **Slug:** team-users-settings  ·  **Wave:** 10
**Depends on:** foundation-auth-rbac, foundation-design-system, settings-module

## Goal
Build the full `/settings/users` team-management experience that `settings-module` left as a 4-line stub: a members table (name, email, role, status, last active), an invite-member sheet, edit-role / freeze / unfreeze / remove row actions, a pending-invitations panel, and a pending-approval queue for tenants that require admin approval. All of this is wired against the existing `users`, `tenant_memberships`, `roles`, and `invitations` tables from `foundation-auth-rbac` — no new tables are introduced. The one schema change is widening the `tenant_memberships.status` CHECK to admit `'pending_approval'`.

## Architecture
- **API worker (`apps/zync-api`, Hono):** Adds a `settings/users` router exposing list/invite/resend/revoke/role/freeze/unfreeze/remove/pending/approve/reject/accept endpoints plus a shared `GET /api/roles`. All routes resolve the caller's tenant from the session (`c.get('session')`) and gate on the `users:*` permissions via `requirePermission()` from `@zync/auth`. Mutations that change a member's effective access (`approve`, `role`, `freeze`, `unfreeze`, `remove`) call `bumpUserVersion(userId)` so the session cache (`user_version:{userId}` KV) forces a token refresh within 60s (foundation-auth-rbac §session caching).
- **Upstream tables consumed (exact names):** `users` (`id`, `email`, `name`/derived, `status`), `tenant_memberships` (PK `(user_id, tenant_id)`; `role_id`, `status`, `freeze_reason`, `created_at`), `roles` (`id`, `tenant_id`, `name`, `is_system_role`), `invitations` (`id`, `tenant_id`, `email`, `role_id`, `token_hash`, `expires_at`, `accepted_at`), `tenants` (`require_approval`, `tier`, `name`).
- **Upstream exports consumed:** `requirePermission`, `authMiddleware`, `bumpUserVersion`, `getMaxTeamMembers`, `meetsMinimumTier`, `generateOpaqueToken`, `hashToken`, `hashPassword`, `rateLimit`, `RATE_LIMITER_AUTH`, `sendEmail` (`SendEmailOptions`), `createNotification`, `createDb`/`createDb`-backed `tenantQuery`/`systemQuery`, `buildSessionPayload`, `signSession`. UI consumes `@zync/ui` primitives (`DataTable`, `Sheet`, `Dialog`, `Select`, `Input`, `Button`, `Badge`, `DropdownMenu`, `EmptyState`, `Toast`/`toast`, `Form`, `FormField`, `Avatar`).
- **Data flow (invite):** Sheet → `POST /api/tenants/:tenantId/members/invite` → validate (not member/pending, role ≠ OWNER, active-count < `getMaxTeamMembers(tier)`) → insert `invitations` row with `token_hash = hashToken(token)` and `expires_at = now()+7d` → `sendEmail` invite → return invitation. Accept page lives in `zync-www` (P021); the **accept API** (`POST /api/invite/accept`) is owned here.
- **Data flow (approval):** When `tenants.require_approval = true`, accept creates a `pending_approval` membership and fires a `member_invited` notification to admins/owner; approve flips to `active` + `user_approved` notification + `bumpUserVersion`.

## Tech Stack
- **apps/zync-api** — Hono routes under `src/routes/settings/users.ts` and `src/routes/roles.ts` and `src/routes/invite.ts`; Zod request validation; Drizzle ORM against Neon Postgres via Hyperdrive (`HYPERDRIVE` binding, `createDb`).
- **apps/zync-app** — React (Vite) page at `src/pages/settings/UsersPage.tsx` plus components, TanStack Query hooks, `@zync/ui` design-system primitives.
- **packages/db** — Drizzle schema delta migration (widen `tenant_memberships.status` CHECK) and the `tenantMemberships`/`invitations`/`roles` table objects already exist upstream; we only add a migration file.
- **Cloudflare bindings:** `HYPERDRIVE` (Postgres), `KV` (user_version), `RATE_LIMITER_AUTH` (resend throttle), `RESEND_API_KEY` secret (via `sendEmail`).
- **Cross-cutting:** invite tokens are opaque (`generateOpaqueToken`) and only their SHA-256 (`hashToken`) is persisted; token comparison is hash-equality (no raw-token storage). UI honors RTL/Hebrew via logical CSS + `useDirection`, `prefers-reduced-motion` on the sheet/dialog transitions, and full aria roles on the table, dialogs, and action menus.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema | 1 | packages/db migration + schema | No (blocks all writes touching pending_approval) |
| B — shared API | 2, 3 | apps/zync-api roles + members list + invite + resend/revoke | After A; 2 and 3 parallel |
| C — lifecycle API | 4, 5 | apps/zync-api role/freeze/unfreeze/remove + pending/approve/reject + accept | After A,2,3 |
| D — UI data layer | 6 | apps/zync-app hooks + zod-shared types | After B,C (interfaces stable) |
| E — UI pages | 7, 8, 9 | apps/zync-app page, invite sheet, pending/approval, row-action dialogs | After 6; 7/8/9 parallel |
| F — frozen banner | 10 | apps/zync-app shell guard | After A; parallel with E |

## Tasks

### Task 1: Widen `tenant_memberships.status` CHECK to admit `pending_approval`
**Blocks:** 2,4,5,6  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/0xxx_membership_status_pending_approval.sql`
- Modify: `packages/db/src/schema/auth.ts` (the existing `tenantMemberships` Drizzle table — update the `status` check expression only)
**Steps:**
- [ ] Add a constraint-widening migration (this is a delta on the locked `tenant_memberships` table, NOT a new table — the spec's "No new tables" holds).
- [ ] Drop and recreate the `status` CHECK to include `'pending_approval'`.
- [ ] Update the Drizzle column definition's check to match, keeping `default('active')` and `notNull()`.
- [ ] Normalize all status literals used by this feature to lowercase: `'active'`, `'frozen'`, `'pending_approval'` (the spec body mixes `FROZEN`/`ACTIVE`; canonical is lowercase).
**Schema / Interfaces:**
```sql
-- Constraint-widening delta on the existing tenant_memberships table (foundation-auth-rbac).
-- Original: CHECK (status IN ('active', 'frozen'))
ALTER TABLE tenant_memberships
  DROP CONSTRAINT IF EXISTS tenant_memberships_status_check;
ALTER TABLE tenant_memberships
  ADD CONSTRAINT tenant_memberships_status_check
  CHECK (status IN ('active', 'frozen', 'pending_approval'));
-- For reference, the column as it now stands (do NOT recreate the table):
--   status        TEXT NOT NULL DEFAULT 'active'
--                 CHECK (status IN ('active', 'frozen', 'pending_approval'))
--   freeze_reason TEXT
```
**Acceptance:**
- [ ] Inserting a `tenant_memberships` row with `status = 'pending_approval'` succeeds; `status = 'invalid'` is rejected by the CHECK.
- [ ] `pnpm --filter @zync/db drizzle:generate` shows no further drift after the migration is applied.

### Task 2: `GET /api/roles` — tenant role list
**Blocks:** 3,6,7  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/roles.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] Implement `GET /api/roles`: select `roles` for the session's `tenant_id` (system + custom), ordered system-first then name.
- [ ] Gate with `authMiddleware` only (requires authenticated; no special permission per spec).
- [ ] Return `[{ id, name, is_system_role }]`.
- [ ] Exclude nothing here; OWNER filtering is enforced at the invite/role-change endpoints, not in this list.
**Schema / Interfaces:**
```ts
// GET /api/roles  → 200
type RoleListItem = { id: string; name: string; is_system_role: boolean };
type RoleListResponse = RoleListItem[];
```
**Acceptance:**
- [ ] Authenticated request returns all roles for the caller's tenant; unauthenticated returns 401.
- [ ] Custom tenant roles appear alongside the five system roles.

### Task 3: Members list + invite + resend + revoke endpoints
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/settings/users.ts`
- Create: `apps/zync-api/src/routes/settings/users.schema.ts` (Zod)
- Modify: `apps/zync-api/src/index.ts` (mount `/api/settings/users` and `/api/tenants/:tenantId/members`)
**Steps:**
- [ ] `GET /api/settings/users` (requires `users:manage`): join `tenant_memberships` × `users` × `roles` for the tenant → members; select `invitations` where `accepted_at IS NULL AND expires_at > now()` → pending invitations. Return `{ members, invitations }`.
- [ ] Derive `lastActiveAt` from the members' most recent session/activity source available upstream (`refresh_tokens.created_at` max as the fallback signal); expose as ISO string or null.
- [ ] `POST /api/tenants/:tenantId/members/invite` (requires `users:invite`): validate `tenantId` matches session tenant (403 otherwise). Zod-validate `{ email, role_id }`.
  - [ ] Reject if `email` is already an active/pending member (409) or has a pending invitation (409).
  - [ ] Look up `role_id` in tenant roles; reject if role name is `OWNER` (400 — invite cannot grant OWNER).
  - [ ] Enforce tier cap: count memberships with `status IN ('active','pending_approval')`; if `count >= getMaxTeamMembers(tenant.tier)` return **402** `{ error: 'Upgrade required' }` (foundation-auth-rbac §invite cap).
  - [ ] `token = generateOpaqueToken()`; insert `invitations { tenant_id, email, role_id, token_hash: hashToken(token), expires_at: now()+7d }`.
  - [ ] `sendEmail({ to: email, locale, subject: "You've been invited to {business_name} on Zync", html, text })` with role name, business name, and accept link `https://zync.is/invite?token={token}`.
  - [ ] Return the created invitation (without the raw token).
- [ ] `POST /api/settings/users/invitations/:id/resend` (requires `users:invite`): rate-limit 1/hour per invitee via `rateLimit(RATE_LIMITER_AUTH, 'invite-resend:{tenantId}:{email}')`; regenerate token (new `token_hash`, refresh `expires_at = now()+7d`), resend email. 429 if throttled.
- [ ] `DELETE /api/settings/users/invitations/:id` (requires `users:manage`): delete the pending `invitations` row scoped to tenant; 404 if not found/owned.
**Schema / Interfaces:**
```ts
// GET /api/settings/users → 200
type MemberRow = {
  userId: string; name: string | null; email: string;
  role: { id: string; name: string };
  status: 'active' | 'frozen' | 'pending_approval';
  lastActiveAt: string | null;
};
type InvitationRow = {
  id: string; email: string; role: { id: string; name: string };
  invitedAt: string; expiresAt: string;
};
type UsersListResponse = { members: MemberRow[]; invitations: InvitationRow[] };

// POST /api/tenants/:tenantId/members/invite  body
const inviteSchema = z.object({ email: z.string().email(), role_id: z.string().uuid() });
```
**Acceptance:**
- [ ] Inviting an existing member returns 409; inviting OWNER role returns 400; inviting at tier cap returns 402.
- [ ] Only `hashToken(token)` is persisted; the raw token never appears in any DB column or API response.
- [ ] A second resend within an hour for the same invitee returns 429.
- [ ] Revoke removes the pending invitation and it disappears from `GET /api/settings/users`.

### Task 4: Role-change, freeze, unfreeze, remove endpoints
**Blocks:** 6  ·  **Blocked by:** 1,2
**Files:**
- Modify: `apps/zync-api/src/routes/settings/users.ts`
- Modify: `apps/zync-api/src/routes/settings/users.schema.ts`
**Steps:**
- [ ] `PATCH /api/settings/users/:userId/role` (requires `users:manage`): Zod `{ role_id }`. Resolve target role in tenant. If target role is OWNER → enforce single-owner transfer: in one transaction, set the current OWNER membership's role to ADMIN and the target to OWNER; require an explicit confirmation flag is not needed server-side (client confirms), but the transfer must be atomic. Otherwise just update `tenant_memberships.role_id`. Call `bumpUserVersion(userId)` (and the demoted owner's id on transfer).
- [ ] `POST /api/settings/users/:userId/freeze` (requires `users:freeze`): set `status = 'frozen'`, store optional `freeze_reason`; `bumpUserVersion(userId)` for immediate revocation. Reject freezing the sole OWNER (400).
- [ ] `POST /api/settings/users/:userId/unfreeze` (requires `users:freeze`): set `status = 'active'`; `bumpUserVersion(userId)`. No confirmation.
- [ ] `DELETE /api/settings/users/:userId` (requires `users:manage`): delete the `tenant_memberships` row only (never the `users` row — user may belong to other tenants); reject removing the sole OWNER (400); `bumpUserVersion(userId)`.
- [ ] Every mutation is tenant-scoped (membership PK `(user_id, tenant_id)`); 404 if the target is not a member of the caller's tenant.
**Schema / Interfaces:**
```ts
const roleChangeSchema = z.object({ role_id: z.string().uuid() });
const freezeSchema = z.object({ freeze_reason: z.string().max(500).optional() });
// PATCH role / POST freeze|unfreeze / DELETE → 200 { ok: true }
```
**Acceptance:**
- [ ] Freeze sets `status='frozen'` and bumps `user_version`; next API call from that user is rejected within ≤60s.
- [ ] Assigning OWNER to another member atomically demotes the prior OWNER to ADMIN.
- [ ] Remove deletes only the membership; the `users` row and its other-tenant memberships remain.
- [ ] Removing/freezing the only OWNER returns 400.

### Task 5: Pending-approval queue + accept-invite endpoint
**Blocks:** 6  ·  **Blocked by:** 1,2,3
**Files:**
- Modify: `apps/zync-api/src/routes/settings/users.ts`
- Create: `apps/zync-api/src/routes/invite.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] `POST /api/invite/accept` (no auth required; token-gated). Body `{ token, name?, password? }`. Steps:
  - [ ] Look up `invitations` by `token_hash = hashToken(token)`; reject if not found, `accepted_at IS NOT NULL`, or `expires_at < now()` (400/410).
  - [ ] Resolve/create the `users` row: if no user with `invitations.email`, require `name`+`password`, create user with `password_hash = await hashPassword(password)` and `status='active'`; else reuse existing user.
  - [ ] Read `tenants.require_approval`. Insert `tenant_memberships { user_id, tenant_id, role_id, status }` where `status = require_approval ? 'pending_approval' : 'active'`.
  - [ ] Set `invitations.accepted_at = now()`.
  - [ ] If `pending_approval`: `createNotification` of type `member_invited` to each admin/owner of the tenant (entity = the new membership), enabling action from the notification center.
  - [ ] If `active`: issue a session (`buildSessionPayload` → `signSession`) so the user can proceed.
  - [ ] Respond with `{ status: 'active' | 'pending_approval', redirect }` where redirect is `app.zync.is/onboarding` for new users else dashboard.
- [ ] `GET /api/tenants/:tenantId/members/pending` (requires `users:manage`): list memberships with `status='pending_approval'` joined to users/roles → `[{ userId, email, name, role, invitedAt }]`.
- [ ] `POST /api/tenants/:tenantId/members/:userId/approve` (requires `users:manage`): set `status='active'`; `createNotification` type `user_approved` to the approved user; `bumpUserVersion(userId)`.
- [ ] `POST /api/tenants/:tenantId/members/:userId/reject` (requires `users:manage`): body `{ notify: boolean, reason?: string }`; delete the `pending_approval` membership row; if `notify`, send the user a rejection notification/email with `reason`.
- [ ] Bulk: `POST /api/tenants/:tenantId/members/approve-all` and `POST /api/tenants/:tenantId/members/reject-all` — apply approve/reject to every `pending_approval` member of the tenant inside a single DB transaction; bump each approved user's version.
**Schema / Interfaces:**
```ts
const acceptSchema = z.object({
  token: z.string().min(1),
  name: z.string().min(1).optional(),
  password: z.string().min(8).optional(),
});
const rejectSchema = z.object({ notify: z.boolean(), reason: z.string().max(500).optional() });
type PendingMember = { userId: string; email: string; name: string | null; role: { id: string; name: string }; invitedAt: string };
type PendingListResponse = PendingMember[];
```
**Acceptance:**
- [ ] Accept on a `require_approval=true` tenant creates a `pending_approval` membership and notifies admins via `member_invited`.
- [ ] Accept on `require_approval=false` creates an `active` membership and returns a usable session.
- [ ] Expired/already-accepted tokens are rejected; token lookup is by hash only.
- [ ] Approve flips status to `active`, sends `user_approved`, and bumps the user's version; approve-all runs atomically.
- [ ] Reject with `notify=true` deletes the membership and notifies the user.

### Task 6: App data layer — query hooks + shared types
**Blocks:** 7,8,9  ·  **Blocked by:** 2,3,4,5
**Files:**
- Create: `apps/zync-app/src/features/settings/users/api.ts`
- Create: `apps/zync-app/src/features/settings/users/types.ts`
**Steps:**
- [ ] Define TanStack Query hooks: `useTeamMembers()` (`GET /api/settings/users`), `usePendingMembers(tenantId)`, `useRoles()` (`GET /api/roles`).
- [ ] Define mutations: `useInviteMember`, `useResendInvite`, `useRevokeInvite`, `useChangeRole`, `useFreezeMember`, `useUnfreezeMember`, `useRemoveMember`, `useApproveMember`, `useRejectMember`, `useApproveAll`, `useRejectAll` — each invalidates `['settings','users']` / `['settings','users','pending']` on success and raises a `toast`.
- [ ] Mirror the API response shapes in `types.ts` (`MemberRow`, `InvitationRow`, `PendingMember`, `RoleListItem`) so components stay decoupled from fetch code.
**Schema / Interfaces:**
```ts
export function useTeamMembers(): UseQueryResult<{ members: MemberRow[]; invitations: InvitationRow[] }>;
export function usePendingMembers(tenantId: string): UseQueryResult<PendingMember[]>;
export function useRoles(): UseQueryResult<RoleListItem[]>;
export function useInviteMember(tenantId: string): UseMutationResult<InvitationRow, ApiError, { email: string; role_id: string }>;
```
**Acceptance:**
- [ ] Hooks compile against the Task 2–5 response types; mutations invalidate and refetch the relevant queries.

### Task 7: Members table page (`/settings/users`)
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/settings/UsersPage.tsx`
- Create: `apps/zync-app/src/features/settings/users/MembersTable.tsx`
- Create: `apps/zync-app/src/features/settings/users/RowActionsMenu.tsx`
- Modify: `apps/zync-app/src/router.tsx` (route `/settings/users`, guarded by `users:manage`)
- Modify: settings sidebar nav registration (add `/settings/users` entry if not already present from settings-module)
**Steps:**
- [ ] Render header "Team Members" with `[+ Invite member]` (opens Task 8 sheet) and `[Export CSV]`.
- [ ] `[Export CSV]`: client-side CSV of `members` (Name, Email, Role, Status, Last active); filename `team-members.csv`.
- [ ] Render `DataTable` with columns Name, Email, Role, Status (`Badge`: active=neutral, frozen=warning, pending_approval=info), Last active (relative time, "—" when null), and a trailing `RowActionsMenu`.
- [ ] `RowActionsMenu` (`DropdownMenu`) items: Edit role, Freeze (when active), Unfreeze (when frozen), Remove — each opens the matching dialog from Task 9.
- [ ] Empty/error states via `@zync/ui` `EmptyState` / `ErrorState`.
- [ ] Accessibility: table has `aria-label="Team members"`, action menus are keyboard-navigable, status badges have text (not color-only). RTL via logical properties + `useDirection`.
**Acceptance:**
- [ ] Page lists members with correct status badges and relative last-active times.
- [ ] Non-`users:manage` users cannot reach the route (guard redirects/403).
- [ ] Export CSV downloads a well-formed file with all visible members.

### Task 8: Invite-member sheet + pending-invitations panel
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/features/settings/users/InviteMemberSheet.tsx`
- Create: `apps/zync-app/src/features/settings/users/PendingInvitations.tsx`
**Steps:**
- [ ] `InviteMemberSheet` (`Sheet`): `Input` email + `Select` role populated from `useRoles()`, OWNER option excluded. `[Cancel]` / `[Send invitation]`.
- [ ] On submit call `useInviteMember`; surface server validation (409 already member, 400 OWNER, 402 tier cap → trigger upgrade modal hook `useUpgradeModal` when present). On success close sheet, toast, and the new pending invite appears at the top.
- [ ] `PendingInvitations` collapsible panel: header `Pending invitations (N)` with collapse toggle; each row shows email, role, "Invited {relative}", `[Resend]` (`useResendInvite`, disabled+toast on 429) and `[Revoke]` (`useRevokeInvite` with a small confirm).
- [ ] Honor `prefers-reduced-motion` on the sheet slide-in and panel collapse animations.
**Acceptance:**
- [ ] Role dropdown never offers Owner.
- [ ] Sending an invite at the tier cap surfaces the upgrade path rather than a raw 402.
- [ ] Resend respects the 1/hour limit (button disabled / toast on 429); Revoke removes the row.

### Task 9: Row-action dialogs (edit role / freeze / unfreeze / remove) + pending-approval queue
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/features/settings/users/EditRoleDialog.tsx`
- Create: `apps/zync-app/src/features/settings/users/FreezeMemberDialog.tsx`
- Create: `apps/zync-app/src/features/settings/users/RemoveMemberDialog.tsx`
- Create: `apps/zync-app/src/features/settings/users/PendingApproval.tsx`
**Steps:**
- [ ] `EditRoleDialog`: `Select` of roles (`useRoles`); selecting Owner shows the transfer warning "This will transfer owner status. Your role will change to Admin." and requires explicit confirm. `[Save]` → `useChangeRole`.
- [ ] `FreezeMemberDialog`: confirmation copy from spec ("{name} will lose access immediately…"); optional reason field → `useFreezeMember`. Unfreeze is instant (no dialog) via `useUnfreezeMember` from the row menu.
- [ ] `RemoveMemberDialog`: confirmation copy ("Remove {name} from the team? Their data … preserved."); `[Remove]` → `useRemoveMember`.
- [ ] `PendingApproval` section (rendered above the table when `usePendingMembers().length > 0`): header "Pending approval (N)"; per-member email/name/role/invited date with `[Approve]`/`[Reject]`. When N>1 show `[Approve all (N)]` / `[Reject all]` → `useApproveAll`/`useRejectAll`. Reject opens a small dialog offering an optional notify+reason.
- [ ] All dialogs: focus-trapped, `role="alertdialog"`, ESC/Cancel close, reduced-motion respected.
**Acceptance:**
- [ ] Editing to Owner shows the transfer warning and demotes the actor to Admin on save.
- [ ] Freeze/Unfreeze/Remove reflect immediately in the table after mutation invalidation.
- [ ] Pending-approval section only renders when there are pending members; Approve all clears the whole queue.

### Task 10: Frozen-state access banner
**Blocks:** —  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/features/settings/users/FrozenAccessBanner.tsx`
- Modify: app shell membership guard (`apps/zync-app/src/app/AppGuard.tsx` or equivalent root layout)
**Steps:**
- [ ] When the session's current-tenant membership `status === 'frozen'`, render `FrozenAccessBanner` in place of the app content: "Your access to {business_name} has been suspended. Contact your administrator to regain access."
- [ ] Ensure the guard reads the freshest status (the freeze endpoint's `bumpUserVersion` already forces a session refresh within 60s).
- [ ] Banner uses design-system `Alert` styling, is announced via `role="alert"`, and is localized (Hebrew/RTL ready).
**Acceptance:**
- [ ] A frozen member sees only the banner, not the app, on their next session refresh after being frozen.
- [ ] Unfreezing restores normal app access on the following refresh.
