diff --git a/.githooks/pre-push b/.githooks/pre-push index 1d4dd0c..24cb86b 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -3,4 +3,19 @@ # commit names a ticket that is still in progress or under review # (commit -> review deletes done tickets -> push). See tools/quality/src/cli.mjs. # Emergency bypass (avoid): git push --no-verify + +# git passes one line per ref: . +# A local sha of all zeros means "delete the remote ref": no commits leave the +# machine, so there is nothing to check. Run the gate only if a ref sends commits. +sends_commits=false +while read -r _local_ref local_sha _remote_ref _remote_sha; do + case "$local_sha" in + *[!0]*) sends_commits=true ;; + esac +done + +if [ "$sends_commits" = false ]; then + exit 0 +fi + exec pnpm -s quality --push diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json new file mode 100644 index 0000000..5963a77 --- /dev/null +++ b/.github/rulesets/main.json @@ -0,0 +1,45 @@ +{ + "name": "main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + { "context": "Quality + integration", "integration_id": 15368 }, + { "context": "Playwright e2e", "integration_id": 15368 } + ] + } + } + ], + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "pull_request" + } + ] +} diff --git a/BACKLOG.md b/BACKLOG.md index 0422263..c45cb69 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -26,10 +26,10 @@ start ──▶ in progress ──▶ under-review ──▶ done ──▶ comm ``` **Review rules (Claude's job):** for each ticket with status `under-review`: -1. Check every acceptance criterion and the Definition of Done: read the code, run `pnpm quality`, and check the running app for user-facing work. +1. Check every acceptance criterion and the Definition of Done: read the code, run `pnpm quality`, and check the running app for user-facing work. For **user-facing** tickets (client routes, nav, pages that replace a coming-soon placeholder), also run `pnpm nx e2e @work-hub/client-e2e` against a local `pnpm dev` (or confirm the PR's **Playwright e2e** check is green) so a page moving from coming-soon to real cannot slip through. 2. **No feedback at all** → status **`done`**, its Review tracker row deleted, and Claude **gives the commit message** for the done tickets. The ticket **stays in the file** until it's committed. 3. **Any feedback** → back to **`in progress`**, with a dated **Review** entry marked 🔁 *changes requested* (a checklist), and its tracker row deleted. When the fixes land, set it to `under-review` again. -4. **After the commit** that carries a ticket as `done` (it's `done` in `HEAD`'s copy of this file), Claude **deletes** it, and **moves the next implementation from `start` to `in progress`** (the first `start` ticket(s) by priority and planned order, following the Roadmap). The deletion and the move go into the next commit. +4. **After the commit** that carries a ticket as `done` (it's `done` in `HEAD`'s copy of this file), Claude **deletes** it, and **moves the next implementations from `start` to `in progress`**: every `start` ticket at the highest remaining priority (the current milestone), in planned order. The deletion and the move go into the next commit. 5. New problems outside the ticket's scope become new `start` tickets, placed by priority. **Automatic tracking:** Claude Code hooks (`.claude/settings.json`) run the tracker at the start of each session and on every message. Claude is told when a ticket is `under-review` and hasn't been reviewed since its last change, when `done` tickets are committed and ready to delete, or when the file breaks a rule. @@ -47,9 +47,16 @@ The git hooks (`.githooks/`, enabled by `pnpm install`) add two gates: `git commit/push --no-verify` bypasses them, for emergencies only. +**Branches and pull requests (since 2026-10-18):** `main` is protected by the ruleset in `.github/rulesets/main.json`: no direct pushes, force-pushes or deletion; changes arrive by **pull request** with **both CI checks green** (`Quality + integration`, `Playwright e2e`) on an up-to-date branch, all conversations resolved, **squash merge** only (linear history), 0 approvals while there's one developer. Per ticket: +1. Work on a branch named after the ticket(s), e.g. `wh-011-permissions` (`git switch -c wh-011-permissions`). +2. Review → `done` → commit on the branch (the commit gate applies). +3. Push the branch (the push gate applies), open a PR whose **title is the commit subject** (`WH-011: …`), since the squash commit on `main` uses it. +4. CI green → **Squash and merge** → delete the branch → `git switch main && git pull`. +5. Claude deletes the merged `done` tickets and moves the next implementations to `in progress`. + Run `pnpm backlog` yourself to see the review queue (`⟳ needs review` marks what's pending) and every ticket by priority. It also lists committed `done` tickets to delete, and catches format mistakes: unknown status, duplicate ID, wrong order, stale tracker. -**Ticket ID:** `WH-###`, never reused (not even after a ticket is deleted). New tickets take the next free number: **next free is WH-078**. +**Ticket ID:** `WH-###`, never reused (not even after a ticket is deleted). New tickets take the next free number: **next free is WH-080**. **Priority:** **P0** broken or insecure, fix first · **P1** current milestone · **P2** planned · **P3** nice to have / cleanup. **Type:** `feature` · `bug` · `security` · `tech-debt` · `chore` @@ -115,19 +122,25 @@ One row per ticket whose status is `under-review`. Updated after every review; a Sorted by **priority** (P0 → P3), then by **status** (`start` → `in progress` → `under-review`), then by planned order. Done tickets are deleted. -### WH-077 · Verify CI on GitHub: first green run and a proven red check -- **Type:** chore · **Priority:** P1 · **Status:** start · **Area:** tooling -- **Blocked by:** WH-069 -- **Why:** a workflow is only proven once GitHub runs it. This can't happen inside WH-069, because the push gate only pushes done, committed work. +### WH-079 · Bug: CI e2e fails on the WH-011 PR, and the branch carries an unrelated `develop` commit +- **Type:** bug · **Priority:** P0 · **Status:** done · **Area:** tooling (e2e, git) +- **Why:** the PR for `wh-011-permissions` can't merge. **CI / Playwright e2e** fails 1 of 13 tests: `app-shell.spec.ts:7` "owner sees full nav and every item opens its route" waits for "We're building this. It'll be ready soon." on `/en/s/acme/roles`, but **WH-011 made Roles a real page** (`space-nav.ts` `roles` → `status: 'ready'`). The test kept the old coming-soon list (failed with 2 retries; the other 12 passed). The reviews missed it because `pnpm quality` doesn't run e2e and the WH-011 review didn't run it either. + - Also found: `ec7f2af` merged `origin/develop` into the branch. `develop` holds only `2e37e4c` "Initial commit: Spring Boot project setup", **unrelated history** from another project. It changed no files, but it puts a foreign root commit in the PR. - **Scope:** - - [ ] Push `main`; the **Quality + integration** and **Playwright e2e** jobs pass on the first run (link the run here). - - [ ] On a throwaway branch with a PR, add a lint error → the check goes red, then delete the branch. Optionally also break an RLS integration test. - - [ ] If a job fails only on GitHub, fix it in a new commit referencing this ticket. - - [ ] Optional: protect `main` (require the CI checks before merge) once the team works through PRs. -- **Done when:** both jobs are green on `main`, and a red check was seen on a PR. + - [x] `apps/client-e2e/src/app-shell.spec.ts`: remove `/en/s/acme/roles` from `comingSoonRoutes`; after the loop, assert the real Roles page (heading "Roles and permissions", and no coming-soon text: `toHaveCount(0)`). + - [x] Drop the stray merge from the feature branch (`git reset --hard cb49b24` before the fix, then `git push --force-with-lease`; the ruleset only protects `main`). + - [x] Decide on `origin/develop`: deleted (`git push origin --delete develop`) — leftover Spring Boot initial commit only. + - [x] Run `pnpm nx e2e @work-hub/client-e2e` locally against `pnpm dev`: 13/13. + - [x] **Process:** add "run e2e for user-facing tickets" to the review rules (Workflow → Review rules, step 1), so a page moving from coming-soon to real can't slip through again. +- **Acceptance criteria:** + - [ ] The PR's **Playwright e2e** and **Quality + integration** checks are green; the PR history shows no `develop` commit. + - [ ] The PR can be squash-merged (ruleset satisfied). +- **Blocks:** merging WH-011, WH-058, WH-077 and WH-078 (all `done` on this branch). +- **Progress (2026-10-18):** reset branch to `cb49b24` (dropped `ec7f2af` + foreign `2e37e4c`); fixed `app-shell.spec.ts` Roles assertion; review rule updated; `origin/develop` deleted; local e2e **13/13**. +- **Commit message:** `WH-079: Fix Roles e2e after WH-011 and drop the stray develop merge` ### WH-011 · Permissions management -- **Type:** feature · **Priority:** P1 · **Status:** start · **Area:** api + client +- **Type:** feature · **Priority:** P1 · **Status:** done · **Area:** api + client - **Why:** your original requirement. Workspace admins set what each member can do and can change anyone's role. - **Scope:** - change a member's role; @@ -142,64 +155,66 @@ Sorted by **priority** (P0 → P3), then by **status** (`start` → `in progress - you can't change your own role; - nobody can change the Owner's role, or make someone Owner (that's ownership transfer, WH-057); - you can only grant a role whose permissions are a **subset of your own** (no escalation). - 2. **API: custom roles.** `GET/POST/PATCH/DELETE /spaces/{id}/roles` (`roles.write`). A name plus permissions from `PERMISSIONS`; system roles can't be renamed or deleted (Owner's permissions are locked; Admin and Member editable?, decide); deleting a role in use → 409 or reassign to the default role; set the default role. + 2. **API: custom roles.** `GET/POST/PATCH/DELETE /spaces/{id}/roles`. A name plus permissions from `PERMISSIONS`; system roles can't be renamed or deleted (Owner permissions locked; Admin/Member permissions editable); deleting a role in use → 409 `ROLE_IN_USE`; set the default role via `isDefault`. 3. **UI:** a role select per row in Team → Members (`useCan(ROLES_WRITE)`); the **Roles and permissions** page replaces its placeholder (a list of roles, plus an editor with permission checkboxes grouped by area and a "used by N members" count); the invite dialog lists custom roles automatically. - Tests: an escalation attempt → 403; self-change → 403; Owner untouchable; cross-workspace role id → 404; RLS on roles writes. +- **Progress (2026-10-18):** + - API: `PATCH …/members/:id` (no self-change, Owner locked, no escalation). + - API: `GET/POST/PATCH/DELETE …/roles` — custom roles; Owner permissions locked; Admin/Member permissions editable; system roles not renamed/deleted; `ROLE_IN_USE` / default-role guards; RLS covered in integration tests. + - UI: Team role picker (`ROLES_WRITE`); Roles and permissions page (list + permission editor + default + delete); invite dialog filters Owner and lists custom roles. + - Tests: members + roles unit/integration (escalation, Owner, cross-workspace 404, RLS write). +- **Progress (2026-10-18, review fixes):** invite/resend grantable-role checks; manage only members/roles ⊆ actor (incl. deactivate/remove); block editing own role; case-insensitive reserved names; unknown perms via Zod only. Spaces tests 58/58 green. API restarted for browser check. +- **Progress (2026-10-18):** custom permission templates capped at **3** per workspace (`MAX_CUSTOM_ROLES` / `ROLE_LIMIT_REACHED`); Owner/Admin via `roles.write`. UI shows count and disables create at the limit. +- **Review (2026-10-18):** 🔁 well built, but there are **two privilege-escalation gaps** to close first. Verified: + - `pnpm quality` is green after a checker fix (see WH-078); the roles integration tests (5) cover CRUD, the Owner lock, escalation on create/update, cross-workspace 404 and `ROLE_IN_USE`, and the members tests cover role changes; + - `PATCH …/members/:id` (`roles.write`): no self-change (`CANNOT_CHANGE_OWN_ROLE`), the Owner untouchable (`CANNOT_MODIFY_OWNER`), no assigning Owner (`CANNOT_ASSIGN_OWNER`), the **new** role must be ⊆ your permissions (`ROLE_ESCALATION`); + - roles: the Owner role is locked (`SYSTEM_ROLE_LOCKED`), system roles can't be renamed or deleted, reserved names, unique `(space_id, name)` (DB constraint + check), the default role can't be cleared or deleted, `ROLE_IN_USE` counts members **and** open invites; Admin/Member permissions editable; no new migration needed. + - [x] 🔴 **Escalation through invites.** `resolveRoleId` / resend now require `permissionsAreSubset` (incl. default role) → 403 `ROLE_ESCALATION`. Unit tests on create + resend. Invite dialog only lists grantable non-Owner roles. + - [x] 🔴 **Acting on people and roles above you.** `updateRole` / `deactivate` / `remove` / `reactivate` require the **current** role ⊆ actor; `RolesService.update` / `delete` require the same for the role being changed. Tests added. + - [x] **Editing the role you hold.** `RolesService.update` / `delete` block `access.roleId` with `CANNOT_CHANGE_OWN_ROLE`. Roles UI locks own role and superior roles. + - [x] Reserved names compared case-insensitively (`owner` / `ADMIN` rejected). + - [x] Unknown permissions rejected by Zod (`z.enum(PERMISSION_LIST)`); service no longer maps unknowns to `ROLE_ESCALATION`. + - [x] Browser check: Roles page in `ar` (RTL, permission tree, 0/3 templates, New template); Team page role pickers for Admin/Member, Owner locked as plain text. +- **Review (2026-10-18, 2nd pass):** 🔁 almost done. All the security fixes are verified **live**: + - an Admin inviting into a custom "Co-owner" role (`space.settings.write`) → **403**; + - an Admin editing that role → **403**; an Admin editing their own Admin role → **403**; + - a role named `manager` → **409** (reserved, case-insensitive); an unknown permission → **400** (Zod enum). + - In code: `assertInviteRoleGrantable` (including the default role), `assertRoleWithinReach` in deactivate, reactivate, remove and role change (current **and** new role), and in role update/delete; own-role edits blocked. + - `pnpm quality`: code checks green; spaces tests 76/76. + - [x] **The invite dialog still offers roles the inviter can't grant.** Invite dialog filters with `permissionsAreSubset(role.permissions, actorPermissions)` (same as Team members); if the default isn't grantable, the first grantable role is pre-selected. + - [x] Browser check: the Team role picker and the Roles page (permission tree, "used by N", default, delete → `ROLE_IN_USE`) in `ar`; confirm Admin no longer sees ungrantable roles in Invite. +- **Progress (2026-10-18, review fixes 3rd pass):** invite dialog grantable-role filter + default fallback. +- **Review (2026-10-18, 3rd pass):** 🔁 the dialog fix is in (`invite-people-dialog.tsx` filters with `permissionsAreSubset(role.permissions, actorPermissions)`). Remaining: + - [x] The suite is red because of the member-role change shared with WH-058 (see WH-058: the `members.service.ts:350` type error and the `members-api.spec.ts` fixture). WH-011 can't be `done` while `pnpm quality` fails. → fixed under WH-058. + - [x] Browser check: Team in `ar` (role pickers, RTL); Roles in `ar` (permission tree, 0/3 templates, Manager listed, New template, default badge on Member). Dev restarted 15:56. +- **Review (2026-10-18, final):** ✅ **done.** `pnpm quality` is all green, on a branch rebased onto `e9608e2`. The suite blocker is fixed (shared with WH-058). **Browser, `ar`:** the Roles page (RTL, no English, Admin/Manager/Member\*/Owner with "used by 1 member", a "default" badge, the "0 of 3 custom templates" limit, the permission tree grouped by area with select all/none); the Team members tab with per-row role pickers. All escalation fixes were verified live in the 2nd pass (invite into a higher role → 403, edit a higher role → 403, edit own role → 403, reserved name `manager` → 409, unknown permission → 400). -### WH-072 · E2E stability: sign in once per role, not through the form in every test -- **Type:** tech-debt · **Priority:** P1 · **Status:** done · **Area:** tooling (e2e) -- **Why:** found while reviewing WH-053 (2026-10-17). Three runs of `pnpm nx e2e @work-hub/client-e2e` against `pnpm dev` gave three different results: 5 passed / 2 failed (`toHaveURL` after sign-in, `ERR_ABORTED`), then 3 passed / 4 timed out, then the API on `:3333` was down. Causes: - - **Sign-in rate limit.** `app-shell.spec.ts` signs in through the form in each test (owner, member, admin), and the sign-up test adds more. Better Auth allows **5 sign-ins per minute**, and on localhost every request shares one key (`no-trusted-ip`), so a full run (plus any manual sign-in) hits 429 and `signIn()` never reaches `/dashboard`. - - **Load.** Playwright runs tests in parallel against a single `next dev`; with compiles on first visit, `page.goto` exceeded 90 s at load average about 18. +### WH-077 · Verify CI on GitHub: first green run and a proven red check +- **Type:** chore · **Priority:** P1 · **Status:** done · **Area:** tooling +- **Why:** a workflow is only proven once GitHub runs it. This can't happen inside WH-069, because the push gate only pushes done, committed work. - **Scope:** - - [x] A Playwright **setup project** signs in once per seed role (owner, admin, member) through the API (`POST /api/auth/sign-in/email`) and saves `storageState` to `playwright/.auth/{role}.json` (gitignored). Tests use `test.use({ storageState })` instead of `signIn()`. One test still covers the real sign-in form. - - [x] `workers: 1` when local (no `CI`); parallel workers allowed in CI against built apps (WH-069). - - [x] When the API isn't reachable locally, fail fast ("start `pnpm dev` first") via `global-setup` — no auto `nx serve server`. -- **Done when:** three consecutive `pnpm nx e2e @work-hub/client-e2e` runs pass against a running `pnpm dev`, with no 429s in the API log. - - [x] Add the Team e2e test (from WH-073): Owner invites by email → the pending row appears with its badge → revoke → the preview answers "revoked". Uses the Owner storage state. -- **Progress (2026-10-18):** setup project + `auth.setup.ts` (API sign-in per role, 429 backoff); `auth-signin.spec.ts` keeps one form path; app-shell uses storageState and Team nav; `team-invites.spec.ts` added; local fail-fast + `workers: 1`. **Three consecutive local runs: 13/13 passed** (after waiting out the shared sign-in window between runs). -- **Review (2026-10-18):** 🔁 one small fix. The design is right and the **Done-when criterion is met: 3 consecutive runs of `pnpm nx e2e @work-hub/client-e2e` against `pnpm dev`, 13/13 passing each time** (runs 2 and 3 took about 1.3 min because back-to-back runs exceed 5 sign-ins/min; `auth.setup.ts` waits and retries on 429, as intended). - - `auth.setup.ts` signs in once per role through the API and saves `playwright/.auth/{role}.json` (gitignored); - - specs use `test.use({ storageState })`; `auth-signin.spec.ts` keeps the real form; sign-up and sign-in start with an empty state; - - `workers: 1` locally; `global-setup.mts` fails fast with "Start `pnpm dev` first"; - - the Team test (invite → pending → revoke → preview revoked) is in `team-invites.spec.ts`; shared helpers `env.ts`, `mailpit.ts`, `reachability.ts`. - - [x] **`pnpm quality` fails (knip):** added `src/auth.setup.ts` and `src/global-setup.mts` to the `apps/client-e2e` knip `entry` list; `pnpm quality` knip ✓. -- **Progress (2026-10-18):** review feedback fixed; relative ESM imports use `.js` extensions; back to under-review. -- **Review (2026-10-18, 2nd pass):** ✅ done. `knip.json` lists `src/auth.setup.ts` and `src/global-setup.mts` as entries; `pnpm quality` is all green. The e2e sources are unchanged since the 3 consecutive passing runs (13/13 each) against `pnpm dev`. - -### WH-069 · CI: run the checks on every push and pull request + - [x] Push `main`; the **Quality + integration** and **Playwright e2e** jobs pass on the first run (link the run here). + - [x] On a throwaway branch with a PR, add a lint error → the check goes red, then delete the branch. Optionally also break an RLS integration test. + - [x] If a job fails only on GitHub, fix it in a new commit referencing this ticket. + - [x] Protect `main` with `.github/rulesets/main.json`: GitHub → Settings → Rules → Rulesets → **New ruleset → Import a ruleset** → pick the file → **Create**. Then **delete the old classic branch protection rule** for `main` (Settings → Branches), which is the one demanding 1 approval. +- **Done when:** both jobs are green on `main`, and a red check was seen on a PR. +- **Progress (2026-10-18):** **red check proven.** A PR from `ci-red-check` (a throwaway `const ciRedCheck = 1;` in `apps/client/src/lib/__ci_red_check.ts`) failed **CI / Quality + integration** after about 2 min, on exactly the intended checks: `@work-hub/client:lint` (the unused variable) and knip (the unused file). Backlog, nx sync, typecheck, every test (client 64/64) and the translation keys passed, which proves install, migrations and Postgres work on GitHub. **CI / Playwright e2e** was skipped as designed (`needs: checks`). GitGuardian: no secrets. + - Found: the `main` protection requires **1 approving review**, and GitHub doesn't allow approving your own PR, so a solo developer is always blocked. Recommended: 0 required approvals for now plus **required status checks** (Quality + integration, Playwright e2e); raise approvals when a second developer joins. +- **Progress (2026-10-18):** decided **pull requests only** for `main`. Added `.github/rulesets/main.json` (deletion + force-push blocked, linear history, PR required with 0 approvals and resolved threads, squash only, required checks `Quality + integration` and `Playwright e2e` from GitHub Actions (`integration_id` 15368) on an up-to-date branch, admin bypass only through the PR merge button). Documented the branch/PR flow in the Workflow section and the README. +- **Progress (2026-10-18):** ruleset `main` active (id 25343016); classic branch protection gone. `ci-red-check` PR closed and remote branch deleted. First `main` run on `2f11d1a` failed on `@work-hub/storage:test` (12k×12k pixel-bomb fixture OOMs under parallel CI). Fix in PR https://github.com/msaouab/WorkHub/pull/3 — **Quality + integration** and **Playwright e2e** both green on the PR. Squash-merge when closing this batch so `main` gets its first full green run. +- **Progress (2026-10-18):** **PR #3, "WH-077: Avoid OOM in storage pixel-bomb test on CI", squash-merged as `e9608e2`.** On GitHub's runners the pixel-bomb fixture (`sharp.create` 12000×12000 → PNG) ran out of memory with the tests in parallel. The test now builds a tiny PNG whose IHDR *claims* √(limit + 1) squared pixels, so sharp's `limitInputPixels` check rejects it from the header without allocating. The merge through a PR (`#3` in the subject) shows the PR flow works. + - Review note: the new fixture has no IDAT data, so with **or without** `limitInputPixels` sharp fails, and `decode()` maps any failure to `IMAGE_UNSUPPORTED_TYPE`; the test no longer proves the limit exists. Tracked in WH-054 (map "exceeds pixel limit" to `IMAGE_TOO_LARGE`, then assert that code). + - [x] Confirm on GitHub that the `main` run for `e9608e2` is green for **both** jobs (Quality + integration, Playwright e2e), and whether PR #3 merged with the checks green or through the admin bypass. Link the run here. +- **Progress (2026-10-18):** **first green `main` run.** Squash-merge of PR #3 landed as `e9608e2`. PR checks were already green (Quality + integration + Playwright e2e on run [39134831997](https://github.com/msaouab/WorkHub/actions/runs/39134831997)) — merge was **not** via admin bypass. Push to `main` then ran CI green for both jobs: [39135928970](https://github.com/msaouab/WorkHub/actions/runs/39135928970) (`e9608e2`). + +### WH-078 · Workflow: start every next implementation after a commit, not just one - **Type:** chore · **Priority:** P1 · **Status:** done · **Area:** tooling -- **Why:** there is **no `.github/` folder**: nothing runs `typecheck/lint/test`, the integration tests or e2e except by hand (SRS §9). Reviews have caught lint failures that a pipeline would have blocked. -- **Scope:** GitHub Actions on Node 22 + pnpm cache: - - `pnpm quality` (covers backlog, sync, typecheck/lint/test, knip, unused i18n); - - a Postgres 16 service for the `*.integration.spec.ts` files (migrate deploy first); - - Playwright e2e against built apps, with Mailpit + MinIO services; - - `pnpm backlog` must print ✓ (via the quality backlog step). -- **Done when:** the workflow file is ready to run on GitHub (first green run + red-check proof → **WH-077**). -- **Plan (2026-10-18):** the repo is on GitHub (`origin`, `main` ahead of remote). - - Workflow `.github/workflows/ci.yml` on `push` to `main` and on `pull_request`: - 1. **checks:** Node 22 + `pnpm/action-setup` + pnpm store cache; `pnpm install --frozen-lockfile`; Postgres 16 + app role SQL + migrate; `pnpm quality` (**without `--gate`**: the review state is local); - 2. **e2e:** build both apps, Mailpit service + MinIO via compose, seed, Playwright (`CI=true` → parallel workers, built apps via `webServer`). - - Secrets: only test values in the workflow env; no real credentials. - - [x] the workflow file; - - [x] _(moved to WH-077)_ a red check proven on a throwaway branch (a lint error, a failing RLS test); - - [x] a README badge + "how CI works" note (feeds WH-027). -- **Progress (2026-10-18):** added `.github/workflows/ci.yml` (checks + e2e jobs) and README CI section. -- **Review (2026-10-18):** 🔁 a good structure, but it would fail on its first run. Two jobs: **Quality + integration** (Postgres 16 service, app role from `docker/postgres/init/01-app-role.sql`, migrate, `pnpm quality` without `--gate`), then **Playwright e2e** (Postgres + Mailpit services, MinIO through compose, migrate + seed, `pnpm build`, `next start` + `node apps/server/dist/main.js` via Playwright `webServer`). The README has a badge and a "CI" section. **It has never run:** `ci.yml` is uncommitted and `main` is ahead of `origin/main`. - - [x] 🔴 **The migrate step can't work.** Root script `pnpm db:migrate` (`pnpm --filter @work-hub/database exec prisma migrate deploy`); used in both CI jobs and the README. Verified locally. - - [x] **pnpm version given twice.** Removed `version:` / `PNPM_VERSION`; `packageManager` decides. Node via `node-version-file: .nvmrc`. - - [x] **The env is duplicated three times:** heredocs → `tools/ci/write-env.sh` (optional `--with-client`); workflow `env:` stays as the single source for values. - - [x] **Least privilege:** `permissions: contents: read`. - - [x] **Debuggability:** Playwright browser cache + upload report/traces on failure. - - Proving it on GitHub (first green run + a red check) is **WH-077**, since it can only happen after this ticket is committed and pushed. -- **Progress (2026-10-18):** all review feedback fixed; `pnpm quality` ✓; back to under-review. -- **Review (2026-10-18, 2nd pass):** ✅ done: - - root `pnpm db:migrate` (`pnpm --filter @work-hub/database exec prisma migrate deploy`), verified locally ("No pending migrations to apply"), used in both jobs and the README; - - `pnpm/action-setup@v4` with no `version` input (`packageManager` decides); `node-version-file: .nvmrc`; - - env written by one script, `tools/ci/write-env.sh` (`--with-client` for e2e), with required vars asserted; - - `permissions: contents: read`; the Playwright browser cache; the report uploaded on failure from `apps/client-e2e/test-output` (where Nx's TS-solution preset writes `playwright/{output,report}`); - - `pnpm quality` all green. - - The first run on GitHub (green + a proven red check) is WH-077, after the commit and push. +- **Why:** after `2f11d1a`, only WH-077 was moved to `in progress`, while the flow says "move the next implementation**s**". WH-011 (the other P1 `start` ticket) was left behind. +- **What changed (by Claude, 2026-10-18):** `nextStartTickets()` now returns **every `start` ticket at the highest remaining priority** (the current milestone), in planned order. The hook says "move the next implementations … : WH-…, WH-…", the Workflow rule 4 says the same, and the test covers several tickets plus the case with no start ticket. WH-011 was moved to `in progress`. +- **Acceptance criteria:** + - [x] Backlog tests pass (15); `pnpm backlog` ✓. + - [x] Also fixed: the pre-push hook ran the whole push gate when **deleting** a remote branch (`git push origin --delete ci-red-check` was blocked). `.githooks/pre-push` now reads git's ref lines and skips the gate when every local sha is all zeros (delete-only pushes send no commits). + - [x] Also fixed (found in the WH-011 review): the unused-translation-key check treated `Spaces.roles.permissions.*` as unused, because the page reads them as `t(\`permissions.${permission}\`)` with dotted values (`space.settings.write`), so the dynamic segment is a **grandparent** of the leaves. `findUnusedMessageKeys()` now accepts any ancestor read with `${…}`, plus a test (quality tests 10/10). ### WH-012 · Sign in with Google, GitHub, Microsoft and GitLab - **Type:** feature · **Priority:** P2 · **Status:** start · **Area:** api + client @@ -236,18 +251,6 @@ Sorted by **priority** (P0 → P3), then by **status** (`start` → `in progress - the "General" settings section becomes editable. - **Done when:** a new workspace stores all three; the Owner can edit them; Admin and Member get 403; the slug change moves the URL. -### WH-058 · Teams and the Manager role -- **Type:** feature · **Priority:** P2 · **Status:** start · **Area:** api + client + database -- **Why:** the PRD has four roles (Owner, Admin/HR, **Manager**, Employee) and FR-MEM-05/06 need teams with one or more managers who approve their team's leave. The code only has Owner, Admin and Member presets and no teams. Approvals (WH-062) can't route without this. -- **Scope:** - - a `Manager` system role preset (`leave.approve`, optional `documents.fulfil`, `calendar.read`, …); - - `teams` + `team_members` + `team_managers` tables with RLS; - - CRUD under `teams.write`; - - assign a member's team and manager (FR-MEM-05); - - the "Teams" page replaces its placeholder. -- **Done when:** an Admin creates a team, adds members and managers; a manager sees only their teams' requests (once WH-062 exists). -- **UI placement (2026-10-17, see WH-073):** teams appear as a **"Groups" tab inside the Team page**, not as a separate sidebar item. The user-facing word "Team" now means the whole workspace roster, so use "Groups" in the UI (keep `teams` as the table and permission name: `teams.write`). - ### WH-060 · Leave types and the balance ledger - **Type:** feature · **Priority:** P2 · **Status:** start · **Area:** api + client + database + worker - **Why:** FR-LVB-01..05 + FR-SPC-03. Balances are the base of every leave screen. Split into DB / API / UI / worker tickets when it starts. @@ -315,6 +318,41 @@ Sorted by **priority** (P0 → P3), then by **status** (`start` → `in progress - "Documents" replaces its placeholder. - **Done when:** a ready document downloads with a signed URL that expires after 10 min; anonymous access to the private bucket → 403. +### WH-058 · Teams and the Manager role +- **Type:** feature · **Priority:** P2 · **Status:** done · **Area:** api + client + database +- **Why:** the PRD has four roles (Owner, Admin/HR, **Manager**, Employee) and FR-MEM-05/06 need teams with one or more managers who approve their team's leave. The code only has Owner, Admin and Member presets and no teams. Approvals (WH-062) can't route without this. +- **Scope:** + - [x] a `Manager` system role preset (`leave.approve`, optional `documents.fulfil`, `calendar.read`, …); + - [x] `teams` + `team_members` + `team_managers` tables with RLS; + - [x] CRUD under `teams.write`; + - [x] assign a member's team and manager (FR-MEM-05); + - [x] the "Teams" page replaces its placeholder. +- **Done when:** an Admin creates a team, adds members and managers; a manager sees only their teams' requests (once WH-062 exists). +- **UI placement (2026-10-17, see WH-073):** teams appear as a **"Groups" tab inside the Team page**, not as a separate sidebar item. The user-facing word "Team" now means the whole workspace roster, so use "Groups" in the UI (keep `teams` as the table and permission name: `teams.write`). +- **Progress (2026-10-18):** `SYSTEM_ROLE_NAMES.MANAGER` with Member + `leave.approve` + `documents.fulfil` (Member ⊆ Manager ⊆ Admin ⊆ Owner). Migration `20261018150000_teams_and_manager_role` creates teams tables + RLS and backfills Manager on existing spaces. Seed/`systemRolesFor` create Manager for new workspaces. +- **Progress (2026-10-18):** API `GET/POST/PATCH/DELETE …/teams` (`members.read` list, `teams.write` mutations); contracts + unit/integration tests (CRUD, name conflict, RLS). Client Groups tab: list/create/edit/delete with member + manager pickers (`useCan(TEAMS_WRITE)`). Ready for batch review. +- **Review (2026-10-18):** 🔁 a solid base; three fixes. Verified: + - migration `20261018150000_teams_and_manager_role` (applied): `teams`, `team_members` and `team_managers` with forced tenant RLS, unique names per space, cascades; the **app role's grants** cover them (default privileges); + - the **Manager** backfill on existing spaces (live roles: Admin, Manager, Member\*, Owner); `systemRolesFor()` creates it for new workspaces; the catalog test keeps Member ⊆ Manager ⊆ Admin ⊆ Owner; + - **live:** a Member creating a team → 403; an Admin creating one → 201; a duplicate name → 409; a Member listing teams → 200 (`members.read`); delete → 204; tests: teams service (3) + integration (3). + - [x] **Managers who can't approve.** `assertManagersCanApprove` → 400 `VALIDATION_FAILED` when a manager's role lacks `leave.approve`. Groups picker lists only members with that permission; member list API now returns `role.permissions`. + - [x] **Same-workspace integrity in the database.** Migration uses composite FKs: `UNIQUE (id, space_id)` on `teams` / `memberships`, and `FOREIGN KEY (team_id|membership_id, space_id)` on `team_members` / `team_managers`. + - [x] Minor: backfill comment notes `gen_random_uuid()` (v4) vs app UUID v7. + - [x] Browser check: the Groups tab (create/edit with the member and manager pickers, `teams.write` gating; manager list excludes plain Members) in `ar`. +- **Progress (2026-10-18, review fixes):** manager `leave.approve` guard + UI filter; composite same-space FKs in `20261018150000_teams_and_manager_role`; member `role.permissions` on the list contract. +- **Review (2026-10-18, 2nd pass):** 🔁 the feedback is fixed, but the suite is red. Verified: + - **manager rule:** `TeamsService` rejects managers whose role lacks `leave.approve` → 400 `VALIDATION_FAILED` (unit test `teams.service.spec.ts:118`, integration `teams.integration.spec.ts:197`); the Groups picker lists only people with `leave.approve`; + - **composite FKs**, edited into the still-uncommitted migration and re-applied at 15:46 (checksum matches the file): `UNIQUE (id, space_id)` on `teams` and `memberships`, plus `(team_id, space_id)` / `(membership_id, space_id)` FKs on both link tables. **Proven in Postgres** (rolled back): a team in workspace A with a membership from B → `team_members_membership_space_fkey` violation; a link row with B's `space_id` and A's team → `team_managers_team_space_fkey` violation; + - the UUID v4 backfill comment is added. + - [x] 🔴 **Typecheck fails** in `@work-hub/spaces`: `toMember` now filters through `PERMISSION_LIST` via `catalogPermissions()`. + - [x] 🔴 **A client test fails:** `specs/members-api.spec.ts` fixture includes `role.permissions`. + - [x] Browser check: Groups in `ar` — New group dialog; managers list = Owner only (approver); members list includes Member + Owner; RTL + translated hint. Dev restarted 15:56. +- **Progress (2026-10-18, review fixes 2nd pass):** catalog filter on member permissions; members-api fixture updated; Arabic Groups browser check. +- **Review (2026-10-18, final):** ✅ **done.** + - `members.service.ts` `toMember()` now narrows role permissions with `catalogPermissions()` (a type guard over the catalog, so stale DB values never reach the contract); the `members-api.spec.ts` fixture has `role.permissions`; `pnpm quality` is all green. + - **Live, fresh API:** a plain Member as manager → **400 `VALIDATION_FAILED`**; an Admin (with `leave.approve`) → 201. + - **Browser, `ar`:** the Groups tab lists the team (manager and members, "1 member · 1 manager", Edit/Delete, New group); the **manager picker lists only approvers** (Adam Admin, Mohamed Saouab, Olivia Owner, without "Member") with an explanatory hint. The test team was deleted afterwards. + ### WH-042 · Dark mode: decide, then implement or remove the unused styles - **Type:** feature · **Priority:** P3 · **Status:** start · **Area:** client - **Why:** `global.css` has a `.dark` block, but nothing ever turns it on, and it defines no brand colors. WH-037's "check in dark mode" couldn't be done. @@ -380,7 +418,7 @@ Sorted by **priority** (P0 → P3), then by **status** (`start` → `in progress - **Why:** three small leftovers noted when WH-047 and WH-050 were closed in `d39d25a`. - **Scope:** - [ ] Delete `libs/api/auth/src/strip-client-image.ts`, its spec, and the `databaseHooks.user.update.before` that uses it. It can't work: Better Auth merges hook results (`{...actualData, ...result.data}`), so omitting `image` changes nothing. The route-level `hooks.before` on `/update-user` (400 when `image` is sent) is the real guard and has its own test. - - [ ] A pixel-bomb upload answers 415 `IMAGE_UNSUPPORTED_TYPE`. Map sharp's "exceeds pixel limit" error to 413 `IMAGE_TOO_LARGE` (or a new `IMAGE_DIMENSIONS_TOO_LARGE`) so the client can show "image too large". + - [ ] A pixel-bomb upload answers 415 `IMAGE_UNSUPPORTED_TYPE`. **Also a test gap since `e9608e2` (WH-077):** the spec's header-only PNG fails to decode with or without `limitInputPixels`, so it passes even if the limit is removed. Once the pixel-limit error has its own code, assert that code (and make the fixture's IHDR exactly 1 pixel over the limit). Map sharp's "exceeds pixel limit" error to 413 `IMAGE_TOO_LARGE` (or a new `IMAGE_DIMENSIONS_TOO_LARGE`) so the client can show "image too large". - [ ] The bucket name `workhub` is hard-coded in `docker-compose.yml` and `docker/minio/anonymous-get-object.json`, while the API reads `STORAGE_BUCKET`. Either derive both from `STORAGE_BUCKET` (render the policy with `sed` in `minio-init`) or document that they must match. ### WH-057 · Transfer ownership, archive and delete a workspace diff --git a/README.md b/README.md index 3506770..3036331 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ On every push to `main` and every pull request, GitHub Actions runs: 1. **Quality + integration** — `pnpm quality` (backlog rules, `nx sync:check`, typecheck/lint/test, knip, unused translation keys) against Postgres 16 with migrations applied. The commit gate (`--gate`) is **not** used in CI; review state is local. 2. **Playwright e2e** — builds the client and API, starts Mailpit + MinIO, seeds Acme, and runs `@work-hub/client-e2e` against the built apps. +`main` is protected by [`.github/rulesets/main.json`](.github/rulesets/main.json): changes arrive only by pull request, both checks must be green on an up-to-date branch, squash merge only. To apply or update it: Settings → Rules → Rulesets → Import a ruleset. + Local e2e: start `pnpm dev`, then `pnpm nx e2e @work-hub/client-e2e` (one worker; signs in once per seed role via the API). ## Status diff --git a/apps/client-e2e/src/app-shell.spec.ts b/apps/client-e2e/src/app-shell.spec.ts index 24e25fd..dd5cfd7 100644 --- a/apps/client-e2e/src/app-shell.spec.ts +++ b/apps/client-e2e/src/app-shell.spec.ts @@ -26,7 +26,6 @@ test.describe('app shell (WH-053) — owner', () => { { path: '/en/s/acme/holidays', heading: 'Holidays' }, { path: '/en/s/acme/documents', heading: 'Documents' }, { path: '/en/s/acme/approvals', heading: 'Approvals' }, - { path: '/en/s/acme/roles', heading: 'Roles and permissions' }, { path: '/en/s/acme/leave-policies', heading: 'Leave policies' }, ] as const; @@ -40,6 +39,15 @@ test.describe('app shell (WH-053) — owner', () => { ).toBeVisible(); } + // WH-011: Roles is a real page (not coming soon). + await page.goto('/en/s/acme/roles'); + await expect( + page.getByRole('heading', { name: 'Roles and permissions' }) + ).toBeVisible(); + await expect( + page.getByText("We're building this. It'll be ready soon.") + ).toHaveCount(0); + await page.goto('/en/s/acme/team'); await expect(page.getByRole('heading', { name: 'Team' })).toBeVisible(); await expect( diff --git a/apps/client/specs/members-api.spec.ts b/apps/client/specs/members-api.spec.ts index 11902bb..b9a8c61 100644 --- a/apps/client/specs/members-api.spec.ts +++ b/apps/client/specs/members-api.spec.ts @@ -14,6 +14,12 @@ describe('listSpaceMembersResponseSchema', () => { role: { id: '01936b3c-0000-7000-8000-000000000003', name: 'Member', + permissions: [ + 'members.read', + 'calendar.read', + 'requests.create', + 'profile.write', + ], }, membershipStatus: 'active', createdAt: '2026-10-17T12:00:00.000Z', diff --git a/apps/client/specs/space-nav.spec.ts b/apps/client/specs/space-nav.spec.ts index c32ff89..57fd748 100644 --- a/apps/client/specs/space-nav.spec.ts +++ b/apps/client/specs/space-nav.spec.ts @@ -86,8 +86,9 @@ describe('activeSpaceNavId', () => { }); describe('SPACE_NAV_ITEMS readiness', () => { - it('marks Team as ready', () => { + it('marks Team and Roles as ready', () => { expect(SPACE_NAV_ITEMS.find((i) => i.id === 'team')?.status).toBe('ready'); + expect(SPACE_NAV_ITEMS.find((i) => i.id === 'roles')?.status).toBe('ready'); }); }); diff --git a/apps/client/src/app/[locale]/(app)/s/[slug]/roles/page.tsx b/apps/client/src/app/[locale]/(app)/s/[slug]/roles/page.tsx index 8c01f1c..fd9212f 100644 --- a/apps/client/src/app/[locale]/(app)/s/[slug]/roles/page.tsx +++ b/apps/client/src/app/[locale]/(app)/s/[slug]/roles/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next'; -import { PERMISSIONS } from '@work-hub/constants'; import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { SpaceFeaturePage } from '@/components/shell/space-feature-page'; +import { SpaceRoles } from '@/components/spaces/space-roles'; type PageProps = { params: Promise<{ locale: string; slug: string }>; @@ -20,11 +19,5 @@ export async function generateMetadata({ export default async function Page({ params }: PageProps) { const { locale } = await params; setRequestLocale(locale); - return ( - - ); + return ; } diff --git a/apps/client/src/app/[locale]/(auth)/layout.tsx b/apps/client/src/app/[locale]/(auth)/layout.tsx index 2000ff8..6eb1ba2 100644 --- a/apps/client/src/app/[locale]/(auth)/layout.tsx +++ b/apps/client/src/app/[locale]/(auth)/layout.tsx @@ -9,7 +9,7 @@ export default function AuthLayout({ children }: { children: ReactNode }) { className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_20%_0%,oklch(0.92_0.035_145),transparent_45%),radial-gradient(circle_at_90%_100%,oklch(0.94_0.02_100),transparent_40%)]" /> -
+
{children}
diff --git a/apps/client/src/app/global.css b/apps/client/src/app/global.css index a1b8a14..68ed61f 100644 --- a/apps/client/src/app/global.css +++ b/apps/client/src/app/global.css @@ -242,6 +242,13 @@ --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); --radius: 0.625rem; + /* + * Clearance below scrollable page content (AppMain, auth, etc.). + * Prefer this token over ad-hoc pb-* so every surface keeps the same end gap. + */ + --app-scroll-end: max(7.5rem, calc(5rem + env(safe-area-inset-bottom, 0px))); + --app-scroll-pad-x: 1rem; + --app-scroll-pad-top: 2rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); @@ -252,6 +259,14 @@ --sidebar-ring: oklch(0.708 0 0); } +@media (min-width: 640px) { + :root { + --app-scroll-end: max(9rem, calc(6rem + env(safe-area-inset-bottom, 0px))); + --app-scroll-pad-x: 1.5rem; + --app-scroll-pad-top: 2.5rem; + } +} + .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); @@ -309,6 +324,19 @@ } } +/** + * Scrollable page column: horizontal inset + end clearance. + * Top inset lives on AppMain’s inner wrapper (not here) so `position: sticky; + * top: 0` can sit flush with the scrollport — padding on the overflow element + * leaves a gap where scrolled content stays visible above sticky bars. + * Pair with a trailing `h-(--app-scroll-end)` spacer when the region is flex+overflow + * so the gap survives flex min-height quirks. + */ +@utility app-scroll-region { + padding-inline: var(--app-scroll-pad-x); + scroll-padding-bottom: var(--app-scroll-end); +} + /* Animation delays — Tailwind `delay-*` is transition-delay only. */ @utility animate-delay-100 { animation-delay: 100ms; diff --git a/apps/client/src/components/invites/accept-invite.tsx b/apps/client/src/components/invites/accept-invite.tsx index c2c42eb..0160d8a 100644 --- a/apps/client/src/components/invites/accept-invite.tsx +++ b/apps/client/src/components/invites/accept-invite.tsx @@ -1,12 +1,12 @@ 'use client'; import { useLocale, useTranslations } from 'next-intl'; -import { useEffect, useRef } from 'react'; import { PageLoader } from '@/components/brand/logo-loader'; import { Button } from '@/components/ui/button'; import { buttonVariants } from '@/components/ui/button-variants'; import { useAuth } from '@/hooks/use-auth'; import { useAcceptInvite, useInvitePreview } from '@/hooks/use-invites'; +import { useRoleLabel } from '@/hooks/use-role-label'; import { Link, useRouter } from '@/i18n/navigation'; import { ApiError } from '@/lib/api/fetch'; import { ROUTES } from '@/lib/routes'; @@ -31,6 +31,10 @@ type AcceptInviteProps = { token: string; }; +/** + * Invite landing: preview the workspace, then the user must approve before joining. + * Signed-out users sign in / sign up first; signed-in users confirm explicitly (no auto-accept). + */ export function AcceptInvite({ token }: AcceptInviteProps) { const t = useTranslations('InviteAccept'); const locale = useLocale(); @@ -38,44 +42,12 @@ export function AcceptInvite({ token }: AcceptInviteProps) { const { isSignedIn, isPending: authPending } = useAuth(); const preview = useInvitePreview(token); const accept = useAcceptInvite(); - const autoTried = useRef(false); + const roleLabel = useRoleLabel(preview.data?.roleName ?? ''); - const signedIn = isSignedIn; const nextPath = `/invite/${encodeURIComponent(token)}`; const signInHref = `${ROUTES.signIn}?next=${encodeURIComponent(nextPath)}`; const signUpHref = `${ROUTES.signUp}?next=${encodeURIComponent(nextPath)}`; - const acceptMutate = accept.mutateAsync; - - useEffect(() => { - if ( - !signedIn || - !preview.data || - accept.isSuccess || - accept.isPending || - autoTried.current - ) { - return; - } - autoTried.current = true; - void acceptMutate(token) - .then((result) => { - router.replace(`${ROUTES.space}/${result.spaceSlug}`); - router.refresh(); - }) - .catch(() => { - // Surface via accept.error below. - }); - }, [ - signedIn, - preview.data, - accept.isSuccess, - accept.isPending, - acceptMutate, - token, - router, - ]); - if (preview.isLoading || authPending) { return ; } @@ -94,22 +66,18 @@ export function AcceptInvite({ token }: AcceptInviteProps) { return null; } - if (!signedIn) { + if (!isSignedIn) { return (
-
-

- {t('title', { space: data.spaceName })} -

-

- {t('description', { role: data.roleName })} -

- {data.kind === 'email' && data.email ? ( -

- {t('emailHint', { email: data.email })} -

- ) : null} -
+
; } @@ -162,10 +130,7 @@ export function AcceptInvite({ token }: AcceptInviteProps) { type="button" variant="outline" className="self-start" - onClick={() => { - autoTried.current = false; - accept.reset(); - }} + onClick={() => accept.reset()} > {t('tryAgain')} @@ -173,7 +138,66 @@ export function AcceptInvite({ token }: AcceptInviteProps) { ); } - return ; + return ( +
+ +
+ + + {t('decline')} + +
+
+ ); +} + +function InviteSummary({ + title, + description, + emailHint, +}: { + title: string; + description: string; + emailHint: string | null; +}) { + return ( +
+

+ {title} +

+

{description}

+ {emailHint ? ( +

{emailHint}

+ ) : null} +
+ ); } function InviteMessage({ title, body }: { title: string; body: string }) { diff --git a/apps/client/src/components/shell/app-frame.tsx b/apps/client/src/components/shell/app-frame.tsx index d37a917..a99923d 100644 --- a/apps/client/src/components/shell/app-frame.tsx +++ b/apps/client/src/components/shell/app-frame.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { useTranslations } from 'next-intl'; import { TopBar } from '@/components/shell/top-bar'; +import { cn } from '@/lib/utils'; type AppFrameProps = { children: ReactNode; @@ -42,6 +43,11 @@ export function AppFrame({ ); } +/** + * Primary scroll column for signed-in pages. + * End clearance comes from `--app-scroll-end` (global.css) via a trailing spacer — + * more reliable than padding alone on flex overflow containers. + */ export function AppMain({ children, wide = false, @@ -53,20 +59,23 @@ export function AppMain({ }) { return (
{children}
+
); } diff --git a/apps/client/src/components/shell/coming-soon.tsx b/apps/client/src/components/shell/coming-soon.tsx index 64e9f2b..a4c651b 100644 --- a/apps/client/src/components/shell/coming-soon.tsx +++ b/apps/client/src/components/shell/coming-soon.tsx @@ -15,7 +15,6 @@ export type ComingSoonFeature = | 'people' | 'approvals' | 'roles' - | 'teams' | 'leavePolicies' | 'signInMethods' | 'sessions' diff --git a/apps/client/src/components/spaces/permission-tree.tsx b/apps/client/src/components/spaces/permission-tree.tsx new file mode 100644 index 0000000..826f6be --- /dev/null +++ b/apps/client/src/components/spaces/permission-tree.tsx @@ -0,0 +1,300 @@ +'use client'; + +import { PERMISSION_LIST, type Permission } from '@work-hub/constants'; +import { ChevronRightIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useEffect, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; + +type PermissionTreeNode = { + /** Dotted path for this node (`space`, `space.settings`, …). */ + path: string; + segment: string; + /** Set when this node is a grantable leaf permission. */ + permission?: Permission; + children: PermissionTreeNode[]; +}; + +/** Build a tree from dotted permission ids, preserving catalog order. */ +export function buildPermissionTree( + catalog: readonly Permission[] = PERMISSION_LIST +): PermissionTreeNode[] { + const roots: PermissionTreeNode[] = []; + const byPath = new Map(); + + for (const permission of catalog) { + const segments = permission.split('.'); + let path = ''; + let parentChildren = roots; + + for (const [index, segment] of segments.entries()) { + if (segment === undefined) continue; + path = path ? `${path}.${segment}` : segment; + let node = byPath.get(path); + if (!node) { + node = { path, segment, children: [] }; + byPath.set(path, node); + parentChildren.push(node); + } + if (index === segments.length - 1) { + node.permission = permission; + } + parentChildren = node.children; + } + } + + return roots; +} + +const PERMISSION_TREE = buildPermissionTree(); + +/** Root branches start open; nested branches start closed. */ +function initialExpandedPaths(tree: PermissionTreeNode[]): Set { + return new Set(tree.map((node) => node.path)); +} + +type PermissionTreeProps = { + selected: readonly string[]; + grantable: ReadonlySet; + locked: boolean; + busy: boolean; + onToggleLeaf: (permission: Permission) => void; + onToggleBranch: (leaves: Permission[], checked: boolean) => void; +}; + +export function PermissionTree({ + selected, + grantable, + locked, + busy, + onToggleLeaf, + onToggleBranch, +}: PermissionTreeProps) { + const t = useTranslations('Spaces.roles'); + const selectedSet = new Set(selected); + const [expanded, setExpanded] = useState(() => + initialExpandedPaths(PERMISSION_TREE) + ); + + function toggleExpanded(path: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + } + + return ( +
    + {PERMISSION_TREE.map((node) => ( + + ))} +
+ ); +} + +function PermissionTreeItem({ + node, + depth, + selected, + grantable, + locked, + busy, + expanded, + onToggleExpanded, + onToggleLeaf, + onToggleBranch, +}: { + node: PermissionTreeNode; + depth: number; + selected: ReadonlySet; + grantable: ReadonlySet; + locked: boolean; + busy: boolean; + expanded: ReadonlySet; + onToggleExpanded: (path: string) => void; + onToggleLeaf: (permission: Permission) => void; + onToggleBranch: (leaves: Permission[], checked: boolean) => void; +}) { + const t = useTranslations('Spaces.roles'); + const leaves = collectLeaves(node); + const grantableLeaves = leaves.filter((permission) => + grantable.has(permission) + ); + const selectedCount = grantableLeaves.filter((permission) => + selected.has(permission) + ).length; + const allSelected = + grantableLeaves.length > 0 && selectedCount === grantableLeaves.length; + const someSelected = selectedCount > 0 && !allSelected; + const leafPermission = + node.children.length === 0 ? node.permission : undefined; + const isLeaf = leafPermission !== undefined; + const hasChildren = node.children.length > 0; + const isOpen = hasChildren && expanded.has(node.path); + const canInteract = + !locked && + !busy && + (leafPermission !== undefined + ? grantable.has(leafPermission) + : grantableLeaves.length > 0); + + const label = + leafPermission !== undefined + ? t(`permissions.${leafPermission}` as 'permissions.members.read') + : t(`tree.${node.segment}` as 'tree.space'); + + return ( +
  • +
    0 && 'ps-1' + )} + > + {hasChildren ? ( + + ) : ( + + )} + +
    + {hasChildren && isOpen ? ( +
      + {node.children.map((child) => ( + + ))} +
    + ) : null} +
  • + ); +} + +function collectLeaves(node: PermissionTreeNode): Permission[] { + if (node.permission && node.children.length === 0) { + return [node.permission]; + } + return node.children.flatMap(collectLeaves); +} + +function TriStateCheckbox({ + checked, + indeterminate, + disabled, + onChange, +}: { + checked: boolean; + indeterminate: boolean; + disabled: boolean; + onChange: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + ref.current.indeterminate = indeterminate; + } + }, [indeterminate]); + + return ( + + ); +} diff --git a/apps/client/src/components/spaces/space-branding.tsx b/apps/client/src/components/spaces/space-branding.tsx index 7b7365c..cfdb4dc 100644 --- a/apps/client/src/components/spaces/space-branding.tsx +++ b/apps/client/src/components/spaces/space-branding.tsx @@ -22,14 +22,10 @@ const ACCEPTED = new Set(['image/jpeg', 'image/png', 'image/webp']); type SpaceBrandingProps = { space: MeSpace; - /** Compact home layout vs settings section. */ - variant?: 'home' | 'settings'; }; -export function SpaceBranding({ - space, - variant = 'home', -}: SpaceBrandingProps) { +/** Logo + cover editor for workspace settings (not space home). */ +export function SpaceBranding({ space }: SpaceBrandingProps) { const t = useTranslations('Spaces.branding'); const canEdit = space.permissions.includes(PERMISSIONS.SPACE_SETTINGS_WRITE); const updateLogo = useUpdateSpaceLogo(space.id); @@ -148,7 +144,7 @@ export function SpaceBranding({ const coverStyle = coverBackground(space, reposition ? draftY : space.coverPositionY); return ( -
    +
    +

    {t('presets')}

    {COVER_PRESETS.map((preset) => ( diff --git a/apps/client/src/components/spaces/space-home.tsx b/apps/client/src/components/spaces/space-home.tsx index 209d825..a70c6ec 100644 --- a/apps/client/src/components/spaces/space-home.tsx +++ b/apps/client/src/components/spaces/space-home.tsx @@ -2,13 +2,13 @@ import { useEffect } from 'react'; import { useTranslations } from 'next-intl'; -import { SpaceBranding } from '@/components/spaces/space-branding'; import { useCurrentSpace } from '@/hooks/use-space'; import { useRoleLabel } from '@/hooks/use-role-label'; import { setLastSpaceSlug } from '@/lib/last-space'; /** - * Workspace home content (WH-052). Access / paused / loading are handled by SpaceShell. + * Workspace home (WH-052). Branding lives in settings; home will grow into + * KPIs / charts. Access / paused / loading are handled by SpaceShell. */ export function SpaceHome() { const t = useTranslations('Spaces.home'); @@ -32,8 +32,6 @@ export function SpaceHome() { return (
    - -

    {t('eyebrow')}

    diff --git a/apps/client/src/components/spaces/space-roles.tsx b/apps/client/src/components/spaces/space-roles.tsx new file mode 100644 index 0000000..c051e1e --- /dev/null +++ b/apps/client/src/components/spaces/space-roles.tsx @@ -0,0 +1,636 @@ +'use client'; + +import { + MAX_CUSTOM_ROLES, + PERMISSION_LIST, + PERMISSIONS, + SYSTEM_ROLE_NAMES, + permissionsAreSubset, + type Permission, +} from '@work-hub/constants'; +import type { SpaceRoleOption } from '@work-hub/contracts'; +import { useTranslations } from 'next-intl'; +import { useEffect, useMemo, useState } from 'react'; +import { LogoLoader } from '@/components/brand/logo-loader'; +import { PageHeader } from '@/components/shell/page-header'; +import { RequireSpacePermission } from '@/components/shell/require-space-permission'; +import { PermissionTree } from '@/components/spaces/permission-tree'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useRoleLabel } from '@/hooks/use-role-label'; +import { useRoleActions, useSpaceRoles } from '@/hooks/use-roles'; +import { useCurrentSpace } from '@/hooks/use-space'; +import { ApiError } from '@/lib/api/fetch'; +import { cn } from '@/lib/utils'; + +const ROLE_ERROR_CODES = [ + 'ROLE_ESCALATION', + 'SYSTEM_ROLE_LOCKED', + 'ROLE_IN_USE', + 'ROLE_LIMIT_REACHED', + 'CONFLICT', +] as const; + +type RoleErrorCode = (typeof ROLE_ERROR_CODES)[number]; + +function isRoleErrorCode(value: string): value is RoleErrorCode { + return (ROLE_ERROR_CODES as readonly string[]).includes(value); +} + +/** Roles and permissions settings (WH-011). */ +export function SpaceRoles() { + return ( + + + + ); +} + +function SpaceRolesBody() { + const t = useTranslations('Spaces.roles'); + const { space } = useCurrentSpace(); + const rolesQuery = useSpaceRoles(space?.id); + const actions = useRoleActions(space?.id ?? ''); + const [selectedId, setSelectedId] = useState(null); + const [creating, setCreating] = useState(false); + const [deleteTarget, setDeleteTarget] = useState( + null + ); + const [error, setError] = useState(null); + + const roles = rolesQuery.data?.items ?? []; + const customRoleCount = roles.filter((role) => !role.isSystem).length; + const atCustomRoleLimit = customRoleCount >= MAX_CUSTOM_ROLES; + + useEffect(() => { + if (selectedId || creating || roles.length === 0) { + return; + } + setSelectedId(roles[0]?.id ?? null); + }, [roles, selectedId, creating]); + + useEffect(() => { + if (creating && atCustomRoleLimit) { + setCreating(false); + } + }, [creating, atCustomRoleLimit]); + + const selected = roles.find((role) => role.id === selectedId) ?? null; + + if (!space) { + return null; + } + + return ( +
    + + + {rolesQuery.isPending ? ( +
    + +
    + ) : null} + + {rolesQuery.isError ? ( +

    + {t('failed')} +

    + ) : null} + + {!rolesQuery.isPending && !rolesQuery.isError ? ( +
    + + +
    + {creating ? ( + { + setCreating(false); + setSelectedId(roles[0]?.id ?? null); + setError(null); + }} + onSave={async (draft) => { + setError(null); + try { + const created = await actions.create.mutateAsync(draft); + setCreating(false); + setSelectedId(created.id); + } catch (err) { + setError(mapRoleError(err, t)); + } + }} + /> + ) : selected ? ( + { + setError(null); + const body = { + name: + draft.name !== selected.name ? draft.name : undefined, + permissions: samePermissions( + draft.permissions, + selected.permissions + ) + ? undefined + : draft.permissions, + isDefault: + draft.isDefault !== selected.isDefault + ? draft.isDefault + : undefined, + }; + if ( + body.name === undefined && + body.permissions === undefined && + body.isDefault === undefined + ) { + return; + } + try { + await actions.update.mutateAsync({ + roleId: selected.id, + body, + }); + } catch (err) { + setError(mapRoleError(err, t)); + } + }} + onDelete={() => setDeleteTarget(selected)} + /> + ) : ( +

    {t('empty')}

    + )} +
    +
    + ) : null} + + { + if (!open) { + setDeleteTarget(null); + setError(null); + } + }} + onConfirm={async () => { + if (!deleteTarget) { + return; + } + setError(null); + try { + await actions.remove.mutateAsync(deleteTarget.id); + setDeleteTarget(null); + setSelectedId(roles.find((r) => r.id !== deleteTarget.id)?.id ?? null); + } catch (err) { + setError(mapRoleError(err, t)); + } + }} + /> +
    + ); +} + +function RoleListItem({ + role, + active, + onSelect, + membersLabel, + defaultLabel, +}: { + role: SpaceRoleOption; + active: boolean; + onSelect: () => void; + membersLabel: string; + defaultLabel: string; +}) { + const label = useRoleLabel(role.name); + return ( +
  • + +
  • + ); +} + +type RoleDraft = { + name: string; + permissions: Permission[]; + isDefault: boolean; +}; + +function RoleEditor({ + mode, + role, + actorPermissions, + spaceRoleName, + busy, + error, + onSave, + onCancel, + onDelete, +}: { + mode: 'create' | 'edit'; + role?: SpaceRoleOption; + actorPermissions: readonly string[]; + spaceRoleName: string; + busy: boolean; + error: string | null; + onSave: (draft: RoleDraft) => Promise; + onCancel?: () => void; + onDelete?: () => void; +}) { + const t = useTranslations('Spaces.roles'); + const roleLabel = useRoleLabel(role?.name ?? ''); + const isOwner = role?.name === SYSTEM_ROLE_NAMES.OWNER; + const isSystem = role?.isSystem ?? false; + const isOwnRole = + mode === 'edit' && Boolean(role && role.name === spaceRoleName); + const aboveActor = + mode === 'edit' && + Boolean( + role && !permissionsAreSubset(role.permissions, actorPermissions) + ); + const nameLocked = mode === 'edit' && (isSystem || isOwnRole || aboveActor); + const permissionsLocked = isOwner || isOwnRole || aboveActor; + + const [name, setName] = useState(role?.name ?? ''); + const [permissions, setPermissions] = useState( + (role?.permissions ?? [PERMISSIONS.MEMBERS_READ]) as Permission[] + ); + const [isDefault, setIsDefault] = useState(role?.isDefault ?? false); + + useEffect(() => { + setName(role?.name ?? ''); + setPermissions( + (role?.permissions ?? [PERMISSIONS.MEMBERS_READ]) as Permission[] + ); + setIsDefault(role?.isDefault ?? false); + }, [role]); + + const grantable = useMemo( + () => + new Set( + PERMISSION_LIST.filter((permission) => + permissionsAreSubset([permission], actorPermissions) + ) + ), + [actorPermissions] + ); + + function togglePermission(permission: Permission) { + if (permissionsLocked || !grantable.has(permission)) { + return; + } + setPermissions((prev) => + prev.includes(permission) + ? prev.filter((item) => item !== permission) + : [...prev, permission] + ); + } + + function toggleBranch(leaves: Permission[], checked: boolean) { + if (permissionsLocked) { + return; + } + setPermissions((prev) => { + const next = new Set(prev); + for (const leaf of leaves) { + if (!grantable.has(leaf)) { + continue; + } + if (checked) { + next.add(leaf); + } else { + next.delete(leaf); + } + } + return [...next]; + }); + } + + const grantableList = useMemo( + () => PERMISSION_LIST.filter((permission) => grantable.has(permission)), + [grantable] + ); + const selectedGrantableCount = grantableList.filter((permission) => + permissions.includes(permission) + ).length; + const allGrantableSelected = + grantableList.length > 0 && + selectedGrantableCount === grantableList.length; + const noneGrantableSelected = selectedGrantableCount === 0; + + function selectAllPermissions() { + if (permissionsLocked || busy) { + return; + } + setPermissions(grantableList); + } + + function deselectAllPermissions() { + if (permissionsLocked || busy) { + return; + } + setPermissions((prev) => prev.filter((permission) => !grantable.has(permission))); + } + + return ( +
    { + event.preventDefault(); + void onSave({ + name: name.trim(), + permissions, + isDefault, + }); + }} + > +
    +

    + {mode === 'create' ? t('createTitle') : roleLabel} +

    + {mode === 'edit' && role ? ( +

    + {t('memberCount', { count: role.memberCount })} +

    + ) : null} +
    + +
    + + + {nameLocked ? ( +

    {t('systemNameHint')}

    + ) : null} +
    + +
    +
    +

    + {t('permissionsHeading')} +

    + {!permissionsLocked ? ( +
    + + +
    + ) : null} +
    + {isOwner ? ( +

    {t('ownerLocked')}

    + ) : null} + {isOwnRole ? ( +

    {t('ownRoleLocked')}

    + ) : null} + {aboveActor && !isOwnRole ? ( +

    {t('aboveActorLocked')}

    + ) : null} + +
    + + {!isOwner ? ( + + ) : null} + + {error ? ( +

    + {error} +

    + ) : null} + +
    + {!permissionsLocked ? ( + + ) : null} + {mode === 'create' && onCancel ? ( + + ) : null} + {mode === 'edit' && !isSystem && !permissionsLocked && onDelete ? ( + + ) : null} +
    +
    + ); +} + +function DeleteRoleDialog({ + role, + open, + busy, + error, + onOpenChange, + onConfirm, +}: { + role: SpaceRoleOption | null; + open: boolean; + busy: boolean; + error: string | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + const t = useTranslations('Spaces.roles'); + const label = useRoleLabel(role?.name ?? ''); + + return ( + + + {t('confirmDeleteTitle')} + + {t('confirmDeleteDescription', { name: label })} + + {error ? ( +

    + {error} +

    + ) : null} +
    + + +
    +
    +
    + ); +} + +function samePermissions(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) { + return false; + } + const left = new Set(a); + return b.every((item) => left.has(item)); +} + +function mapRoleError( + err: unknown, + t: ReturnType> +): string { + if (err instanceof ApiError && err.problem?.code) { + const code = err.problem.code; + if (isRoleErrorCode(code)) { + return t(`errors.${code}`); + } + } + return t('actionFailed'); +} diff --git a/apps/client/src/components/spaces/space-settings.tsx b/apps/client/src/components/spaces/space-settings.tsx index ed8d714..c243bf4 100644 --- a/apps/client/src/components/spaces/space-settings.tsx +++ b/apps/client/src/components/spaces/space-settings.tsx @@ -106,7 +106,7 @@ function SpaceSettingsBody() { })} -
    +

    {t('sections.general')}

    @@ -125,16 +125,16 @@ function SpaceSettingsBody() {

    {t('generalSoon')}

    -
    +

    {t('brandingTitle')}

    {t('brandingDescription')}

    - +
    {canBilling ? ( -
    +

    {t('sections.billing')}

    @@ -143,7 +143,7 @@ function SpaceSettingsBody() { ) : null} {canDelete ? ( -
    +

    {t('sections.danger')}

    diff --git a/apps/client/src/components/team/invite-people-dialog.tsx b/apps/client/src/components/team/invite-people-dialog.tsx index 7822aca..e00bd86 100644 --- a/apps/client/src/components/team/invite-people-dialog.tsx +++ b/apps/client/src/components/team/invite-people-dialog.tsx @@ -1,8 +1,20 @@ 'use client'; +import { + permissionsAreSubset, + SYSTEM_ROLE_NAMES, +} from '@work-hub/constants'; import { CheckIcon, ChevronDownIcon, CopyIcon, XIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useEffect, useId, useState, type FormEvent, type KeyboardEvent } from 'react'; +import { + useEffect, + useId, + useMemo, + useState, + type FormEvent, + type KeyboardEvent, +} from 'react'; +import { RoleSelect } from '@/components/team/role-select'; import { Button } from '@/components/ui/button'; import { buttonVariants } from '@/components/ui/button-variants'; import { @@ -21,7 +33,7 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useInviteActions, useSpaceRoles } from '@/hooks/use-invites'; -import { useRoleLabel } from '@/hooks/use-role-label'; +import { useCurrentSpace } from '@/hooks/use-space'; import { ApiError } from '@/lib/api/fetch'; import { parseInviteEmails } from '@/lib/team/parse-invite-emails'; import { cn } from '@/lib/utils'; @@ -46,8 +58,19 @@ export function InvitePeopleDialog({ }: InvitePeopleDialogProps) { const t = useTranslations('Team.inviteDialog'); const tInvites = useTranslations('Spaces.invites'); + const { space } = useCurrentSpace(); const actions = useInviteActions(spaceId); const rolesQuery = useSpaceRoles(open ? spaceId : undefined); + const actorPermissions = space?.permissions ?? []; + const assignableRoles = useMemo( + () => + (rolesQuery.data?.items ?? []).filter( + (role) => + role.name !== SYSTEM_ROLE_NAMES.OWNER && + permissionsAreSubset(role.permissions, actorPermissions) + ), + [rolesQuery.data?.items, actorPermissions] + ); const baseId = useId(); const [mode, setMode] = useState('email'); @@ -77,16 +100,18 @@ export function InvitePeopleDialog({ }, [open]); useEffect(() => { - if (roleId || !rolesQuery.data?.items.length) { + if (assignableRoles.length === 0) { + return; + } + if (roleId && assignableRoles.some((role) => role.id === roleId)) { return; } const preferred = - rolesQuery.data.items.find((role) => role.isDefault) ?? - rolesQuery.data.items[0]; + assignableRoles.find((role) => role.isDefault) ?? assignableRoles[0]; if (preferred) { setRoleId(preferred.id); } - }, [roleId, rolesQuery.data]); + }, [roleId, assignableRoles]); function commitChips(raw: string) { const next = parseInviteEmails(raw); @@ -222,7 +247,7 @@ export function InvitePeopleDialog({ void; - placeholder: string; -}) { - const selected = roles.find((role) => role.id === value); - const selectedLabel = useRoleLabel(selected?.name ?? ''); - - return ( - - - - {selected ? selectedLabel : placeholder} - - - - - - {roles.map((role) => ( - - ))} - - - - ); -} - -function RoleRadioItem({ id, name }: { id: string; name: string }) { - const label = useRoleLabel(name); - return ( - {label} - ); -} - function mapError( err: unknown, tInvites: ReturnType>, diff --git a/apps/client/src/components/team/role-select.tsx b/apps/client/src/components/team/role-select.tsx new file mode 100644 index 0000000..3bdf769 --- /dev/null +++ b/apps/client/src/components/team/role-select.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { ChevronDownIcon } from 'lucide-react'; +import { buttonVariants } from '@/components/ui/button-variants'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useRoleLabel } from '@/hooks/use-role-label'; +import { cn } from '@/lib/utils'; + +const fieldTriggerClassName = cn( + buttonVariants({ variant: 'outline' }), + 'w-full justify-between gap-2 px-2.5 font-normal' +); + +type RoleOption = { id: string; name: string }; + +type RoleSelectProps = { + labelledBy: string; + value: string; + roles: RoleOption[]; + disabled?: boolean; + onValueChange: (value: string) => void; + placeholder: string; + className?: string; +}; + +export function RoleSelect({ + labelledBy, + value, + roles, + disabled, + onValueChange, + placeholder, + className, +}: RoleSelectProps) { + const selected = roles.find((role) => role.id === value); + const selectedLabel = useRoleLabel(selected?.name ?? ''); + + return ( + + + + {selected ? selectedLabel : placeholder} + + + + + + {roles.map((role) => ( + + ))} + + + + ); +} + +function RoleRadioItem({ id, name }: { id: string; name: string }) { + const label = useRoleLabel(name); + return ( + {label} + ); +} diff --git a/apps/client/src/components/team/team-groups.tsx b/apps/client/src/components/team/team-groups.tsx new file mode 100644 index 0000000..21d4dac --- /dev/null +++ b/apps/client/src/components/team/team-groups.tsx @@ -0,0 +1,519 @@ +'use client'; + +import { PERMISSIONS } from '@work-hub/constants'; +import type { SpaceMember, SpaceTeam } from '@work-hub/contracts'; +import { useTranslations } from 'next-intl'; +import { useEffect, useMemo, useState } from 'react'; +import { UserAvatar } from '@/components/account/user-avatar'; +import { LogoLoader } from '@/components/brand/logo-loader'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useCan } from '@/hooks/use-can'; +import { useMembers } from '@/hooks/use-members'; +import { useCurrentSpace } from '@/hooks/use-space'; +import { useSpaceTeams, useTeamActions } from '@/hooks/use-teams'; +import { ApiError } from '@/lib/api/fetch'; +import { cn } from '@/lib/utils'; + +const TEAM_ERROR_CODES = [ + 'TEAM_NAME_TAKEN', + 'MEMBERSHIP_NOT_IN_SPACE', + 'VALIDATION_FAILED', + 'CONFLICT', +] as const; + +type TeamErrorCode = (typeof TEAM_ERROR_CODES)[number]; + +function isTeamErrorCode(value: string): value is TeamErrorCode { + return (TEAM_ERROR_CODES as readonly string[]).includes(value); +} + +/** Groups tab — workspace teams (WH-058). */ +export function TeamGroups() { + const t = useTranslations('Team.groups'); + const { space } = useCurrentSpace(); + const canWrite = useCan(PERMISSIONS.TEAMS_WRITE); + const teamsQuery = useSpaceTeams(space?.id); + const actions = useTeamActions(space?.id ?? ''); + const [editor, setEditor] = useState< + { mode: 'create' } | { mode: 'edit'; team: SpaceTeam } | null + >(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [error, setError] = useState(null); + + const teams = teamsQuery.data?.items ?? []; + + function mapError(err: unknown): string { + if (err instanceof ApiError && err.problem?.code) { + const code = err.problem.code; + if (isTeamErrorCode(code)) { + return t(`errors.${code}`); + } + } + return t('errors.failed'); + } + + if (!space) { + return null; + } + + return ( +
    +
    +

    {t('description')}

    + {canWrite ? ( + + ) : null} +
    + + {error ? ( +

    + {error} +

    + ) : null} + + {teamsQuery.isPending ? ( +
    + +
    + ) : null} + + {teamsQuery.isError ? ( +

    {t('failed')}

    + ) : null} + + {!teamsQuery.isPending && !teamsQuery.isError && teams.length === 0 ? ( +

    + {canWrite ? t('emptyWritable') : t('empty')} +

    + ) : null} + + {teams.length > 0 ? ( +
      + {teams.map((team) => ( +
    • +
      +
      +

      {team.name}

      +

      + {t('counts', { + members: team.members.length, + managers: team.managers.length, + })} +

      +
      + {canWrite ? ( +
      + + +
      + ) : null} +
      +
      + + +
      +
    • + ))} +
    + ) : null} + + {editor ? ( + setEditor(null)} + onSave={async (payload) => { + try { + if (editor.mode === 'create') { + await actions.create.mutateAsync(payload); + } else { + await actions.update.mutateAsync({ + teamId: editor.team.id, + body: payload, + }); + } + setEditor(null); + setError(null); + } catch (err) { + setError(mapError(err)); + throw err; + } + }} + /> + ) : null} + + { + if (!open) { + setDeleteTarget(null); + } + }} + > + + {t('confirmDeleteTitle')} + + {t('confirmDeleteDescription', { + name: deleteTarget?.name ?? '', + })} + +
    + + +
    +
    +
    +
    + ); +} + +function PersonList({ + title, + people, + empty, +}: { + title: string; + people: SpaceTeam['members']; + empty: string; +}) { + return ( +
    +

    + {title} +

    + {people.length === 0 ? ( +

    {empty}

    + ) : ( +
      + {people.map((person) => ( +
    • + + {person.name} +
    • + ))} +
    + )} +
    + ); +} + +function GroupEditorDialog({ + spaceId, + mode, + initial, + busy, + onClose, + onSave, +}: { + spaceId: string; + mode: 'create' | 'edit'; + initial: SpaceTeam | null; + busy: boolean; + onClose: () => void; + onSave: (payload: { + name: string; + memberIds: string[]; + managerIds: string[]; + }) => Promise; +}) { + const t = useTranslations('Team.groups'); + const membersQuery = useMembers(spaceId, { status: 'active' }); + const [name, setName] = useState(initial?.name ?? ''); + const [memberIds, setMemberIds] = useState( + () => new Set(initial?.members.map((m) => m.membershipId) ?? []) + ); + const [managerIds, setManagerIds] = useState( + () => new Set(initial?.managers.map((m) => m.membershipId) ?? []) + ); + const [localError, setLocalError] = useState(null); + + useEffect(() => { + if (membersQuery.hasNextPage && !membersQuery.isFetchingNextPage) { + void membersQuery.fetchNextPage(); + } + }, [membersQuery]); + + const people = useMemo(() => { + const items = + membersQuery.data?.pages.flatMap((page) => page.items) ?? []; + const byId = new Map(); + for (const item of items) { + byId.set(item.id, item); + } + return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, [membersQuery.data]); + + const approvers = useMemo( + () => + people.filter((person) => + person.role.permissions.includes(PERMISSIONS.LEAVE_APPROVE) + ), + [people] + ); + + useEffect(() => { + if (membersQuery.isPending) { + return; + } + const allowed = new Set(approvers.map((person) => person.id)); + setManagerIds((prev) => { + const next = new Set( + [...prev].filter((id) => allowed.has(id)) + ); + if (next.size === prev.size) { + return prev; + } + return next; + }); + }, [approvers, membersQuery.isPending]); + + function toggle(set: Set, id: string): Set { + const next = new Set(set); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + } + + return ( + { + if (!open) { + onClose(); + } + }} + > + + + {mode === 'create' ? t('createTitle') : t('editTitle')} + + + {t('editorDescription')} + + +
    { + event.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) { + setLocalError(t('nameRequired')); + return; + } + setLocalError(null); + void onSave({ + name: trimmed, + memberIds: [...memberIds], + managerIds: [...managerIds], + }).catch(() => { + /* parent surfaces API error */ + }); + }} + > +
    + + setName(event.target.value)} + maxLength={100} + required + autoFocus + /> +
    + + setManagerIds((prev) => toggle(prev, id))} + loading={membersQuery.isPending} + /> + + setMemberIds((prev) => toggle(prev, id))} + loading={membersQuery.isPending} + /> + + {localError ? ( +

    + {localError} +

    + ) : null} + +
    + + +
    + +
    +
    + ); +} + +function MemberChecklist({ + title, + hint, + empty, + people, + selected, + onToggle, + loading, +}: { + title: string; + hint: string; + empty?: string; + people: SpaceMember[]; + selected: Set; + onToggle: (membershipId: string) => void; + loading: boolean; +}) { + return ( +
    + {title} +

    {hint}

    + {loading ? ( +

    …

    + ) : people.length === 0 ? ( +

    + {empty ?? hint} +

    + ) : ( +
      + {people.map((person) => { + const checked = selected.has(person.id); + return ( +
    • + +
    • + ); + })} +
    + )} +
    + ); +} diff --git a/apps/client/src/components/team/team-members.tsx b/apps/client/src/components/team/team-members.tsx index e712b9b..b33f06a 100644 --- a/apps/client/src/components/team/team-members.tsx +++ b/apps/client/src/components/team/team-members.tsx @@ -1,12 +1,17 @@ 'use client'; -import { PERMISSIONS, SYSTEM_ROLE_NAMES } from '@work-hub/constants'; -import type { SpaceMember } from '@work-hub/contracts'; +import { + PERMISSIONS, + SYSTEM_ROLE_NAMES, + permissionsAreSubset, +} from '@work-hub/constants'; +import type { SpaceMember, SpaceRoleOption } from '@work-hub/contracts'; import { ChevronDownIcon, MoreHorizontalIcon } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useMemo, useState } from 'react'; +import { useId, useMemo, useState } from 'react'; import { UserAvatar } from '@/components/account/user-avatar'; import { LogoLoader } from '@/components/brand/logo-loader'; +import { RoleSelect } from '@/components/team/role-select'; import { Button } from '@/components/ui/button'; import { buttonVariants } from '@/components/ui/button-variants'; import { Label } from '@/components/ui/label'; @@ -40,6 +45,7 @@ import { useMe } from '@/hooks/use-me'; import { useMemberActions } from '@/hooks/use-member-actions'; import { useMembers } from '@/hooks/use-members'; import { useRoleLabel } from '@/hooks/use-role-label'; +import { useSpaceRoles } from '@/hooks/use-roles'; import { useCurrentSpace } from '@/hooks/use-space'; import { ApiError } from '@/lib/api/fetch'; import { cn } from '@/lib/utils'; @@ -59,6 +65,9 @@ const ERROR_CODES = [ 'CANNOT_DEACTIVATE_SELF', 'CANNOT_REMOVE_SELF', 'LAST_OWNER', + 'CANNOT_CHANGE_OWN_ROLE', + 'CANNOT_ASSIGN_OWNER', + 'ROLE_ESCALATION', ] as const; type MemberErrorCode = (typeof ERROR_CODES)[number]; @@ -74,6 +83,7 @@ export function TeamMembers() { const { space } = useCurrentSpace(); const { data: me } = useMe(); const canWrite = useCan(PERMISSIONS.MEMBERS_WRITE); + const canChangeRoles = useCan(PERMISSIONS.ROLES_WRITE); const [search, setSearch] = useState(''); const [status, setStatus] = useState<'' | 'active' | 'deactivated'>(''); const [role, setRole] = useState(''); @@ -83,6 +93,9 @@ export function TeamMembers() { status: status || undefined, role: role || undefined, }); + const rolesQuery = useSpaceRoles( + canChangeRoles ? space?.id : undefined + ); const actions = useMemberActions(space?.id ?? ''); const [pending, setPending] = useState(null); const [actionError, setActionError] = useState(null); @@ -92,6 +105,15 @@ export function TeamMembers() { [membersQuery.data] ); + const assignableRoles = useMemo(() => { + const actorPermissions = space?.permissions ?? []; + return (rolesQuery.data?.items ?? []).filter( + (row) => + row.name !== SYSTEM_ROLE_NAMES.OWNER && + permissionsAreSubset(row.permissions, actorPermissions) + ); + }, [rolesQuery.data?.items, space?.permissions]); + /** When the full unfiltered list is loaded, hide Leave for the sole Owner. */ const hideLeaveAsLastOwner = useMemo(() => { if ( @@ -117,8 +139,24 @@ export function TeamMembers() { actions.deactivate.isPending || actions.reactivate.isPending || actions.remove.isPending || + actions.updateRole.isPending || actions.leave.isPending; + async function onChangeRole(member: SpaceMember, roleId: string) { + if (roleId === member.role.id) { + return; + } + setActionError(null); + try { + await actions.updateRole.mutateAsync({ + membershipId: member.id, + roleId, + }); + } catch (err) { + setActionError(mapActionError(err, t)); + } + } + async function confirmPending() { if (!pending || !space) { return; @@ -238,6 +276,9 @@ export function TeamMembers() { {t('columns.status')} + + {t('columns.joined')} + {t('columns.actions')} @@ -250,6 +291,10 @@ export function TeamMembers() { member={member} isSelf={member.userId === me?.id} canWrite={canWrite} + canChangeRoles={canChangeRoles} + assignableRoles={assignableRoles} + roleChangeBusy={actions.updateRole.isPending} + onChangeRole={onChangeRole} canLeave={ member.userId === me?.id ? !hideLeaveAsLastOwner : false } @@ -263,12 +308,19 @@ export function TeamMembers() { reactivateLabel={t('reactivate')} removeLabel={t('remove')} leaveLabel={t('leave')} + rolePlaceholder={t('columns.role')} /> ))} ) : null} + {actionError && !pending ? ( +

    + {actionError} +

    + ) : null} + {membersQuery.hasNextPage ? (

    {canInvite ? ( diff --git a/apps/client/src/hooks/use-invites.ts b/apps/client/src/hooks/use-invites.ts index c6e83d6..e59c65a 100644 --- a/apps/client/src/hooks/use-invites.ts +++ b/apps/client/src/hooks/use-invites.ts @@ -14,13 +14,14 @@ import { acceptInvite, createSpaceInvite, fetchSpaceInvites, - fetchSpaceRoles, previewInvite, resendSpaceInvite, revokeSpaceInvite, } from '@/lib/api/invites'; import { queryKeys } from '@/lib/api/query-keys'; +export { useSpaceRoles } from '@/hooks/use-roles'; + /** Cursor-paginated invites for a workspace. */ export function useInvites( spaceId: string | undefined, @@ -44,19 +45,6 @@ export function useInvites( }); } -export function useSpaceRoles(spaceId: string | undefined) { - return useQuery({ - queryKey: queryKeys.invites.roles(spaceId ?? ''), - queryFn: () => { - if (!spaceId) { - throw new Error('spaceId is required'); - } - return fetchSpaceRoles(spaceId); - }, - enabled: Boolean(spaceId), - }); -} - export function useInvitePreview(token: string | undefined) { return useQuery({ queryKey: queryKeys.invites.preview(token ?? ''), diff --git a/apps/client/src/hooks/use-member-actions.ts b/apps/client/src/hooks/use-member-actions.ts index 19a2331..77eb6f1 100644 --- a/apps/client/src/hooks/use-member-actions.ts +++ b/apps/client/src/hooks/use-member-actions.ts @@ -6,6 +6,7 @@ import { useRouter } from '@/i18n/navigation'; import { deactivateMember, leaveSpace, + patchSpaceMember, reactivateMember, removeMember, } from '@/lib/api/members'; @@ -18,7 +19,7 @@ function invalidateMembers(queryClient: ReturnType, space }); } -/** Deactivate / reactivate / remove / leave for a workspace (WH-046 UI). */ +/** Deactivate / reactivate / remove / leave / change role for a workspace. */ export function useMemberActions(spaceId: string) { const queryClient = useQueryClient(); const router = useRouter(); @@ -40,6 +41,17 @@ export function useMemberActions(spaceId: string) { onSuccess: () => invalidateMembers(queryClient, spaceId), }); + const updateRole = useMutation({ + mutationFn: ({ + membershipId, + roleId, + }: { + membershipId: string; + roleId: string; + }) => patchSpaceMember(spaceId, membershipId, { roleId }), + onSuccess: () => invalidateMembers(queryClient, spaceId), + }); + const leave = useMutation({ mutationFn: () => leaveSpace(spaceId), onSuccess: () => { @@ -51,5 +63,5 @@ export function useMemberActions(spaceId: string) { }, }); - return { deactivate, reactivate, remove, leave }; + return { deactivate, reactivate, remove, updateRole, leave }; } diff --git a/apps/client/src/hooks/use-roles.ts b/apps/client/src/hooks/use-roles.ts new file mode 100644 index 0000000..dfa5dbf --- /dev/null +++ b/apps/client/src/hooks/use-roles.ts @@ -0,0 +1,66 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { CreateSpaceRole, PatchSpaceRole } from '@work-hub/contracts'; +import { + createSpaceRole, + deleteSpaceRole, + fetchSpaceRoles, + patchSpaceRole, +} from '@/lib/api/roles'; +import { queryKeys } from '@/lib/api/query-keys'; + +export function useSpaceRoles(spaceId: string | undefined) { + return useQuery({ + queryKey: queryKeys.invites.roles(spaceId ?? ''), + queryFn: () => { + if (!spaceId) { + throw new Error('spaceId is required'); + } + return fetchSpaceRoles(spaceId); + }, + enabled: Boolean(spaceId), + }); +} + +function invalidateRoles( + queryClient: ReturnType, + spaceId: string +) { + return Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.invites.roles(spaceId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.members.all(spaceId), + }), + ]); +} + +/** Create / update / delete custom workspace roles (WH-011). */ +export function useRoleActions(spaceId: string) { + const queryClient = useQueryClient(); + + const create = useMutation({ + mutationFn: (body: CreateSpaceRole) => createSpaceRole(spaceId, body), + onSuccess: () => invalidateRoles(queryClient, spaceId), + }); + + const update = useMutation({ + mutationFn: ({ + roleId, + body, + }: { + roleId: string; + body: PatchSpaceRole; + }) => patchSpaceRole(spaceId, roleId, body), + onSuccess: () => invalidateRoles(queryClient, spaceId), + }); + + const remove = useMutation({ + mutationFn: (roleId: string) => deleteSpaceRole(spaceId, roleId), + onSuccess: () => invalidateRoles(queryClient, spaceId), + }); + + return { create, update, remove }; +} diff --git a/apps/client/src/hooks/use-teams.ts b/apps/client/src/hooks/use-teams.ts new file mode 100644 index 0000000..c0d93fd --- /dev/null +++ b/apps/client/src/hooks/use-teams.ts @@ -0,0 +1,55 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { CreateSpaceTeam, PatchSpaceTeam } from '@work-hub/contracts'; +import { + createSpaceTeam, + deleteSpaceTeam, + fetchSpaceTeams, + patchSpaceTeam, +} from '@/lib/api/teams'; +import { queryKeys } from '@/lib/api/query-keys'; + +export function useSpaceTeams(spaceId: string | undefined) { + return useQuery({ + queryKey: queryKeys.teams.all(spaceId ?? ''), + queryFn: () => { + if (!spaceId) { + throw new Error('spaceId is required'); + } + return fetchSpaceTeams(spaceId); + }, + enabled: Boolean(spaceId), + }); +} + +/** Create / update / delete workspace groups (WH-058). */ +export function useTeamActions(spaceId: string) { + const queryClient = useQueryClient(); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: queryKeys.teams.all(spaceId) }); + + const create = useMutation({ + mutationFn: (body: CreateSpaceTeam) => createSpaceTeam(spaceId, body), + onSuccess: invalidate, + }); + + const update = useMutation({ + mutationFn: ({ + teamId, + body, + }: { + teamId: string; + body: PatchSpaceTeam; + }) => patchSpaceTeam(spaceId, teamId, body), + onSuccess: invalidate, + }); + + const remove = useMutation({ + mutationFn: (teamId: string) => deleteSpaceTeam(spaceId, teamId), + onSuccess: invalidate, + }); + + return { create, update, remove }; +} diff --git a/apps/client/src/lib/api/invites.ts b/apps/client/src/lib/api/invites.ts index 3e0d97f..8fa4a47 100644 --- a/apps/client/src/lib/api/invites.ts +++ b/apps/client/src/lib/api/invites.ts @@ -4,7 +4,6 @@ import { createSpaceInviteResponseSchema, invitePreviewSchema, listSpaceInvitesResponseSchema, - listSpaceRolesResponseSchema, spaceInviteSchema, type AcceptInviteResponse, type CreateSpaceEmailInvitesResponse, @@ -13,10 +12,10 @@ import { type InvitePreview, type ListSpaceInvitesQuery, type ListSpaceInvitesResponse, - type ListSpaceRolesResponse, type SpaceInvite, } from '@work-hub/contracts'; import { apiFetch } from '@/lib/api/fetch'; +export { fetchSpaceRoles } from '@/lib/api/roles'; export async function fetchSpaceInvites( spaceId: string, @@ -39,13 +38,6 @@ export async function fetchSpaceInvites( return listSpaceInvitesResponseSchema.parse(data); } -export async function fetchSpaceRoles( - spaceId: string -): Promise { - const data = await apiFetch(`spaces/${spaceId}/roles`); - return listSpaceRolesResponseSchema.parse(data); -} - export async function createSpaceInvite( spaceId: string, body: CreateSpaceInvite diff --git a/apps/client/src/lib/api/members.ts b/apps/client/src/lib/api/members.ts index 1def8dd..0369f1f 100644 --- a/apps/client/src/lib/api/members.ts +++ b/apps/client/src/lib/api/members.ts @@ -3,6 +3,7 @@ import { spaceMemberSchema, type ListSpaceMembersQuery, type ListSpaceMembersResponse, + type PatchSpaceMember, type SpaceMember, } from '@work-hub/contracts'; import { apiFetch } from '@/lib/api/fetch'; @@ -34,6 +35,18 @@ export async function fetchSpaceMembers( return listSpaceMembersResponseSchema.parse(data); } +export async function patchSpaceMember( + spaceId: string, + membershipId: string, + body: PatchSpaceMember +): Promise { + const data = await apiFetch( + `spaces/${spaceId}/members/${membershipId}`, + { method: 'PATCH', body } + ); + return spaceMemberSchema.parse(data); +} + export async function deactivateMember( spaceId: string, membershipId: string diff --git a/apps/client/src/lib/api/query-keys.ts b/apps/client/src/lib/api/query-keys.ts index 03441bd..4a51c01 100644 --- a/apps/client/src/lib/api/query-keys.ts +++ b/apps/client/src/lib/api/query-keys.ts @@ -20,4 +20,7 @@ export const queryKeys = { roles: (spaceId: string) => ['invites', spaceId, 'roles'] as const, preview: (token: string) => ['invites', 'preview', token] as const, }, + teams: { + all: (spaceId: string) => ['teams', spaceId] as const, + }, } as const; diff --git a/apps/client/src/lib/api/roles.ts b/apps/client/src/lib/api/roles.ts new file mode 100644 index 0000000..f486d5f --- /dev/null +++ b/apps/client/src/lib/api/roles.ts @@ -0,0 +1,48 @@ +import { + listSpaceRolesResponseSchema, + spaceRoleOptionSchema, + type CreateSpaceRole, + type ListSpaceRolesResponse, + type PatchSpaceRole, + type SpaceRoleOption, +} from '@work-hub/contracts'; +import { apiFetch } from '@/lib/api/fetch'; + +export async function fetchSpaceRoles( + spaceId: string +): Promise { + const data = await apiFetch(`spaces/${spaceId}/roles`); + return listSpaceRolesResponseSchema.parse(data); +} + +export async function createSpaceRole( + spaceId: string, + body: CreateSpaceRole +): Promise { + const data = await apiFetch(`spaces/${spaceId}/roles`, { + method: 'POST', + body, + }); + return spaceRoleOptionSchema.parse(data); +} + +export async function patchSpaceRole( + spaceId: string, + roleId: string, + body: PatchSpaceRole +): Promise { + const data = await apiFetch(`spaces/${spaceId}/roles/${roleId}`, { + method: 'PATCH', + body, + }); + return spaceRoleOptionSchema.parse(data); +} + +export async function deleteSpaceRole( + spaceId: string, + roleId: string +): Promise { + await apiFetch(`spaces/${spaceId}/roles/${roleId}`, { + method: 'DELETE', + }); +} diff --git a/apps/client/src/lib/api/teams.ts b/apps/client/src/lib/api/teams.ts new file mode 100644 index 0000000..a61f7d1 --- /dev/null +++ b/apps/client/src/lib/api/teams.ts @@ -0,0 +1,48 @@ +import { + listSpaceTeamsResponseSchema, + spaceTeamSchema, + type CreateSpaceTeam, + type ListSpaceTeamsResponse, + type PatchSpaceTeam, + type SpaceTeam, +} from '@work-hub/contracts'; +import { apiFetch } from '@/lib/api/fetch'; + +export async function fetchSpaceTeams( + spaceId: string +): Promise { + const data = await apiFetch(`spaces/${spaceId}/teams`); + return listSpaceTeamsResponseSchema.parse(data); +} + +export async function createSpaceTeam( + spaceId: string, + body: CreateSpaceTeam +): Promise { + const data = await apiFetch(`spaces/${spaceId}/teams`, { + method: 'POST', + body, + }); + return spaceTeamSchema.parse(data); +} + +export async function patchSpaceTeam( + spaceId: string, + teamId: string, + body: PatchSpaceTeam +): Promise { + const data = await apiFetch(`spaces/${spaceId}/teams/${teamId}`, { + method: 'PATCH', + body, + }); + return spaceTeamSchema.parse(data); +} + +export async function deleteSpaceTeam( + spaceId: string, + teamId: string +): Promise { + await apiFetch(`spaces/${spaceId}/teams/${teamId}`, { + method: 'DELETE', + }); +} diff --git a/apps/client/src/lib/nav/space-nav.ts b/apps/client/src/lib/nav/space-nav.ts index 108ee5d..0883688 100644 --- a/apps/client/src/lib/nav/space-nav.ts +++ b/apps/client/src/lib/nav/space-nav.ts @@ -102,7 +102,7 @@ export const SPACE_NAV_ITEMS: readonly SpaceNavItem[] = [ icon: 'shield', labelKey: 'roles', section: 'manage', - status: 'soon', + status: 'ready', permission: PERMISSIONS.ROLES_WRITE, }, { diff --git a/apps/client/src/messages/ar.json b/apps/client/src/messages/ar.json index e74ad72..063a927 100644 --- a/apps/client/src/messages/ar.json +++ b/apps/client/src/messages/ar.json @@ -391,6 +391,7 @@ "email": "البريد", "role": "الدور", "status": "الحالة", + "joined": "تاريخ الانضمام", "actions": "إجراءات" }, "actions": "إجراءات العضو", @@ -414,7 +415,88 @@ "CANNOT_MODIFY_OWNER": "لا يمكن تغيير المالكين بهذه الطريقة.", "CANNOT_DEACTIVATE_SELF": "لا يمكنك إيقاف نفسك.", "CANNOT_REMOVE_SELF": "استخدم المغادرة لإزالة نفسك.", - "LAST_OWNER": "انقل الملكية قبل المغادرة كآخر مالك." + "LAST_OWNER": "انقل الملكية قبل المغادرة كآخر مالك.", + "CANNOT_CHANGE_OWN_ROLE": "لا يمكنك تغيير دورك بنفسك.", + "CANNOT_ASSIGN_OWNER": "استخدم نقل الملكية لتعيين مالك.", + "ROLE_ESCALATION": "لا يمكنك منح صلاحيات لا تملكها." + } + }, + "roles": { + "title": "الأدوار والصلاحيات", + "description": "أدوار مدمجة وما يصل إلى 3 قوالب صلاحيات مخصصة يمكنك تعيينها للأشخاص.", + "loading": "جارٍ تحميل الأدوار…", + "failed": "تعذّر تحميل الأدوار. حاول مرة أخرى.", + "empty": "اختر دورًا لتعديله.", + "listHeading": "الأدوار", + "create": "قالب جديد", + "createTitle": "قالب صلاحيات جديد", + "templateCount": "{count} من {max} قوالب مخصصة", + "limitReached": "يمكنك إنشاء ما يصل إلى {max} قوالب صلاحيات مخصصة.", + "nameLabel": "اسم الدور", + "systemNameHint": "الأدوار المدمجة تحتفظ باسمها.", + "permissionsHeading": "الصلاحيات", + "selectAll": "تحديد الكل", + "deselectAll": "إلغاء تحديد الكل", + "expandBranch": "توسيع {name}", + "collapseBranch": "طي {name}", + "ownerLocked": "دور المالك يمتلك دائمًا كل الصلاحيات.", + "ownRoleLocked": "لا يمكنك تعديل الدور الذي تحمله حاليًا.", + "aboveActorLocked": "يمكنك تعديل الأدوار ذات الصلاحيات المساوية أو الأقل من صلاحياتك فقط.", + "defaultLabel": "الدور الافتراضي للدعوات الجديدة", + "defaultBadge": "افتراضي", + "memberCount": "{count, plural, =0 {غير مستخدم من أي عضو} one {يستخدمه عضو واحد} other {يستخدمه # أعضاء}}", + "save": "حفظ", + "saving": "جارٍ الحفظ…", + "cancel": "إلغاء", + "delete": "حذف الدور", + "confirmDeleteTitle": "حذف هذا الدور؟", + "confirmDeleteDescription": "ستتم إزالة «{name}». يجب نقل الأعضاء إلى دور آخر أولًا.", + "actionFailed": "فشلت هذه العملية. حاول مرة أخرى.", + "tree": { + "space": "مساحة العمل", + "settings": "الإعدادات", + "billing": "الفوترة", + "members": "الأعضاء", + "roles": "الأدوار", + "teams": "الفرق", + "invites": "الدعوات", + "leave_types": "أنواع الإجازات", + "holidays": "العطل", + "balances": "الأرصدة", + "leave": "الإجازات", + "documents": "المستندات", + "calendar": "التقويم", + "requests": "الطلبات", + "profile": "الملف الشخصي" + }, + "permissions": { + "space": { + "settings": { "write": "تعديل إعدادات مساحة العمل" }, + "delete": "حذف مساحة العمل", + "billing": { "write": "إدارة الفوترة" } + }, + "members": { + "read": "عرض الأعضاء", + "write": "إدارة الأعضاء" + }, + "roles": { "write": "إدارة الأدوار والصلاحيات" }, + "teams": { "write": "إدارة الفرق" }, + "invites": { "write": "دعوة أشخاص" }, + "leave_types": { "write": "إدارة أنواع الإجازات" }, + "holidays": { "write": "إدارة العطل" }, + "balances": { "adjust": "تعديل أرصدة الإجازات" }, + "leave": { "approve": "الموافقة على طلبات الإجازة" }, + "documents": { "fulfil": "تلبية طلبات المستندات" }, + "calendar": { "read": "عرض تقويم الفريق" }, + "requests": { "create": "إنشاء طلبات إجازة ومستندات" }, + "profile": { "write": "تعديل الملف الشخصي" } + }, + "errors": { + "ROLE_ESCALATION": "لا يمكنك منح صلاحيات لا تملكها.", + "SYSTEM_ROLE_LOCKED": "لا يمكن تغيير الأدوار المدمجة بهذه الطريقة.", + "ROLE_IN_USE": "انقل الأعضاء والدعوات عن هذا الدور قبل حذفه.", + "ROLE_LIMIT_REACHED": "يمكنك إنشاء ما يصل إلى 3 قوالب صلاحيات مخصصة.", + "CONFLICT": "يتعارض هذا التغيير مع الدور الافتراضي أو الاسم." } }, "invites": { @@ -498,6 +580,7 @@ "roles": { "Owner": "المالك", "Admin": "المشرف", + "Manager": "المدير", "Member": "عضو" }, "statusValues": { @@ -520,7 +603,7 @@ "title": "إعدادات المساحة", "description": "إدارة مظهر مساحة العمل هذه.", "brandingTitle": "الهوية البصرية", - "brandingDescription": "الشعار والغلاف الظاهران في الصفحة الرئيسية وفي المبدّل.", + "brandingDescription": "الشعار والغلاف المستخدمان في مبدّل مساحة العمل والإعدادات.", "sectionNav": "أقسام الإعدادات", "sections": { "general": "عام", @@ -588,8 +671,12 @@ "loading": "جارٍ تحميل الدعوة…", "joining": "جارٍ الانضمام إلى مساحة العمل…", "title": "انضم إلى {space}", - "description": "ستنضم بصفتك {role}.", + "description": "ستنضم بصفتك {role}. سجّل الدخول أو أنشئ حسابًا للمتابعة.", + "confirmDescription": "دُعيت للانضمام بصفتك {role}. اقبل لإضافة مساحة العمل إلى حسابك.", "emailHint": "سجّل الدخول بـ {email} لقبول هذه الدعوة.", + "emailHintSignedIn": "أُرسلت هذه الدعوة إلى {email}.", + "accept": "قبول الدعوة", + "decline": "ليس الآن", "signIn": "تسجيل الدخول", "signUp": "إنشاء حساب", "unavailableTitle": "الدعوة غير متاحة", @@ -650,6 +737,41 @@ "send": "إرسال الدعوات", "close": "إغلاق", "failed": "تعذّر إرسال الدعوات. حاول مرة أخرى." + }, + "groups": { + "description": "نظّم الأشخاص في مجموعات مع مديرين يمكنهم الموافقة على الإجازات.", + "newGroup": "مجموعة جديدة", + "loading": "جارٍ تحميل المجموعات…", + "failed": "تعذّر تحميل المجموعات. حاول مرة أخرى.", + "empty": "لا توجد مجموعات بعد.", + "emptyWritable": "لا توجد مجموعات بعد. أنشئ واحدة لإسناد المديرين والأعضاء.", + "counts": "{members, plural, one {عضو واحد} other {# أعضاء}} · {managers, plural, one {مدير واحد} other {# مديرين}}", + "managers": "المديرون", + "members": "الأعضاء", + "noManagers": "لا يوجد مديرون معيّنون.", + "noMembers": "لا يوجد أعضاء معيّنون.", + "edit": "تعديل", + "delete": "حذف", + "cancel": "إلغاء", + "createTitle": "مجموعة جديدة", + "editTitle": "تعديل المجموعة", + "editorDescription": "سمِّ المجموعة واختر المديرين والأعضاء.", + "name": "الاسم", + "nameRequired": "أدخل اسم المجموعة.", + "managersHint": "المديرون يوافقون على إجازات أفراد هذه المجموعة. يُعرض فقط من يتضمن دورهم صلاحية الموافقة على الإجازات.", + "managersEmpty": "لا يوجد من لديه صلاحية الموافقة على الإجازات. عيّن أولاً مديرًا أو مشرفًا أو مالكًا.", + "membersHint": "الأشخاص المنتمون إلى هذه المجموعة.", + "create": "إنشاء المجموعة", + "save": "حفظ", + "confirmDeleteTitle": "حذف المجموعة؟", + "confirmDeleteDescription": "ستُحذف «{name}». لن تُحذف العضويات من مساحة العمل.", + "errors": { + "TEAM_NAME_TAKEN": "توجد مجموعة بهذا الاسم بالفعل.", + "MEMBERSHIP_NOT_IN_SPACE": "شخص أو أكثر ليسوا نشطين في هذه المساحة.", + "VALIDATION_FAILED": "دور هذا الشخص لا يسمح بالموافقة على الطلبات.", + "CONFLICT": "تعارض هذا التغيير مع تحديث آخر. حاول مرة أخرى.", + "failed": "تعذّر حفظ المجموعة. حاول مرة أخرى." + } } }, "ComingSoon": { @@ -664,7 +786,6 @@ "people": "الأشخاص", "approvals": "الموافقات", "roles": "الأدوار والصلاحيات", - "teams": "المجموعات", "leavePolicies": "سياسات الإجازة", "signInMethods": "طرق تسجيل الدخول", "sessions": "الجلسات", diff --git a/apps/client/src/messages/en.json b/apps/client/src/messages/en.json index b9784df..cb7154e 100644 --- a/apps/client/src/messages/en.json +++ b/apps/client/src/messages/en.json @@ -391,6 +391,7 @@ "email": "Email", "role": "Role", "status": "Status", + "joined": "Joined", "actions": "Actions" }, "actions": "Member actions", @@ -414,7 +415,88 @@ "CANNOT_MODIFY_OWNER": "Owners can't be changed this way.", "CANNOT_DEACTIVATE_SELF": "You can't deactivate yourself.", "CANNOT_REMOVE_SELF": "Use leave to remove yourself.", - "LAST_OWNER": "Transfer ownership before leaving as the last owner." + "LAST_OWNER": "Transfer ownership before leaving as the last owner.", + "CANNOT_CHANGE_OWN_ROLE": "You can't change your own role.", + "CANNOT_ASSIGN_OWNER": "Use ownership transfer to make someone Owner.", + "ROLE_ESCALATION": "You can't grant permissions you don't have." + } + }, + "roles": { + "title": "Roles and permissions", + "description": "Built-in roles plus up to 3 custom permission templates you can assign to people.", + "loading": "Loading roles…", + "failed": "Couldn't load roles. Try again.", + "empty": "Select a role to edit it.", + "listHeading": "Roles", + "create": "New template", + "createTitle": "New permission template", + "templateCount": "{count} of {max} custom templates", + "limitReached": "You can create up to {max} custom permission templates.", + "nameLabel": "Role name", + "systemNameHint": "Built-in roles keep their name.", + "permissionsHeading": "Permissions", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "expandBranch": "Expand {name}", + "collapseBranch": "Collapse {name}", + "ownerLocked": "The Owner role always has every permission.", + "ownRoleLocked": "You can't edit the role you currently hold.", + "aboveActorLocked": "You can only edit roles with permissions at or below your own.", + "defaultLabel": "Default role for new invites", + "defaultBadge": "Default", + "memberCount": "{count, plural, =0 {Used by no members} one {Used by # member} other {Used by # members}}", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "delete": "Delete role", + "confirmDeleteTitle": "Delete this role?", + "confirmDeleteDescription": "“{name}” will be removed. Members must be moved to another role first.", + "actionFailed": "That action failed. Try again.", + "tree": { + "space": "Workspace", + "settings": "Settings", + "billing": "Billing", + "members": "Members", + "roles": "Roles", + "teams": "Teams", + "invites": "Invites", + "leave_types": "Leave types", + "holidays": "Holidays", + "balances": "Balances", + "leave": "Leave", + "documents": "Documents", + "calendar": "Calendar", + "requests": "Requests", + "profile": "Profile" + }, + "permissions": { + "space": { + "settings": { "write": "Edit workspace settings" }, + "delete": "Delete the workspace", + "billing": { "write": "Manage billing" } + }, + "members": { + "read": "View members", + "write": "Manage members" + }, + "roles": { "write": "Manage roles and permissions" }, + "teams": { "write": "Manage teams" }, + "invites": { "write": "Invite people" }, + "leave_types": { "write": "Manage leave types" }, + "holidays": { "write": "Manage holidays" }, + "balances": { "adjust": "Adjust leave balances" }, + "leave": { "approve": "Approve leave requests" }, + "documents": { "fulfil": "Fulfil document requests" }, + "calendar": { "read": "View the team calendar" }, + "requests": { "create": "Create leave and document requests" }, + "profile": { "write": "Edit own profile" } + }, + "errors": { + "ROLE_ESCALATION": "You can't grant permissions you don't have.", + "SYSTEM_ROLE_LOCKED": "Built-in roles can't be changed that way.", + "ROLE_IN_USE": "Move members and invites off this role before deleting it.", + "ROLE_LIMIT_REACHED": "You can create up to 3 custom permission templates.", + "CONFLICT": "That change conflicts with the current default role or name." } }, "invites": { @@ -498,6 +580,7 @@ "roles": { "Owner": "Owner", "Admin": "Admin", + "Manager": "Manager", "Member": "Member" }, "statusValues": { @@ -520,7 +603,7 @@ "title": "Workspace settings", "description": "Manage how this workspace looks.", "brandingTitle": "Branding", - "brandingDescription": "Logo and cover shown on the workspace home and in the switcher.", + "brandingDescription": "Logo and cover used in the workspace switcher and settings.", "sectionNav": "Settings sections", "sections": { "general": "General", @@ -588,8 +671,12 @@ "loading": "Loading invite…", "joining": "Joining workspace…", "title": "Join {space}", - "description": "You'll join as {role}.", + "description": "You'll join as {role}. Sign in or create an account to continue.", + "confirmDescription": "You've been invited to join as {role}. Accept to add this workspace to your account.", "emailHint": "Sign in with {email} to accept this invite.", + "emailHintSignedIn": "This invite was sent to {email}.", + "accept": "Accept invite", + "decline": "Not now", "signIn": "Sign in", "signUp": "Create account", "unavailableTitle": "Invite unavailable", @@ -650,6 +737,41 @@ "send": "Send invites", "close": "Close", "failed": "Couldn't send invites. Try again." + }, + "groups": { + "description": "Organize people into groups with managers who can approve leave.", + "newGroup": "New group", + "loading": "Loading groups…", + "failed": "Couldn't load groups. Try again.", + "empty": "No groups yet.", + "emptyWritable": "No groups yet. Create one to assign managers and members.", + "counts": "{members, plural, one {# member} other {# members}} · {managers, plural, one {# manager} other {# managers}}", + "managers": "Managers", + "members": "Members", + "noManagers": "No managers assigned.", + "noMembers": "No members assigned.", + "edit": "Edit", + "delete": "Delete", + "cancel": "Cancel", + "createTitle": "New group", + "editTitle": "Edit group", + "editorDescription": "Name the group and choose managers and members.", + "name": "Name", + "nameRequired": "Enter a group name.", + "managersHint": "Managers approve leave for people in this group. Only people whose role includes leave approval are listed.", + "managersEmpty": "No one with leave approval is available. Assign Manager, Admin, or Owner first.", + "membersHint": "People who belong to this group.", + "create": "Create group", + "save": "Save", + "confirmDeleteTitle": "Delete group?", + "confirmDeleteDescription": "“{name}” will be removed. Memberships are not deleted.", + "errors": { + "TEAM_NAME_TAKEN": "A group with this name already exists.", + "MEMBERSHIP_NOT_IN_SPACE": "One or more people aren't active in this workspace.", + "VALIDATION_FAILED": "This person's role can't approve requests.", + "CONFLICT": "That change conflicted with another update. Try again.", + "failed": "Couldn't save the group. Try again." + } } }, "ComingSoon": { @@ -664,7 +786,6 @@ "people": "People", "approvals": "Approvals", "roles": "Roles and permissions", - "teams": "Groups", "leavePolicies": "Leave policies", "signInMethods": "Sign-in methods", "sessions": "Sessions", diff --git a/apps/client/src/messages/fr.json b/apps/client/src/messages/fr.json index ac56b9d..80eeddd 100644 --- a/apps/client/src/messages/fr.json +++ b/apps/client/src/messages/fr.json @@ -391,6 +391,7 @@ "email": "E-mail", "role": "Rôle", "status": "Statut", + "joined": "Rejoint le", "actions": "Actions" }, "actions": "Actions du membre", @@ -414,7 +415,88 @@ "CANNOT_MODIFY_OWNER": "Les propriétaires ne peuvent pas être modifiés ainsi.", "CANNOT_DEACTIVATE_SELF": "Vous ne pouvez pas vous désactiver.", "CANNOT_REMOVE_SELF": "Utilisez « quitter » pour vous retirer.", - "LAST_OWNER": "Transférez la propriété avant de quitter en tant que dernier propriétaire." + "LAST_OWNER": "Transférez la propriété avant de quitter en tant que dernier propriétaire.", + "CANNOT_CHANGE_OWN_ROLE": "Vous ne pouvez pas modifier votre propre rôle.", + "CANNOT_ASSIGN_OWNER": "Utilisez le transfert de propriété pour nommer un propriétaire.", + "ROLE_ESCALATION": "Vous ne pouvez pas accorder des permissions que vous n'avez pas." + } + }, + "roles": { + "title": "Rôles et permissions", + "description": "Rôles intégrés et jusqu'à 3 modèles de permissions personnalisés à attribuer.", + "loading": "Chargement des rôles…", + "failed": "Impossible de charger les rôles. Réessayez.", + "empty": "Sélectionnez un rôle pour le modifier.", + "listHeading": "Rôles", + "create": "Nouveau modèle", + "createTitle": "Nouveau modèle de permissions", + "templateCount": "{count} sur {max} modèles personnalisés", + "limitReached": "Vous pouvez créer jusqu'à {max} modèles de permissions personnalisés.", + "nameLabel": "Nom du rôle", + "systemNameHint": "Les rôles intégrés conservent leur nom.", + "permissionsHeading": "Permissions", + "selectAll": "Tout sélectionner", + "deselectAll": "Tout désélectionner", + "expandBranch": "Développer {name}", + "collapseBranch": "Réduire {name}", + "ownerLocked": "Le rôle Propriétaire a toujours toutes les permissions.", + "ownRoleLocked": "Vous ne pouvez pas modifier le rôle que vous détenez.", + "aboveActorLocked": "Vous ne pouvez modifier que des rôles dont les permissions sont inférieures ou égales aux vôtres.", + "defaultLabel": "Rôle par défaut pour les nouvelles invitations", + "defaultBadge": "Par défaut", + "memberCount": "{count, plural, =0 {Utilisé par aucun membre} one {Utilisé par # membre} other {Utilisé par # membres}}", + "save": "Enregistrer", + "saving": "Enregistrement…", + "cancel": "Annuler", + "delete": "Supprimer le rôle", + "confirmDeleteTitle": "Supprimer ce rôle ?", + "confirmDeleteDescription": "« {name} » sera supprimé. Les membres doivent d'abord être déplacés vers un autre rôle.", + "actionFailed": "Cette action a échoué. Réessayez.", + "tree": { + "space": "Espace", + "settings": "Paramètres", + "billing": "Facturation", + "members": "Membres", + "roles": "Rôles", + "teams": "Équipes", + "invites": "Invitations", + "leave_types": "Types de congés", + "holidays": "Jours fériés", + "balances": "Soldes", + "leave": "Congés", + "documents": "Documents", + "calendar": "Calendrier", + "requests": "Demandes", + "profile": "Profil" + }, + "permissions": { + "space": { + "settings": { "write": "Modifier les paramètres de l'espace" }, + "delete": "Supprimer l'espace", + "billing": { "write": "Gérer la facturation" } + }, + "members": { + "read": "Voir les membres", + "write": "Gérer les membres" + }, + "roles": { "write": "Gérer les rôles et permissions" }, + "teams": { "write": "Gérer les équipes" }, + "invites": { "write": "Inviter des personnes" }, + "leave_types": { "write": "Gérer les types de congés" }, + "holidays": { "write": "Gérer les jours fériés" }, + "balances": { "adjust": "Ajuster les soldes de congés" }, + "leave": { "approve": "Approuver les demandes de congés" }, + "documents": { "fulfil": "Traiter les demandes de documents" }, + "calendar": { "read": "Voir le calendrier d'équipe" }, + "requests": { "create": "Créer des demandes de congés et documents" }, + "profile": { "write": "Modifier son propre profil" } + }, + "errors": { + "ROLE_ESCALATION": "Vous ne pouvez pas accorder des permissions que vous n'avez pas.", + "SYSTEM_ROLE_LOCKED": "Les rôles intégrés ne peuvent pas être modifiés ainsi.", + "ROLE_IN_USE": "Déplacez les membres et invitations avant de supprimer ce rôle.", + "ROLE_LIMIT_REACHED": "Vous pouvez créer jusqu'à 3 modèles de permissions personnalisés.", + "CONFLICT": "Ce changement entre en conflit avec le rôle par défaut ou le nom." } }, "invites": { @@ -498,6 +580,7 @@ "roles": { "Owner": "Propriétaire", "Admin": "Admin", + "Manager": "Manager", "Member": "Membre" }, "statusValues": { @@ -520,7 +603,7 @@ "title": "Paramètres de l'espace", "description": "Gérez l'apparence de cet espace.", "brandingTitle": "Apparence", - "brandingDescription": "Logo et couverture affichés sur l'accueil et dans le sélecteur.", + "brandingDescription": "Logo et couverture utilisés dans le sélecteur et les paramètres.", "sectionNav": "Sections des paramètres", "sections": { "general": "Général", @@ -588,8 +671,12 @@ "loading": "Chargement de l'invitation…", "joining": "Rejoindre l'espace…", "title": "Rejoindre {space}", - "description": "Vous rejoindrez en tant que {role}.", + "description": "Vous rejoindrez en tant que {role}. Connectez-vous ou créez un compte pour continuer.", + "confirmDescription": "Vous êtes invité(e) en tant que {role}. Acceptez pour ajouter cet espace à votre compte.", "emailHint": "Connectez-vous avec {email} pour accepter cette invitation.", + "emailHintSignedIn": "Cette invitation a été envoyée à {email}.", + "accept": "Accepter l'invitation", + "decline": "Pas maintenant", "signIn": "Se connecter", "signUp": "Créer un compte", "unavailableTitle": "Invitation indisponible", @@ -650,6 +737,41 @@ "send": "Envoyer les invitations", "close": "Fermer", "failed": "Impossible d'envoyer les invitations. Réessayez." + }, + "groups": { + "description": "Organisez les personnes en groupes avec des managers qui approuvent les congés.", + "newGroup": "Nouveau groupe", + "loading": "Chargement des groupes…", + "failed": "Impossible de charger les groupes. Réessayez.", + "empty": "Aucun groupe pour l’instant.", + "emptyWritable": "Aucun groupe. Créez-en un pour assigner managers et membres.", + "counts": "{members, plural, one {# membre} other {# membres}} · {managers, plural, one {# manager} other {# managers}}", + "managers": "Managers", + "members": "Membres", + "noManagers": "Aucun manager assigné.", + "noMembers": "Aucun membre assigné.", + "edit": "Modifier", + "delete": "Supprimer", + "cancel": "Annuler", + "createTitle": "Nouveau groupe", + "editTitle": "Modifier le groupe", + "editorDescription": "Nommez le groupe et choisissez managers et membres.", + "name": "Nom", + "nameRequired": "Entrez un nom de groupe.", + "managersHint": "Les managers approuvent les congés des personnes du groupe. Seules les personnes dont le rôle inclut l’approbation des congés sont listées.", + "managersEmpty": "Personne n’a l’approbation des congés. Attribuez d’abord Manager, Admin ou Owner.", + "membersHint": "Personnes qui appartiennent à ce groupe.", + "create": "Créer le groupe", + "save": "Enregistrer", + "confirmDeleteTitle": "Supprimer le groupe ?", + "confirmDeleteDescription": "« {name} » sera supprimé. Les adhésions ne sont pas effacées.", + "errors": { + "TEAM_NAME_TAKEN": "Un groupe porte déjà ce nom.", + "MEMBERSHIP_NOT_IN_SPACE": "Une ou plusieurs personnes ne sont pas actives dans cet espace.", + "VALIDATION_FAILED": "Le rôle de cette personne ne permet pas d’approuver les demandes.", + "CONFLICT": "Ce changement est en conflit. Réessayez.", + "failed": "Impossible d’enregistrer le groupe. Réessayez." + } } }, "ComingSoon": { @@ -664,7 +786,6 @@ "people": "Personnes", "approvals": "Approbations", "roles": "Rôles et permissions", - "teams": "Groupes", "leavePolicies": "Politiques de congés", "signInMethods": "Méthodes de connexion", "sessions": "Sessions", diff --git a/libs/api/database/prisma/migrations/20261018150000_teams_and_manager_role/migration.sql b/libs/api/database/prisma/migrations/20261018150000_teams_and_manager_role/migration.sql new file mode 100644 index 0000000..193d295 --- /dev/null +++ b/libs/api/database/prisma/migrations/20261018150000_teams_and_manager_role/migration.sql @@ -0,0 +1,170 @@ +-- WH-058: teams (Groups in UI) + Manager system role for existing workspaces. + +-- --------------------------------------------------------------------------- +-- Tables +-- --------------------------------------------------------------------------- + +CREATE TABLE "teams" ( + "id" UUID NOT NULL, + "space_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "teams_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "teams_space_id_name_key" ON "teams"("space_id", "name"); +CREATE UNIQUE INDEX "teams_id_space_id_key" ON "teams"("id", "space_id"); +CREATE INDEX "teams_space_id_idx" ON "teams"("space_id"); + +ALTER TABLE "teams" ADD CONSTRAINT "teams_space_id_fkey" + FOREIGN KEY ("space_id") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Composite FK targets so team_members / team_managers cannot cross workspaces (NFR-01). +CREATE UNIQUE INDEX "memberships_id_space_id_key" ON "memberships"("id", "space_id"); + +CREATE TABLE "team_members" ( + "id" UUID NOT NULL, + "space_id" UUID NOT NULL, + "team_id" UUID NOT NULL, + "membership_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "team_members_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "team_members_team_id_membership_id_key" + ON "team_members"("team_id", "membership_id"); +CREATE INDEX "team_members_space_id_idx" ON "team_members"("space_id"); +CREATE INDEX "team_members_membership_id_idx" ON "team_members"("membership_id"); + +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_space_fkey" + FOREIGN KEY ("team_id", "space_id") REFERENCES "teams"("id", "space_id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_membership_space_fkey" + FOREIGN KEY ("membership_id", "space_id") REFERENCES "memberships"("id", "space_id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TABLE "team_managers" ( + "id" UUID NOT NULL, + "space_id" UUID NOT NULL, + "team_id" UUID NOT NULL, + "membership_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "team_managers_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "team_managers_team_id_membership_id_key" + ON "team_managers"("team_id", "membership_id"); +CREATE INDEX "team_managers_space_id_idx" ON "team_managers"("space_id"); +CREATE INDEX "team_managers_membership_id_idx" ON "team_managers"("membership_id"); + +ALTER TABLE "team_managers" ADD CONSTRAINT "team_managers_team_space_fkey" + FOREIGN KEY ("team_id", "space_id") REFERENCES "teams"("id", "space_id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "team_managers" ADD CONSTRAINT "team_managers_membership_space_fkey" + FOREIGN KEY ("membership_id", "space_id") REFERENCES "memberships"("id", "space_id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- --------------------------------------------------------------------------- +-- Row-level security (NFR-01). Tenant by app.space_id. +-- --------------------------------------------------------------------------- + +ALTER TABLE "teams" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "teams" FORCE ROW LEVEL SECURITY; + +CREATE POLICY teams_select ON "teams" + FOR SELECT + USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +CREATE POLICY teams_insert ON "teams" + FOR INSERT + WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +CREATE POLICY teams_update ON "teams" + FOR UPDATE + USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid) + WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +CREATE POLICY teams_delete ON "teams" + FOR DELETE + USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +ALTER TABLE "team_members" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "team_members" FORCE ROW LEVEL SECURITY; + +CREATE POLICY team_members_select ON "team_members" + FOR SELECT + USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +CREATE POLICY team_members_insert ON "team_members" + FOR INSERT + WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +CREATE POLICY team_members_update ON "team_members" + FOR UPDATE + USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid) + WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +CREATE POLICY team_members_delete ON "team_members" + FOR DELETE + USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +ALTER TABLE "team_managers" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "team_managers" FORCE ROW LEVEL SECURITY; + +CREATE POLICY team_managers_select ON "team_managers" + FOR SELECT + USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +CREATE POLICY team_managers_insert ON "team_managers" + FOR INSERT + WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +CREATE POLICY team_managers_update ON "team_managers" + FOR UPDATE + USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid) + WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +CREATE POLICY team_managers_delete ON "team_managers" + FOR DELETE + USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid); + +-- --------------------------------------------------------------------------- +-- Backfill Manager system role (Member + leave.approve + documents.fulfil). +-- New workspaces get it from systemRolesFor(); existing ones need this insert. +-- gen_random_uuid() is UUID v4; app-created ids use UUID v7. Acceptable for a +-- one-off backfill (Postgres 16 has no uuidv7()). +-- --------------------------------------------------------------------------- + +INSERT INTO "roles" ( + "id", + "space_id", + "name", + "permissions", + "is_system", + "created_at", + "updated_at" +) +SELECT + gen_random_uuid(), + s.id, + 'Manager', + ARRAY[ + 'members.read', + 'calendar.read', + 'requests.create', + 'profile.write', + 'leave.approve', + 'documents.fulfil' + ]::text[], + true, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP +FROM "spaces" s +WHERE NOT EXISTS ( + SELECT 1 FROM "roles" r + WHERE r.space_id = s.id AND r.name = 'Manager' +); diff --git a/libs/api/database/prisma/schema.prisma b/libs/api/database/prisma/schema.prisma index a1b0099..d16cb41 100644 --- a/libs/api/database/prisma/schema.prisma +++ b/libs/api/database/prisma/schema.prisma @@ -58,6 +58,7 @@ model Space { roles Role[] @relation("SpaceRoles") memberships Membership[] invites Invite[] + teams Team[] @@map("spaces") } @@ -124,12 +125,68 @@ model Membership { user User @relation(fields: [userId], references: [id], onDelete: Cascade) role Role @relation(fields: [roleId], references: [id], onDelete: Restrict) + teamMemberships TeamMember[] + teamManagerOf TeamManager[] + @@unique([spaceId, userId]) + /** Composite target for team_members / team_managers same-space FKs (WH-058). */ + @@unique([id, spaceId]) @@index([userId]) @@index([roleId]) @@map("memberships") } +/** Workspace group (UI: “Groups”; API/DB: teams) — WH-058. */ +model Team { + id String @id @default(uuid(7)) @db.Uuid + spaceId String @map("space_id") @db.Uuid + name String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + members TeamMember[] + managers TeamManager[] + + @@unique([spaceId, name]) + /** Composite target for team_members / team_managers same-space FKs (WH-058). */ + @@unique([id, spaceId]) + @@index([spaceId]) + @@map("teams") +} + +model TeamMember { + id String @id @default(uuid(7)) @db.Uuid + spaceId String @map("space_id") @db.Uuid + teamId String @map("team_id") @db.Uuid + membershipId String @map("membership_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + team Team @relation(fields: [teamId, spaceId], references: [id, spaceId], onDelete: Cascade) + membership Membership @relation(fields: [membershipId, spaceId], references: [id, spaceId], onDelete: Cascade) + + @@unique([teamId, membershipId]) + @@index([spaceId]) + @@index([membershipId]) + @@map("team_members") +} + +model TeamManager { + id String @id @default(uuid(7)) @db.Uuid + spaceId String @map("space_id") @db.Uuid + teamId String @map("team_id") @db.Uuid + membershipId String @map("membership_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + team Team @relation(fields: [teamId, spaceId], references: [id, spaceId], onDelete: Cascade) + membership Membership @relation(fields: [membershipId, spaceId], references: [id, spaceId], onDelete: Cascade) + + @@unique([teamId, membershipId]) + @@index([spaceId]) + @@index([membershipId]) + @@map("team_managers") +} + model Session { id String @id @default(uuid(7)) @db.Uuid token String @unique diff --git a/libs/api/spaces/src/controllers/space-members.controller.ts b/libs/api/spaces/src/controllers/space-members.controller.ts index 47a197e..3bc8ef5 100644 --- a/libs/api/spaces/src/controllers/space-members.controller.ts +++ b/libs/api/spaces/src/controllers/space-members.controller.ts @@ -1,10 +1,12 @@ import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, + Patch, Post, Query, UseGuards, @@ -30,6 +32,7 @@ import type { } from '@work-hub/contracts'; import { PROBLEM_DETAILS_REF, SESSION_SECURITY } from '@work-hub/core'; import { ListSpaceMembersQueryDto } from '../dto/list-space-members-query.dto'; +import { PatchSpaceMemberDto } from '../dto/patch-space-member.dto'; import { MembersService } from '../members.service'; @ApiTags('spaces') @@ -55,6 +58,24 @@ export class SpaceMembersController { return this.members.list(access, query); } + @Patch(':membershipId') + @RequirePermission(PERMISSIONS.ROLES_WRITE) + @ApiOperation({ summary: 'Change a member role' }) + @ApiParam({ name: 'spaceId', format: 'uuid' }) + @ApiParam({ name: 'membershipId', format: 'uuid' }) + @ApiResponse({ status: 200, description: 'Member with updated role' }) + @ApiResponse({ status: 401, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 403, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 404, ...PROBLEM_DETAILS_REF }) + async updateRole( + @Param('spaceId') _spaceId: string, + @Param('membershipId') membershipId: string, + @Body() body: PatchSpaceMemberDto, + @CurrentSpaceAccess() access: SpaceAccess + ): Promise { + return this.members.updateRole(access, membershipId, body.roleId); + } + @Post(':membershipId/deactivate') @HttpCode(HttpStatus.OK) @RequirePermission(PERMISSIONS.MEMBERS_WRITE) diff --git a/libs/api/spaces/src/controllers/space-roles.controller.ts b/libs/api/spaces/src/controllers/space-roles.controller.ts index 67d0fef..9c32b46 100644 --- a/libs/api/spaces/src/controllers/space-roles.controller.ts +++ b/libs/api/spaces/src/controllers/space-roles.controller.ts @@ -1,4 +1,15 @@ -import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; import { ApiCookieAuth, ApiOperation, @@ -14,24 +25,29 @@ import { type SpaceAccess, } from '@work-hub/access'; import { PERMISSIONS } from '@work-hub/constants'; -import type { ListSpaceRolesResponse } from '@work-hub/contracts'; +import type { + ListSpaceRolesResponse, + SpaceRoleOption, +} from '@work-hub/contracts'; import { PROBLEM_DETAILS_REF, SESSION_SECURITY } from '@work-hub/core'; -import { InvitesService } from '../invites.service'; +import { CreateSpaceRoleDto } from '../dto/create-space-role.dto'; +import { PatchSpaceRoleDto } from '../dto/patch-space-role.dto'; +import { RolesService } from '../roles.service'; @ApiTags('spaces') @ApiCookieAuth(SESSION_SECURITY) @Controller('spaces/:spaceId/roles') @UseGuards(SpaceGuard, RequirePermissionGuard) export class SpaceRolesController { - constructor(private readonly invites: InvitesService) {} + constructor(private readonly roles: RolesService) {} @Get() @RequirePermission(PERMISSIONS.MEMBERS_READ) @ApiOperation({ - summary: 'List assignable workspace roles (excludes Owner)', + summary: 'List workspace roles (includes Owner; invite UI filters it out)', }) @ApiParam({ name: 'spaceId', format: 'uuid' }) - @ApiResponse({ status: 200, description: 'Role options' }) + @ApiResponse({ status: 200, description: 'Role list' }) @ApiResponse({ status: 401, ...PROBLEM_DETAILS_REF }) @ApiResponse({ status: 403, ...PROBLEM_DETAILS_REF }) @ApiResponse({ status: 404, ...PROBLEM_DETAILS_REF }) @@ -39,6 +55,62 @@ export class SpaceRolesController { @Param('spaceId') _spaceId: string, @CurrentSpaceAccess() access: SpaceAccess ): Promise { - return this.invites.listRoles(access); + return this.roles.list(access); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequirePermission(PERMISSIONS.ROLES_WRITE) + @ApiOperation({ summary: 'Create a custom role' }) + @ApiParam({ name: 'spaceId', format: 'uuid' }) + @ApiResponse({ status: 201, description: 'Role created' }) + @ApiResponse({ status: 401, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 403, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 404, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 409, ...PROBLEM_DETAILS_REF }) + async create( + @Param('spaceId') _spaceId: string, + @Body() body: CreateSpaceRoleDto, + @CurrentSpaceAccess() access: SpaceAccess + ): Promise { + return this.roles.create(access, body); + } + + @Patch(':roleId') + @RequirePermission(PERMISSIONS.ROLES_WRITE) + @ApiOperation({ summary: 'Update a role (name, permissions, or default)' }) + @ApiParam({ name: 'spaceId', format: 'uuid' }) + @ApiParam({ name: 'roleId', format: 'uuid' }) + @ApiResponse({ status: 200, description: 'Role updated' }) + @ApiResponse({ status: 401, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 403, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 404, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 409, ...PROBLEM_DETAILS_REF }) + async update( + @Param('spaceId') _spaceId: string, + @Param('roleId') roleId: string, + @Body() body: PatchSpaceRoleDto, + @CurrentSpaceAccess() access: SpaceAccess + ): Promise { + return this.roles.update(access, roleId, body); + } + + @Delete(':roleId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequirePermission(PERMISSIONS.ROLES_WRITE) + @ApiOperation({ summary: 'Delete a custom role' }) + @ApiParam({ name: 'spaceId', format: 'uuid' }) + @ApiParam({ name: 'roleId', format: 'uuid' }) + @ApiResponse({ status: 204, description: 'Role deleted' }) + @ApiResponse({ status: 401, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 403, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 404, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 409, ...PROBLEM_DETAILS_REF }) + async remove( + @Param('spaceId') _spaceId: string, + @Param('roleId') roleId: string, + @CurrentSpaceAccess() access: SpaceAccess + ): Promise { + await this.roles.delete(access, roleId); } } diff --git a/libs/api/spaces/src/controllers/space-teams.controller.ts b/libs/api/spaces/src/controllers/space-teams.controller.ts new file mode 100644 index 0000000..f532fac --- /dev/null +++ b/libs/api/spaces/src/controllers/space-teams.controller.ts @@ -0,0 +1,110 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiCookieAuth, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { + CurrentSpaceAccess, + RequirePermission, + RequirePermissionGuard, + SpaceGuard, + type SpaceAccess, +} from '@work-hub/access'; +import { PERMISSIONS } from '@work-hub/constants'; +import type { ListSpaceTeamsResponse, SpaceTeam } from '@work-hub/contracts'; +import { PROBLEM_DETAILS_REF, SESSION_SECURITY } from '@work-hub/core'; +import { CreateSpaceTeamDto } from '../dto/create-space-team.dto'; +import { PatchSpaceTeamDto } from '../dto/patch-space-team.dto'; +import { TeamsService } from '../teams.service'; + +@ApiTags('spaces') +@ApiCookieAuth(SESSION_SECURITY) +@Controller('spaces/:spaceId/teams') +@UseGuards(SpaceGuard, RequirePermissionGuard) +export class SpaceTeamsController { + constructor(private readonly teams: TeamsService) {} + + @Get() + @RequirePermission(PERMISSIONS.MEMBERS_READ) + @ApiOperation({ summary: 'List workspace groups (teams)' }) + @ApiParam({ name: 'spaceId', format: 'uuid' }) + @ApiResponse({ status: 200, description: 'Group list' }) + @ApiResponse({ status: 401, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 403, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 404, ...PROBLEM_DETAILS_REF }) + async list( + @Param('spaceId') _spaceId: string, + @CurrentSpaceAccess() access: SpaceAccess + ): Promise { + return this.teams.list(access); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequirePermission(PERMISSIONS.TEAMS_WRITE) + @ApiOperation({ summary: 'Create a group' }) + @ApiParam({ name: 'spaceId', format: 'uuid' }) + @ApiResponse({ status: 201, description: 'Group created' }) + @ApiResponse({ status: 401, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 403, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 404, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 409, ...PROBLEM_DETAILS_REF }) + async create( + @Param('spaceId') _spaceId: string, + @Body() body: CreateSpaceTeamDto, + @CurrentSpaceAccess() access: SpaceAccess + ): Promise { + return this.teams.create(access, body); + } + + @Patch(':teamId') + @RequirePermission(PERMISSIONS.TEAMS_WRITE) + @ApiOperation({ summary: 'Update a group (name, members, managers)' }) + @ApiParam({ name: 'spaceId', format: 'uuid' }) + @ApiParam({ name: 'teamId', format: 'uuid' }) + @ApiResponse({ status: 200, description: 'Group updated' }) + @ApiResponse({ status: 401, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 403, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 404, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 409, ...PROBLEM_DETAILS_REF }) + async update( + @Param('spaceId') _spaceId: string, + @Param('teamId') teamId: string, + @Body() body: PatchSpaceTeamDto, + @CurrentSpaceAccess() access: SpaceAccess + ): Promise { + return this.teams.update(access, teamId, body); + } + + @Delete(':teamId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequirePermission(PERMISSIONS.TEAMS_WRITE) + @ApiOperation({ summary: 'Delete a group' }) + @ApiParam({ name: 'spaceId', format: 'uuid' }) + @ApiParam({ name: 'teamId', format: 'uuid' }) + @ApiResponse({ status: 204, description: 'Group deleted' }) + @ApiResponse({ status: 401, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 403, ...PROBLEM_DETAILS_REF }) + @ApiResponse({ status: 404, ...PROBLEM_DETAILS_REF }) + async remove( + @Param('spaceId') _spaceId: string, + @Param('teamId') teamId: string, + @CurrentSpaceAccess() access: SpaceAccess + ): Promise { + await this.teams.delete(access, teamId); + } +} diff --git a/libs/api/spaces/src/dto/create-space-role.dto.ts b/libs/api/spaces/src/dto/create-space-role.dto.ts new file mode 100644 index 0000000..e626873 --- /dev/null +++ b/libs/api/spaces/src/dto/create-space-role.dto.ts @@ -0,0 +1,4 @@ +import { createSpaceRoleSchema } from '@work-hub/contracts'; +import { createZodDto } from 'nestjs-zod'; + +export class CreateSpaceRoleDto extends createZodDto(createSpaceRoleSchema) {} diff --git a/libs/api/spaces/src/dto/create-space-team.dto.ts b/libs/api/spaces/src/dto/create-space-team.dto.ts new file mode 100644 index 0000000..90712cc --- /dev/null +++ b/libs/api/spaces/src/dto/create-space-team.dto.ts @@ -0,0 +1,4 @@ +import { createSpaceTeamSchema } from '@work-hub/contracts'; +import { createZodDto } from 'nestjs-zod'; + +export class CreateSpaceTeamDto extends createZodDto(createSpaceTeamSchema) {} diff --git a/libs/api/spaces/src/dto/patch-space-member.dto.ts b/libs/api/spaces/src/dto/patch-space-member.dto.ts new file mode 100644 index 0000000..3ffd84b --- /dev/null +++ b/libs/api/spaces/src/dto/patch-space-member.dto.ts @@ -0,0 +1,4 @@ +import { patchSpaceMemberSchema } from '@work-hub/contracts'; +import { createZodDto } from 'nestjs-zod'; + +export class PatchSpaceMemberDto extends createZodDto(patchSpaceMemberSchema) {} diff --git a/libs/api/spaces/src/dto/patch-space-role.dto.ts b/libs/api/spaces/src/dto/patch-space-role.dto.ts new file mode 100644 index 0000000..c183255 --- /dev/null +++ b/libs/api/spaces/src/dto/patch-space-role.dto.ts @@ -0,0 +1,4 @@ +import { patchSpaceRoleSchema } from '@work-hub/contracts'; +import { createZodDto } from 'nestjs-zod'; + +export class PatchSpaceRoleDto extends createZodDto(patchSpaceRoleSchema) {} diff --git a/libs/api/spaces/src/dto/patch-space-team.dto.ts b/libs/api/spaces/src/dto/patch-space-team.dto.ts new file mode 100644 index 0000000..9286ee2 --- /dev/null +++ b/libs/api/spaces/src/dto/patch-space-team.dto.ts @@ -0,0 +1,4 @@ +import { patchSpaceTeamSchema } from '@work-hub/contracts'; +import { createZodDto } from 'nestjs-zod'; + +export class PatchSpaceTeamDto extends createZodDto(patchSpaceTeamSchema) {} diff --git a/libs/api/spaces/src/index.ts b/libs/api/spaces/src/index.ts index 9ced803..4d03e62 100644 --- a/libs/api/spaces/src/index.ts +++ b/libs/api/spaces/src/index.ts @@ -1,5 +1,7 @@ export { SpacesModule } from './spaces.module'; export { InvitesService } from './invites.service'; export { MembersService } from './members.service'; +export { RolesService } from './roles.service'; export { SpacesService } from './spaces.service'; +export { TeamsService } from './teams.service'; export { systemRolesFor, type SystemRoleRow } from './system-roles'; diff --git a/libs/api/spaces/src/invites.service.spec.ts b/libs/api/spaces/src/invites.service.spec.ts index d082411..1f99624 100644 --- a/libs/api/spaces/src/invites.service.spec.ts +++ b/libs/api/spaces/src/invites.service.spec.ts @@ -6,7 +6,11 @@ import { } from '@nestjs/common'; import type { ConfigService } from '@nestjs/config'; import type { SpaceAccess } from '@work-hub/access'; -import { ErrorCode, SYSTEM_ROLE_NAMES } from '@work-hub/constants'; +import { + ErrorCode, + PERMISSIONS, + SYSTEM_ROLE_NAMES, +} from '@work-hub/constants'; import type { PrismaService } from '@work-hub/database'; import type { MailService } from '@work-hub/mail'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -25,7 +29,17 @@ const access: SpaceAccess = { membershipStatus: 'active', roleId, roleName: SYSTEM_ROLE_NAMES.ADMIN, - permissions: [], + permissions: [ + PERMISSIONS.MEMBERS_READ, + PERMISSIONS.INVITES_WRITE, + PERMISSIONS.ROLES_WRITE, + ], +}; + +const grantableRole = { + id: roleId, + name: SYSTEM_ROLE_NAMES.MEMBER, + permissions: [PERMISSIONS.MEMBERS_READ], }; function inviteRow(overrides: Record = {}) { @@ -253,7 +267,7 @@ describe('InvitesService.create', () => { findFirst: vi.fn(), }, membership: { findFirst: vi.fn() }, - role: { findFirst: vi.fn() }, + role: { findFirst: vi.fn().mockResolvedValue(grantableRole) }, }); const result = await service.create(access, { @@ -269,6 +283,30 @@ describe('InvitesService.create', () => { expect(mail.sendSpaceInvite).not.toHaveBeenCalled(); }); + it('rejects inviting into a role above the actor', async () => { + const elevatedRoleId = '01936b3c-0000-7000-8000-0000000000ef'; + const { service } = createService({ + role: { + findFirst: vi.fn().mockResolvedValue({ + id: elevatedRoleId, + name: 'Co-owner', + permissions: [PERMISSIONS.SPACE_DELETE], + }), + }, + }); + + await expect( + service.create(access, { + kind: 'link', + roleId: elevatedRoleId, + maxUses: 5, + expiresInDays: 3, + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_ESCALATION }, + }); + }); + it('returns per-address bulk results for email invites', async () => { const created = inviteRow({ kind: 'email', @@ -297,7 +335,7 @@ describe('InvitesService.create', () => { .mockResolvedValueOnce(null) .mockResolvedValueOnce({ id: 'm1' }), }, - role: { findFirst: vi.fn() }, + role: { findFirst: vi.fn().mockResolvedValue(grantableRole) }, }); const result = await service.create(access, { @@ -344,31 +382,6 @@ describe('InvitesService.create', () => { }); }); -describe('InvitesService.listRoles', () => { - it('marks the workspace default role', async () => { - const memberRole = { id: roleId, name: SYSTEM_ROLE_NAMES.MEMBER }; - const adminRole = { - id: '01936b3c-0000-7000-8000-0000000000ad', - name: SYSTEM_ROLE_NAMES.ADMIN, - }; - const { service } = createService({ - space: { - findFirstOrThrow: vi.fn().mockResolvedValue({ defaultRoleId: roleId }), - }, - role: { - findMany: vi.fn().mockResolvedValue([adminRole, memberRole]), - }, - }); - - await expect(service.listRoles(access)).resolves.toEqual({ - items: [ - { ...adminRole, isDefault: false }, - { ...memberRole, isDefault: true }, - ], - }); - }); -}); - describe('InvitesService.resend', () => { it('revokes the old invite and creates a fresh email invite', async () => { const existing = inviteRow({ @@ -395,6 +408,7 @@ describe('InvitesService.resend', () => { space: { findFirstOrThrow: vi.fn().mockResolvedValue({ name: 'Acme' }), }, + role: { findFirst: vi.fn().mockResolvedValue(grantableRole) }, }); const result = await service.resend(access, inviteId); @@ -405,6 +419,31 @@ describe('InvitesService.resend', () => { expect.objectContaining({ to: 'mia@workhub.test' }) ); }); + + it('rejects resend when the invite role is above the actor', async () => { + const existing = inviteRow({ + kind: 'email', + email: 'mia@workhub.test', + maxUses: 1, + revokedAt: null, + }); + const { service } = createService({ + invite: { + findFirst: vi.fn().mockResolvedValue(existing), + }, + role: { + findFirst: vi.fn().mockResolvedValue({ + id: roleId, + name: 'Co-owner', + permissions: [PERMISSIONS.SPACE_DELETE], + }), + }, + }); + + await expect(service.resend(access, inviteId)).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_ESCALATION }, + }); + }); }); describe('InvitesService.revoke', () => { diff --git a/libs/api/spaces/src/invites.service.ts b/libs/api/spaces/src/invites.service.ts index c46ecc0..145ff40 100644 --- a/libs/api/spaces/src/invites.service.ts +++ b/libs/api/spaces/src/invites.service.ts @@ -10,7 +10,11 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { SpaceAccess } from '@work-hub/access'; -import { ErrorCode, SYSTEM_ROLE_NAMES } from '@work-hub/constants'; +import { + ErrorCode, + SYSTEM_ROLE_NAMES, + permissionsAreSubset, +} from '@work-hub/constants'; import type { AcceptInviteResponse, CreateSpaceEmailInvitesResponse, @@ -20,7 +24,6 @@ import type { InvitePreview, ListSpaceInvitesQuery, ListSpaceInvitesResponse, - ListSpaceRolesResponse, SpaceInvite, } from '@work-hub/contracts'; import { Prisma, PrismaService } from '@work-hub/database'; @@ -132,33 +135,6 @@ export class InvitesService { }; } - async listRoles(access: SpaceAccess): Promise { - return this.prisma.withTenant( - { spaceId: access.spaceId, userId: access.userId }, - async (tx) => { - const space = await tx.space.findFirstOrThrow({ - where: { id: access.spaceId }, - select: { defaultRoleId: true }, - }); - const rows = await tx.role.findMany({ - where: { - spaceId: access.spaceId, - name: { not: SYSTEM_ROLE_NAMES.OWNER }, - }, - select: { id: true, name: true }, - orderBy: { name: 'asc' }, - }); - return { - items: rows.map((row) => ({ - id: row.id, - name: row.name, - isDefault: row.id === space.defaultRoleId, - })), - }; - } - ); - } - async create( access: SpaceAccess, input: CreateSpaceInvite @@ -196,6 +172,8 @@ export class InvitesService { throw new BadRequestException('Only email invites can be resent'); } + await this.assertInviteRoleGrantable(tx, access, existing.roleId); + await assertEmailInviteBudget(tx, access.spaceId, 1); if (!existing.revokedAt) { @@ -385,7 +363,7 @@ export class InvitesService { const row = await this.prisma.withTenant( { spaceId: access.spaceId, userId: access.userId }, async (tx) => { - const roleId = await this.resolveRoleId(tx, access.spaceId, input.roleId); + const roleId = await this.resolveRoleId(tx, access, input.roleId); const expiresAt = addDays(new Date(), input.expiresInDays); return tx.invite.create({ data: { @@ -423,7 +401,7 @@ export class InvitesService { async (tx) => { await assertEmailInviteBudget(tx, access.spaceId, addresses.length); - const roleId = await this.resolveRoleId(tx, access.spaceId, input.roleId); + const roleId = await this.resolveRoleId(tx, access, input.roleId); const space = await tx.space.findFirstOrThrow({ where: { id: access.spaceId }, select: { id: true, name: true, slug: true }, @@ -568,36 +546,54 @@ export class InvitesService { private async resolveRoleId( tx: Prisma.TransactionClient, - spaceId: string, + access: SpaceAccess, roleId: string | undefined ): Promise { if (roleId) { if (!isUuid(roleId)) { throw new BadRequestException('Invalid roleId'); } - const role = await tx.role.findFirst({ - where: { id: roleId, spaceId }, - select: { id: true, name: true }, - }); - if (!role) { - throw new BadRequestException('Role not found in this workspace'); - } - if (role.name === SYSTEM_ROLE_NAMES.OWNER) { - throw new BadRequestException('Cannot invite as Owner'); - } - return role.id; + await this.assertInviteRoleGrantable(tx, access, roleId); + return roleId; } const space = await tx.space.findFirstOrThrow({ - where: { id: spaceId }, + where: { id: access.spaceId }, select: { defaultRoleId: true }, }); if (!space.defaultRoleId) { throw new BadRequestException('Workspace has no default role'); } + await this.assertInviteRoleGrantable(tx, access, space.defaultRoleId); return space.defaultRoleId; } + /** + * Invite role (or default) must exist, not be Owner, and be ⊆ actor permissions. + */ + private async assertInviteRoleGrantable( + tx: Prisma.TransactionClient, + access: SpaceAccess, + roleId: string + ): Promise { + const role = await tx.role.findFirst({ + where: { id: roleId, spaceId: access.spaceId }, + select: { id: true, name: true, permissions: true }, + }); + if (!role) { + throw new BadRequestException('Role not found in this workspace'); + } + if (role.name === SYSTEM_ROLE_NAMES.OWNER) { + throw new BadRequestException('Cannot invite as Owner'); + } + if (!permissionsAreSubset(role.permissions, access.permissions)) { + throw new ForbiddenException({ + message: 'You cannot invite with permissions you do not have', + code: ErrorCode.ROLE_ESCALATION, + }); + } + } + private toInvite(row: InviteWithRole): SpaceInvite { return { id: row.id, diff --git a/libs/api/spaces/src/members.integration.spec.ts b/libs/api/spaces/src/members.integration.spec.ts index bef2526..bdaf2fe 100644 --- a/libs/api/spaces/src/members.integration.spec.ts +++ b/libs/api/spaces/src/members.integration.spec.ts @@ -336,6 +336,71 @@ describeDb('MembersService + SpaceGuard (DB)', () => { }); }); + it('changes a member role with WH-011 safety rules', async () => { + const ownerAccess = await loadAccess(spaceAId, ownerId); + const adminAccess = await loadAccess(spaceAId, adminId); + + const roles = await prisma.withTenant( + { userId: ownerId, spaceId: spaceAId }, + (tx) => tx.role.findMany({ where: { spaceId: spaceAId } }) + ); + const adminRole = roles.find((r) => r.name === SYSTEM_ROLE_NAMES.ADMIN); + const memberRole = roles.find((r) => r.name === SYSTEM_ROLE_NAMES.MEMBER); + const ownerRole = roles.find((r) => r.name === SYSTEM_ROLE_NAMES.OWNER); + if (!adminRole || !memberRole || !ownerRole) { + throw new Error('system roles missing'); + } + + await expect( + members.updateRole(adminAccess, adminMembershipId, memberRole.id) + ).rejects.toMatchObject({ + response: { code: ErrorCode.CANNOT_CHANGE_OWN_ROLE }, + }); + await expect( + members.updateRole(adminAccess, ownerMembershipId, memberRole.id) + ).rejects.toMatchObject({ + response: { code: ErrorCode.CANNOT_MODIFY_OWNER }, + }); + await expect( + members.updateRole(adminAccess, memberMembershipId, ownerRole.id) + ).rejects.toMatchObject({ + response: { code: ErrorCode.CANNOT_ASSIGN_OWNER }, + }); + + // Space B owner role id is cross-tenant → 404 under space A tenant. + const spaceBOwnerRoleId = await prisma.withTenant( + { userId: outsiderId, spaceId: spaceBId }, + async (tx) => { + const membership = await tx.membership.findUniqueOrThrow({ + where: { id: spaceBOwnerMembershipId }, + select: { roleId: true }, + }); + return membership.roleId; + } + ); + await expect( + members.updateRole(adminAccess, memberMembershipId, spaceBOwnerRoleId) + ).rejects.toBeInstanceOf(NotFoundException); + + const promoted = await members.updateRole( + ownerAccess, + memberMembershipId, + adminRole.id + ); + expect(promoted.role.id).toBe(adminRole.id); + expect(promoted.role.name).toBe(SYSTEM_ROLE_NAMES.ADMIN); + expect(promoted.role.permissions).toEqual( + expect.arrayContaining([PERMISSIONS.LEAVE_APPROVE, PERMISSIONS.ROLES_WRITE]) + ); + + const demoted = await members.updateRole( + ownerAccess, + memberMembershipId, + memberRole.id + ); + expect(demoted.role.name).toBe(SYSTEM_ROLE_NAMES.MEMBER); + }); + it('lets a member leave the workspace', async () => { const leaveUserId = randomUUID(); await prisma.user.create({ diff --git a/libs/api/spaces/src/members.service.spec.ts b/libs/api/spaces/src/members.service.spec.ts index c04757a..c105cb2 100644 --- a/libs/api/spaces/src/members.service.spec.ts +++ b/libs/api/spaces/src/members.service.spec.ts @@ -3,7 +3,11 @@ import { ForbiddenException, NotFoundException, } from '@nestjs/common'; -import { ErrorCode, SYSTEM_ROLE_NAMES } from '@work-hub/constants'; +import { + ErrorCode, + PERMISSIONS, + SYSTEM_ROLE_NAMES, +} from '@work-hub/constants'; import type { PrismaService } from '@work-hub/database'; import { describe, expect, it, vi } from 'vitest'; import { MembersService } from './members.service'; @@ -13,15 +17,23 @@ const actorId = '01936b3c-0000-7000-8000-0000000000bb'; const memberUserId = '01936b3c-0000-7000-8000-0000000000cc'; const membershipId = '01936b3c-0000-7000-8000-0000000000dd'; const ownerMembershipId = '01936b3c-0000-7000-8000-0000000000ee'; +const adminRoleId = '01936b3c-0000-7000-8000-0000000000a1'; +const memberRoleId = '01936b3c-0000-7000-8000-0000000000a2'; +const ownerRoleId = '01936b3c-0000-7000-8000-0000000000a3'; const access = { spaceId, userId: actorId, membershipId: '01936b3c-0000-7000-8000-0000000000ff', membershipStatus: 'active' as const, - roleId: 'role-admin', + roleId: adminRoleId, roleName: SYSTEM_ROLE_NAMES.ADMIN, - permissions: [], + permissions: [ + PERMISSIONS.MEMBERS_READ, + PERMISSIONS.MEMBERS_WRITE, + PERMISSIONS.ROLES_WRITE, + PERMISSIONS.INVITES_WRITE, + ], }; function memberRow(overrides: Record = {}) { @@ -36,7 +48,11 @@ function memberRow(overrides: Record = {}) { email: 'member@workhub.test', image: null, }, - role: { id: 'role-member', name: SYSTEM_ROLE_NAMES.MEMBER }, + role: { + id: memberRoleId, + name: SYSTEM_ROLE_NAMES.MEMBER, + permissions: [PERMISSIONS.MEMBERS_READ], + }, ...overrides, }; } @@ -47,6 +63,7 @@ function mockPrisma(handlers: { update?: ReturnType; delete?: ReturnType; count?: ReturnType; + roleFindFirst?: ReturnType; }) { const tx = { membership: { @@ -56,6 +73,9 @@ function mockPrisma(handlers: { delete: handlers.delete ?? vi.fn(), count: handlers.count ?? vi.fn(), }, + role: { + findFirst: handlers.roleFindFirst ?? vi.fn(), + }, }; const withTenant = vi.fn( async (_ctx: unknown, fn: (client: typeof tx) => Promise) => @@ -129,7 +149,11 @@ describe('MembersService.deactivate', () => { const findFirst = vi.fn().mockResolvedValue( memberRow({ id: ownerMembershipId, - role: { id: 'role-owner', name: SYSTEM_ROLE_NAMES.OWNER }, + role: { + id: 'role-owner', + name: SYSTEM_ROLE_NAMES.OWNER, + permissions: [PERMISSIONS.SPACE_DELETE], + }, }) ); const { prisma } = mockPrisma({ findFirst }); @@ -207,6 +231,161 @@ describe('MembersService.remove', () => { }); }); +describe('MembersService.updateRole', () => { + it('forbids changing your own role', async () => { + const findFirst = vi.fn().mockResolvedValue( + memberRow({ userId: actorId, id: access.membershipId }) + ); + const { prisma } = mockPrisma({ findFirst }); + const service = new MembersService(prisma); + + await expect( + service.updateRole(access, access.membershipId, adminRoleId) + ).rejects.toMatchObject({ + response: { code: ErrorCode.CANNOT_CHANGE_OWN_ROLE }, + }); + }); + + it('forbids changing the Owner', async () => { + const findFirst = vi.fn().mockResolvedValue( + memberRow({ + id: ownerMembershipId, + role: { + id: ownerRoleId, + name: SYSTEM_ROLE_NAMES.OWNER, + permissions: [PERMISSIONS.SPACE_DELETE], + }, + }) + ); + const { prisma } = mockPrisma({ findFirst }); + const service = new MembersService(prisma); + + await expect( + service.updateRole(access, ownerMembershipId, memberRoleId) + ).rejects.toMatchObject({ + response: { code: ErrorCode.CANNOT_MODIFY_OWNER }, + }); + }); + + it('forbids assigning Owner', async () => { + const findFirst = vi.fn().mockResolvedValue(memberRow()); + const roleFindFirst = vi.fn().mockResolvedValue({ + id: ownerRoleId, + name: SYSTEM_ROLE_NAMES.OWNER, + permissions: [PERMISSIONS.SPACE_DELETE], + }); + const { prisma } = mockPrisma({ findFirst, roleFindFirst }); + const service = new MembersService(prisma); + + await expect( + service.updateRole(access, membershipId, ownerRoleId) + ).rejects.toMatchObject({ + response: { code: ErrorCode.CANNOT_ASSIGN_OWNER }, + }); + }); + + it('forbids escalation beyond the actor permissions', async () => { + const findFirst = vi.fn().mockResolvedValue(memberRow()); + const roleFindFirst = vi.fn().mockResolvedValue({ + id: adminRoleId, + name: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [PERMISSIONS.SPACE_DELETE, PERMISSIONS.ROLES_WRITE], + }); + const { prisma } = mockPrisma({ findFirst, roleFindFirst }); + const service = new MembersService(prisma); + + await expect( + service.updateRole(access, membershipId, adminRoleId) + ).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_ESCALATION }, + }); + }); + + it('returns 404 for a role in another workspace', async () => { + const findFirst = vi.fn().mockResolvedValue(memberRow()); + const roleFindFirst = vi.fn().mockResolvedValue(null); + const { prisma } = mockPrisma({ findFirst, roleFindFirst }); + const service = new MembersService(prisma); + + await expect( + service.updateRole(access, membershipId, adminRoleId) + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('updates the role when the grant is a subset', async () => { + const findFirst = vi.fn().mockResolvedValue(memberRow()); + const roleFindFirst = vi.fn().mockResolvedValue({ + id: adminRoleId, + name: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [PERMISSIONS.MEMBERS_READ, PERMISSIONS.ROLES_WRITE], + }); + const update = vi.fn().mockResolvedValue( + memberRow({ + role: { + id: adminRoleId, + name: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [PERMISSIONS.MEMBERS_READ, PERMISSIONS.ROLES_WRITE], + }, + }) + ); + const { prisma } = mockPrisma({ findFirst, roleFindFirst, update }); + const service = new MembersService(prisma); + + const result = await service.updateRole(access, membershipId, adminRoleId); + + expect(update).toHaveBeenCalledWith({ + where: { id: membershipId }, + data: { roleId: adminRoleId }, + include: expect.any(Object), + }); + expect(result.role).toEqual({ + id: adminRoleId, + name: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [PERMISSIONS.MEMBERS_READ, PERMISSIONS.ROLES_WRITE], + }); + }); + + it('forbids demoting a member whose current role is above the actor', async () => { + const findFirst = vi.fn().mockResolvedValue( + memberRow({ + role: { + id: 'co-owner', + name: 'Co-owner', + permissions: [PERMISSIONS.SPACE_DELETE, PERMISSIONS.MEMBERS_READ], + }, + }) + ); + const { prisma } = mockPrisma({ findFirst }); + const service = new MembersService(prisma); + + await expect( + service.updateRole(access, membershipId, memberRoleId) + ).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_ESCALATION }, + }); + }); + + it('forbids deactivating a member whose current role is above the actor', async () => { + const findFirst = vi.fn().mockResolvedValue( + memberRow({ + role: { + id: 'co-owner', + name: 'Co-owner', + permissions: [PERMISSIONS.SPACE_DELETE], + }, + }) + ); + const { prisma } = mockPrisma({ findFirst }); + const service = new MembersService(prisma); + + await expect( + service.deactivate(access, membershipId) + ).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_ESCALATION }, + }); + }); +}); + describe('MembersService.leave', () => { it('blocks the last Owner', async () => { const ownerAccess = { diff --git a/libs/api/spaces/src/members.service.ts b/libs/api/spaces/src/members.service.ts index c4d12be..1405137 100644 --- a/libs/api/spaces/src/members.service.ts +++ b/libs/api/spaces/src/members.service.ts @@ -5,7 +5,13 @@ import { NotFoundException, } from '@nestjs/common'; import type { SpaceAccess } from '@work-hub/access'; -import { ErrorCode, SYSTEM_ROLE_NAMES } from '@work-hub/constants'; +import { + ErrorCode, + PERMISSION_LIST, + SYSTEM_ROLE_NAMES, + permissionsAreSubset, + type Permission, +} from '@work-hub/constants'; import type { ListSpaceMembersQuery, ListSpaceMembersResponse, @@ -13,6 +19,8 @@ import type { } from '@work-hub/contracts'; import { Prisma, PrismaService } from '@work-hub/database'; +const PERMISSION_CATALOG = new Set(PERMISSION_LIST); + type MembershipWithUserRole = { id: string; userId: string; @@ -27,6 +35,7 @@ type MembershipWithUserRole = { role: { id: string; name: string; + permissions: string[]; }; }; @@ -78,7 +87,7 @@ export class MembersService { user: { select: { id: true, name: true, email: true, image: true }, }, - role: { select: { id: true, name: true } }, + role: { select: { id: true, name: true, permissions: true } }, }, orderBy: { id: 'asc' }, take: limit + 1, @@ -116,6 +125,7 @@ export class MembersService { code: ErrorCode.CANNOT_DEACTIVATE_SELF, }); } + this.assertRoleWithinReach(target.role.permissions, access.permissions); if (target.status === 'deactivated') { return this.toMember(target); @@ -128,7 +138,7 @@ export class MembersService { user: { select: { id: true, name: true, email: true, image: true }, }, - role: { select: { id: true, name: true } }, + role: { select: { id: true, name: true, permissions: true } }, }, }); return this.toMember(updated); @@ -151,6 +161,7 @@ export class MembersService { code: ErrorCode.CANNOT_MODIFY_OWNER, }); } + this.assertRoleWithinReach(target.role.permissions, access.permissions); if (target.status === 'active') { return this.toMember(target); @@ -163,7 +174,7 @@ export class MembersService { user: { select: { id: true, name: true, email: true, image: true }, }, - role: { select: { id: true, name: true } }, + role: { select: { id: true, name: true, permissions: true } }, }, }); return this.toMember(updated); @@ -189,6 +200,7 @@ export class MembersService { code: ErrorCode.CANNOT_REMOVE_SELF, }); } + this.assertRoleWithinReach(target.role.permissions, access.permissions); await tx.membership.delete({ where: { id: target.id } }); } @@ -222,6 +234,75 @@ export class MembersService { ); } + /** + * Change a member's role (WH-011). Caller must have `roles.write`. + * No self-change, Owner role untouchable, no escalation beyond actor perms. + */ + async updateRole( + access: SpaceAccess, + membershipId: string, + roleId: string + ): Promise { + return this.prisma.withTenant( + { spaceId: access.spaceId, userId: access.userId }, + async (tx) => { + const target = await this.requireMembership( + tx, + access.spaceId, + membershipId + ); + + if (target.userId === access.userId) { + throw new ForbiddenException({ + message: 'You cannot change your own role', + code: ErrorCode.CANNOT_CHANGE_OWN_ROLE, + }); + } + if (target.role.name === SYSTEM_ROLE_NAMES.OWNER) { + throw new ForbiddenException({ + message: 'The Owner role cannot be changed', + code: ErrorCode.CANNOT_MODIFY_OWNER, + }); + } + this.assertRoleWithinReach(target.role.permissions, access.permissions); + if (!isUuid(roleId)) { + throw new NotFoundException('Role not found'); + } + + const nextRole = await tx.role.findFirst({ + where: { id: roleId, spaceId: access.spaceId }, + select: { id: true, name: true, permissions: true }, + }); + if (!nextRole) { + throw new NotFoundException('Role not found'); + } + if (nextRole.name === SYSTEM_ROLE_NAMES.OWNER) { + throw new ForbiddenException({ + message: 'Use ownership transfer to make someone Owner', + code: ErrorCode.CANNOT_ASSIGN_OWNER, + }); + } + this.assertRoleWithinReach(nextRole.permissions, access.permissions); + + if (target.role.id === nextRole.id) { + return this.toMember(target); + } + + const updated = await tx.membership.update({ + where: { id: target.id }, + data: { roleId: nextRole.id }, + include: { + user: { + select: { id: true, name: true, email: true, image: true }, + }, + role: { select: { id: true, name: true, permissions: true } }, + }, + }); + return this.toMember(updated); + } + ); + } + private async requireMembership( tx: Prisma.TransactionClient, spaceId: string, @@ -237,7 +318,7 @@ export class MembersService { user: { select: { id: true, name: true, email: true, image: true }, }, - role: { select: { id: true, name: true } }, + role: { select: { id: true, name: true, permissions: true } }, }, }); @@ -247,6 +328,19 @@ export class MembersService { return row; } + /** Actor may only manage members whose current role is ⊆ actor permissions. */ + private assertRoleWithinReach( + rolePermissions: readonly string[], + actorPermissions: readonly string[] + ): void { + if (!permissionsAreSubset(rolePermissions, actorPermissions)) { + throw new ForbiddenException({ + message: 'You cannot manage a role with permissions you do not have', + code: ErrorCode.ROLE_ESCALATION, + }); + } + } + private toMember(row: MembershipWithUserRole): SpaceMember { return { id: row.id, @@ -257,6 +351,7 @@ export class MembersService { role: { id: row.role.id, name: row.role.name, + permissions: catalogPermissions(row.role.permissions), }, membershipStatus: row.status, createdAt: row.createdAt.toISOString(), @@ -264,6 +359,13 @@ export class MembersService { } } +/** Drop unknown DB strings so the response matches the Permission catalog. */ +function catalogPermissions(values: readonly string[]): Permission[] { + return values.filter((value): value is Permission => + PERMISSION_CATALOG.has(value) + ); +} + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/libs/api/spaces/src/roles.integration.spec.ts b/libs/api/spaces/src/roles.integration.spec.ts new file mode 100644 index 0000000..979d562 --- /dev/null +++ b/libs/api/spaces/src/roles.integration.spec.ts @@ -0,0 +1,307 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { + SPACE_ACCESS_KEY, + SpaceGuard, + type SpaceAccess, +} from '@work-hub/access'; +import { + ErrorCode, + PERMISSIONS, + SYSTEM_ROLE_NAMES, +} from '@work-hub/constants'; +import { DatabaseModule, PrismaService } from '@work-hub/database'; +import { StorageModule } from '@work-hub/storage'; +import { config } from 'dotenv'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { RolesService } from './roles.service'; +import { SpacesService } from './spaces.service'; + +const serverRoot = resolve(import.meta.dirname, '../../../../apps/server'); +config({ + path: [resolve(serverRoot, '.env.local'), resolve(serverRoot, '.env')], + quiet: true, +}); + +const describeDb = process.env.DATABASE_URL ? describe : describe.skip; + +describeDb('RolesService + RLS (DB)', () => { + let prisma: PrismaService; + let spaces: SpacesService; + let roles: RolesService; + let spaceGuard: SpaceGuard; + + const ownerId = randomUUID(); + const adminId = randomUUID(); + const outsiderId = randomUUID(); + let spaceAId = ''; + let spaceBId = ''; + const slugA = `roles-a-${Date.now().toString(36)}`; + const slugB = `roles-b-${Date.now().toString(36)}`; + + async function loadAccess( + spaceId: string, + userId: string + ): Promise { + const request: Record = { + params: { spaceId }, + user: { id: userId }, + }; + await spaceGuard.canActivate({ + switchToHttp: () => ({ + getRequest: () => request, + }), + } as never); + return request[SPACE_ACCESS_KEY] as SpaceAccess; + } + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + cache: true, + ignoreEnvFile: true, + load: [ + () => ({ + databaseUrl: process.env.DATABASE_URL, + storage: { + endpoint: + process.env.STORAGE_ENDPOINT ?? 'http://localhost:9000', + region: process.env.STORAGE_REGION ?? 'us-east-1', + bucket: process.env.STORAGE_BUCKET ?? 'workhub', + accessKeyId: process.env.STORAGE_ACCESS_KEY ?? 'workhub', + secretAccessKey: + process.env.STORAGE_SECRET_KEY ?? 'workhubsecret', + publicBaseUrl: + process.env.STORAGE_PUBLIC_BASE_URL ?? + 'http://localhost:9000/workhub', + forcePathStyle: true, + }, + }), + ], + }), + DatabaseModule, + StorageModule, + ], + providers: [SpacesService, RolesService, SpaceGuard], + }).compile(); + + prisma = moduleRef.get(PrismaService); + spaces = moduleRef.get(SpacesService); + roles = moduleRef.get(RolesService); + spaceGuard = moduleRef.get(SpaceGuard); + await prisma.$connect(); + + await prisma.user.createMany({ + data: [ + { + id: ownerId, + name: 'Roles Owner', + email: `roles-owner-${ownerId}@workhub.test`, + emailVerified: true, + }, + { + id: adminId, + name: 'Roles Admin', + email: `roles-admin-${adminId}@workhub.test`, + emailVerified: true, + }, + { + id: outsiderId, + name: 'Roles Outsider', + email: `roles-out-${outsiderId}@workhub.test`, + emailVerified: true, + }, + ], + }); + + spaceAId = ( + await spaces.create(ownerId, { name: 'Roles A', slug: slugA }) + ).id; + spaceBId = ( + await spaces.create(outsiderId, { name: 'Roles B', slug: slugB }) + ).id; + + await prisma.withTenant({ userId: ownerId, spaceId: spaceAId }, async (tx) => { + const adminRole = await tx.role.findFirstOrThrow({ + where: { spaceId: spaceAId, name: SYSTEM_ROLE_NAMES.ADMIN }, + }); + await tx.membership.create({ + data: { + spaceId: spaceAId, + userId: adminId, + roleId: adminRole.id, + }, + }); + }); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it('lists roles including Owner and supports custom role CRUD', async () => { + const ownerAccess = await loadAccess(spaceAId, ownerId); + const listed = await roles.list(ownerAccess); + expect(listed.items.some((r) => r.name === SYSTEM_ROLE_NAMES.OWNER)).toBe( + true + ); + expect(listed.items.some((r) => r.isDefault)).toBe(true); + + const created = await roles.create(ownerAccess, { + name: 'Lead', + permissions: [PERMISSIONS.MEMBERS_READ, PERMISSIONS.CALENDAR_READ], + }); + expect(created.isSystem).toBe(false); + expect(created.memberCount).toBe(0); + + const updated = await roles.update(ownerAccess, created.id, { + name: 'Team Lead', + isDefault: true, + }); + expect(updated.name).toBe('Team Lead'); + expect(updated.isDefault).toBe(true); + + // Clear default by pointing it back to Member, then delete. + const memberRole = listed.items.find( + (r) => r.name === SYSTEM_ROLE_NAMES.MEMBER + ); + if (!memberRole) { + throw new Error('Member role missing'); + } + await roles.update(ownerAccess, memberRole.id, { isDefault: true }); + await roles.delete(ownerAccess, created.id); + + const after = await roles.list(ownerAccess); + expect(after.items.some((r) => r.id === created.id)).toBe(false); + }); + + it('enforces Owner lock, escalation, and cross-workspace 404', async () => { + const adminAccess = await loadAccess(spaceAId, adminId); + const ownerAccess = await loadAccess(spaceAId, ownerId); + + const listed = await roles.list(ownerAccess); + const ownerRole = listed.items.find( + (r) => r.name === SYSTEM_ROLE_NAMES.OWNER + ); + const adminRole = listed.items.find( + (r) => r.name === SYSTEM_ROLE_NAMES.ADMIN + ); + if (!ownerRole || !adminRole) { + throw new Error('system roles missing'); + } + + await expect( + roles.update(adminAccess, ownerRole.id, { + permissions: [PERMISSIONS.MEMBERS_READ], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.SYSTEM_ROLE_LOCKED }, + }); + + await expect( + roles.create(adminAccess, { + name: 'Escalated', + permissions: [PERMISSIONS.SPACE_DELETE], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_ESCALATION }, + }); + + const spaceBOwnerRoleId = await prisma.withTenant( + { userId: outsiderId, spaceId: spaceBId }, + async (tx) => { + const role = await tx.role.findFirstOrThrow({ + where: { spaceId: spaceBId, name: SYSTEM_ROLE_NAMES.OWNER }, + select: { id: true }, + }); + return role.id; + } + ); + + await expect( + roles.update(adminAccess, spaceBOwnerRoleId, { + permissions: [PERMISSIONS.MEMBERS_READ], + }) + ).rejects.toBeInstanceOf(NotFoundException); + + // RLS: writing a role row under the wrong tenant space id must fail. + await expect( + prisma.withTenant({ userId: adminId, spaceId: spaceAId }, (tx) => + tx.role.create({ + data: { + spaceId: spaceBId, + name: 'CrossTenant', + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: false, + }, + }) + ) + ).rejects.toThrow(); + }); + + it('returns ROLE_IN_USE when members still hold the role', async () => { + const ownerAccess = await loadAccess(spaceAId, ownerId); + const created = await roles.create(ownerAccess, { + name: 'Temp Role', + permissions: [PERMISSIONS.MEMBERS_READ], + }); + + await prisma.withTenant({ userId: ownerId, spaceId: spaceAId }, async (tx) => { + await tx.membership.updateMany({ + where: { spaceId: spaceAId, userId: adminId }, + data: { roleId: created.id }, + }); + }); + + await expect(roles.delete(ownerAccess, created.id)).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_IN_USE }, + }); + + // Restore admin so cleanup / other tests stay sane. + await prisma.withTenant({ userId: ownerId, spaceId: spaceAId }, async (tx) => { + const adminRole = await tx.role.findFirstOrThrow({ + where: { spaceId: spaceAId, name: SYSTEM_ROLE_NAMES.ADMIN }, + }); + await tx.membership.updateMany({ + where: { spaceId: spaceAId, userId: adminId }, + data: { roleId: adminRole.id }, + }); + }); + await roles.delete(ownerAccess, created.id); + }); + + it('forbids deleting system roles', async () => { + const ownerAccess = await loadAccess(spaceAId, ownerId); + const listed = await roles.list(ownerAccess); + const memberRole = listed.items.find( + (r) => r.name === SYSTEM_ROLE_NAMES.MEMBER + ); + if (!memberRole) { + throw new Error('Member role missing'); + } + + await expect(roles.delete(ownerAccess, memberRole.id)).rejects.toMatchObject( + { + response: { code: ErrorCode.SYSTEM_ROLE_LOCKED }, + } + ); + }); + + it('blocks clearing the default without a replacement', async () => { + const ownerAccess = await loadAccess(spaceAId, ownerId); + const listed = await roles.list(ownerAccess); + const defaultRole = listed.items.find((r) => r.isDefault); + if (!defaultRole) { + throw new Error('default role missing'); + } + + await expect( + roles.update(ownerAccess, defaultRole.id, { isDefault: false }) + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/libs/api/spaces/src/roles.service.spec.ts b/libs/api/spaces/src/roles.service.spec.ts new file mode 100644 index 0000000..8c4e6f5 --- /dev/null +++ b/libs/api/spaces/src/roles.service.spec.ts @@ -0,0 +1,418 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import { + ErrorCode, + MAX_CUSTOM_ROLES, + PERMISSIONS, + SYSTEM_ROLE_NAMES, +} from '@work-hub/constants'; +import type { PrismaService } from '@work-hub/database'; +import { describe, expect, it, vi } from 'vitest'; +import { RolesService } from './roles.service'; + +const spaceId = '01936b3c-0000-7000-8000-0000000000aa'; +const actorId = '01936b3c-0000-7000-8000-0000000000bb'; +const adminRoleId = '01936b3c-0000-7000-8000-0000000000a1'; +const memberRoleId = '01936b3c-0000-7000-8000-0000000000a2'; +const ownerRoleId = '01936b3c-0000-7000-8000-0000000000a3'; +const customRoleId = '01936b3c-0000-7000-8000-0000000000a4'; + +const access = { + spaceId, + userId: actorId, + membershipId: '01936b3c-0000-7000-8000-0000000000ff', + membershipStatus: 'active' as const, + roleId: adminRoleId, + roleName: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [ + PERMISSIONS.MEMBERS_READ, + PERMISSIONS.MEMBERS_WRITE, + PERMISSIONS.ROLES_WRITE, + PERMISSIONS.INVITES_WRITE, + ], +}; + +function mockPrisma(tx: Record) { + const withTenant = vi.fn( + async ( + _ctx: unknown, + fn: (client: Record) => Promise + ) => fn(tx) + ); + return { prisma: { withTenant } as unknown as PrismaService, withTenant }; +} + +describe('RolesService.list', () => { + it('includes Owner and marks the default role', async () => { + const { prisma } = mockPrisma({ + space: { + findFirstOrThrow: vi.fn().mockResolvedValue({ + defaultRoleId: memberRoleId, + }), + }, + role: { + findMany: vi.fn().mockResolvedValue([ + { + id: adminRoleId, + name: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [PERMISSIONS.ROLES_WRITE], + isSystem: true, + _count: { memberships: 1 }, + }, + { + id: memberRoleId, + name: SYSTEM_ROLE_NAMES.MEMBER, + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: true, + _count: { memberships: 2 }, + }, + { + id: ownerRoleId, + name: SYSTEM_ROLE_NAMES.OWNER, + permissions: [PERMISSIONS.SPACE_DELETE], + isSystem: true, + _count: { memberships: 1 }, + }, + ]), + }, + }); + const service = new RolesService(prisma); + + await expect(service.list(access)).resolves.toEqual({ + items: [ + { + id: adminRoleId, + name: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [PERMISSIONS.ROLES_WRITE], + isSystem: true, + isDefault: false, + memberCount: 1, + }, + { + id: memberRoleId, + name: SYSTEM_ROLE_NAMES.MEMBER, + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: true, + isDefault: true, + memberCount: 2, + }, + { + id: ownerRoleId, + name: SYSTEM_ROLE_NAMES.OWNER, + permissions: [PERMISSIONS.SPACE_DELETE], + isSystem: true, + isDefault: false, + memberCount: 1, + }, + ], + }); + }); +}); + +describe('RolesService.create', () => { + it('rejects permissions the actor does not have', async () => { + const service = new RolesService( + mockPrisma({}).prisma + ); + + await expect( + service.create(access, { + name: 'Lead', + permissions: [PERMISSIONS.SPACE_DELETE], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_ESCALATION }, + }); + }); + + it('rejects reserved system names', async () => { + const service = new RolesService(mockPrisma({}).prisma); + + await expect( + service.create(access, { + name: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [PERMISSIONS.MEMBERS_READ], + }) + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('rejects a fourth custom permission template', async () => { + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue(null), + count: vi.fn().mockResolvedValue(MAX_CUSTOM_ROLES), + }, + }); + const service = new RolesService(prisma); + + await expect( + service.create(access, { + name: 'Lead', + permissions: [PERMISSIONS.MEMBERS_READ], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_LIMIT_REACHED }, + }); + }); + + it('creates a custom role and can set it as default', async () => { + const create = vi.fn().mockResolvedValue({ + id: customRoleId, + name: 'Lead', + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: false, + _count: { memberships: 0 }, + }); + const spaceUpdate = vi.fn().mockResolvedValue({}); + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue(null), + count: vi.fn().mockResolvedValue(0), + create, + }, + space: { + update: spaceUpdate, + findFirstOrThrow: vi + .fn() + .mockResolvedValue({ defaultRoleId: customRoleId }), + }, + }); + const service = new RolesService(prisma); + + const result = await service.create(access, { + name: 'Lead', + permissions: [PERMISSIONS.MEMBERS_READ], + isDefault: true, + }); + + expect(create).toHaveBeenCalled(); + expect(spaceUpdate).toHaveBeenCalledWith({ + where: { id: spaceId }, + data: { defaultRoleId: customRoleId }, + }); + expect(result).toEqual({ + id: customRoleId, + name: 'Lead', + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: false, + isDefault: true, + memberCount: 0, + }); + }); +}); + +describe('RolesService.update', () => { + it('locks the Owner role', async () => { + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue({ + id: ownerRoleId, + name: SYSTEM_ROLE_NAMES.OWNER, + permissions: [PERMISSIONS.SPACE_DELETE], + isSystem: true, + }), + }, + }); + const service = new RolesService(prisma); + + await expect( + service.update(access, ownerRoleId, { + permissions: [PERMISSIONS.MEMBERS_READ], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.SYSTEM_ROLE_LOCKED }, + }); + }); + + it('forbids renaming system roles', async () => { + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue({ + id: memberRoleId, + name: SYSTEM_ROLE_NAMES.MEMBER, + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: true, + }), + }, + }); + const service = new RolesService(prisma); + + await expect( + service.update(access, memberRoleId, { name: 'Staff' }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.SYSTEM_ROLE_LOCKED }, + }); + }); + + it('allows editing Member permissions within the actor subset', async () => { + const update = vi.fn().mockResolvedValue({ + id: memberRoleId, + name: SYSTEM_ROLE_NAMES.MEMBER, + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: true, + _count: { memberships: 1 }, + }); + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue({ + id: memberRoleId, + name: SYSTEM_ROLE_NAMES.MEMBER, + permissions: [PERMISSIONS.MEMBERS_READ, PERMISSIONS.INVITES_WRITE], + isSystem: true, + }), + update, + }, + space: { + findFirstOrThrow: vi + .fn() + .mockResolvedValue({ defaultRoleId: memberRoleId }), + }, + }); + const service = new RolesService(prisma); + + const result = await service.update(access, memberRoleId, { + permissions: [PERMISSIONS.MEMBERS_READ], + }); + + expect(update).toHaveBeenCalled(); + expect(result.permissions).toEqual([PERMISSIONS.MEMBERS_READ]); + }); + + it('forbids editing the role the actor holds', async () => { + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue({ + id: adminRoleId, + name: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [PERMISSIONS.ROLES_WRITE], + isSystem: true, + }), + }, + }); + const service = new RolesService(prisma); + + await expect( + service.update(access, adminRoleId, { + permissions: [PERMISSIONS.MEMBERS_READ], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.CANNOT_CHANGE_OWN_ROLE }, + }); + }); + + it('forbids editing a role above the actor', async () => { + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue({ + id: customRoleId, + name: 'Co-owner', + permissions: [PERMISSIONS.SPACE_DELETE, PERMISSIONS.ROLES_WRITE], + isSystem: false, + }), + }, + }); + const service = new RolesService(prisma); + + await expect( + service.update(access, customRoleId, { + permissions: [PERMISSIONS.MEMBERS_READ], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_ESCALATION }, + }); + }); + + it('rejects reserved names case-insensitively', async () => { + const service = new RolesService(mockPrisma({}).prisma); + + await expect( + service.create(access, { + name: 'owner', + permissions: [PERMISSIONS.MEMBERS_READ], + }) + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('returns 404 for a role in another workspace', async () => { + const { prisma } = mockPrisma({ + role: { findFirst: vi.fn().mockResolvedValue(null) }, + }); + const service = new RolesService(prisma); + + await expect( + service.update(access, customRoleId, { name: 'Lead' }) + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); + +describe('RolesService.delete', () => { + it('locks system roles', async () => { + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue({ + id: memberRoleId, + name: SYSTEM_ROLE_NAMES.MEMBER, + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: true, + }), + }, + }); + const service = new RolesService(prisma); + + await expect(service.delete(access, memberRoleId)).rejects.toMatchObject({ + response: { code: ErrorCode.SYSTEM_ROLE_LOCKED }, + }); + }); + + it('returns 409 when the role is in use', async () => { + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue({ + id: customRoleId, + name: 'Lead', + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: false, + }), + delete: vi.fn(), + }, + space: { + findFirstOrThrow: vi + .fn() + .mockResolvedValue({ defaultRoleId: memberRoleId }), + }, + membership: { count: vi.fn().mockResolvedValue(2) }, + invite: { count: vi.fn().mockResolvedValue(0) }, + }); + const service = new RolesService(prisma); + + await expect(service.delete(access, customRoleId)).rejects.toMatchObject({ + response: { code: ErrorCode.ROLE_IN_USE }, + }); + }); + + it('deletes an unused custom role', async () => { + const deleteFn = vi.fn().mockResolvedValue({}); + const { prisma } = mockPrisma({ + role: { + findFirst: vi.fn().mockResolvedValue({ + id: customRoleId, + name: 'Lead', + permissions: [PERMISSIONS.MEMBERS_READ], + isSystem: false, + }), + delete: deleteFn, + }, + space: { + findFirstOrThrow: vi + .fn() + .mockResolvedValue({ defaultRoleId: memberRoleId }), + }, + membership: { count: vi.fn().mockResolvedValue(0) }, + invite: { count: vi.fn().mockResolvedValue(0) }, + }); + const service = new RolesService(prisma); + + await service.delete(access, customRoleId); + + expect(deleteFn).toHaveBeenCalledWith({ where: { id: customRoleId } }); + }); +}); diff --git a/libs/api/spaces/src/roles.service.ts b/libs/api/spaces/src/roles.service.ts new file mode 100644 index 0000000..dd449a3 --- /dev/null +++ b/libs/api/spaces/src/roles.service.ts @@ -0,0 +1,386 @@ +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { SpaceAccess } from '@work-hub/access'; +import { + ErrorCode, + MAX_CUSTOM_ROLES, + SYSTEM_ROLE_NAME_LIST, + SYSTEM_ROLE_NAMES, + permissionsAreSubset, + type Permission, +} from '@work-hub/constants'; +import type { + CreateSpaceRole, + ListSpaceRolesResponse, + PatchSpaceRole, + SpaceRoleOption, +} from '@work-hub/contracts'; +import { Prisma, PrismaService } from '@work-hub/database'; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(value: string): boolean { + return UUID_RE.test(value); +} + +const RESERVED_ROLE_NAMES_LOWER = new Set( + SYSTEM_ROLE_NAME_LIST.map((name) => name.toLowerCase()) +); + +@Injectable() +export class RolesService { + constructor(private readonly prisma: PrismaService) {} + + async list(access: SpaceAccess): Promise { + return this.prisma.withTenant( + { spaceId: access.spaceId, userId: access.userId }, + async (tx) => { + const space = await tx.space.findFirstOrThrow({ + where: { id: access.spaceId }, + select: { defaultRoleId: true }, + }); + const rows = await tx.role.findMany({ + where: { spaceId: access.spaceId }, + select: { + id: true, + name: true, + permissions: true, + isSystem: true, + _count: { select: { memberships: true } }, + }, + orderBy: { name: 'asc' }, + }); + return { + items: rows.map((row) => this.toOption(row, space.defaultRoleId)), + }; + } + ); + } + + async create( + access: SpaceAccess, + input: CreateSpaceRole + ): Promise { + this.assertPermissionsSubset(input.permissions, access.permissions); + this.assertNotReservedName(input.name); + + return this.prisma.withTenant( + { spaceId: access.spaceId, userId: access.userId }, + async (tx) => { + await this.assertUniqueName(tx, access.spaceId, input.name); + await this.assertCustomRoleLimit(tx, access.spaceId); + + try { + const created = await tx.role.create({ + data: { + spaceId: access.spaceId, + name: input.name, + permissions: input.permissions, + isSystem: false, + }, + select: { + id: true, + name: true, + permissions: true, + isSystem: true, + _count: { select: { memberships: true } }, + }, + }); + + if (input.isDefault) { + await tx.space.update({ + where: { id: access.spaceId }, + data: { defaultRoleId: created.id }, + }); + } + + const space = await tx.space.findFirstOrThrow({ + where: { id: access.spaceId }, + select: { defaultRoleId: true }, + }); + return this.toOption(created, space.defaultRoleId); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new ConflictException('A role with this name already exists'); + } + throw error; + } + } + ); + } + + async update( + access: SpaceAccess, + roleId: string, + input: PatchSpaceRole + ): Promise { + if (input.permissions) { + this.assertPermissionsSubset(input.permissions, access.permissions); + } + + return this.prisma.withTenant( + { spaceId: access.spaceId, userId: access.userId }, + async (tx) => { + const role = await this.requireRole(tx, access.spaceId, roleId); + + if (role.name === SYSTEM_ROLE_NAMES.OWNER) { + throw new ForbiddenException({ + message: 'The Owner role cannot be modified', + code: ErrorCode.SYSTEM_ROLE_LOCKED, + }); + } + + if (role.id === access.roleId) { + throw new ForbiddenException({ + message: 'You cannot edit the role you hold', + code: ErrorCode.CANNOT_CHANGE_OWN_ROLE, + }); + } + + this.assertRoleWithinReach(role.permissions, access.permissions); + + if (role.isSystem && input.name !== undefined) { + throw new ForbiddenException({ + message: 'System roles cannot be renamed', + code: ErrorCode.SYSTEM_ROLE_LOCKED, + }); + } + + if (input.name !== undefined && input.name !== role.name) { + this.assertNotReservedName(input.name); + await this.assertUniqueName(tx, access.spaceId, input.name, role.id); + } + + if ( + input.isDefault === false && + (await this.isDefaultRole(tx, access.spaceId, role.id)) + ) { + throw new ConflictException({ + message: 'Set another role as default before clearing this one', + code: ErrorCode.CONFLICT, + }); + } + + try { + const updated = await tx.role.update({ + where: { id: role.id }, + data: { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.permissions !== undefined + ? { permissions: input.permissions } + : {}), + }, + select: { + id: true, + name: true, + permissions: true, + isSystem: true, + _count: { select: { memberships: true } }, + }, + }); + + if (input.isDefault === true) { + await tx.space.update({ + where: { id: access.spaceId }, + data: { defaultRoleId: updated.id }, + }); + } + + const space = await tx.space.findFirstOrThrow({ + where: { id: access.spaceId }, + select: { defaultRoleId: true }, + }); + return this.toOption(updated, space.defaultRoleId); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new ConflictException('A role with this name already exists'); + } + throw error; + } + } + ); + } + + async delete(access: SpaceAccess, roleId: string): Promise { + await this.prisma.withTenant( + { spaceId: access.spaceId, userId: access.userId }, + async (tx) => { + const role = await this.requireRole(tx, access.spaceId, roleId); + + if (role.isSystem) { + throw new ForbiddenException({ + message: 'System roles cannot be deleted', + code: ErrorCode.SYSTEM_ROLE_LOCKED, + }); + } + + if (role.id === access.roleId) { + throw new ForbiddenException({ + message: 'You cannot edit the role you hold', + code: ErrorCode.CANNOT_CHANGE_OWN_ROLE, + }); + } + + this.assertRoleWithinReach(role.permissions, access.permissions); + + if (await this.isDefaultRole(tx, access.spaceId, role.id)) { + throw new ConflictException({ + message: 'Set another role as default before deleting this one', + code: ErrorCode.CONFLICT, + }); + } + + const [membershipCount, inviteCount] = await Promise.all([ + tx.membership.count({ where: { roleId: role.id } }), + tx.invite.count({ + where: { roleId: role.id, revokedAt: null }, + }), + ]); + if (membershipCount > 0 || inviteCount > 0) { + throw new ConflictException({ + message: 'Role is assigned to members or invites', + code: ErrorCode.ROLE_IN_USE, + }); + } + + await tx.role.delete({ where: { id: role.id } }); + } + ); + } + + private assertPermissionsSubset( + granted: readonly string[], + actor: readonly string[] + ): void { + if (!permissionsAreSubset(granted, actor)) { + throw new ForbiddenException({ + message: 'You cannot grant permissions you do not have', + code: ErrorCode.ROLE_ESCALATION, + }); + } + } + + /** Actor may only edit/delete roles whose permissions are ⊆ theirs. */ + private assertRoleWithinReach( + rolePermissions: readonly string[], + actorPermissions: readonly string[] + ): void { + if (!permissionsAreSubset(rolePermissions, actorPermissions)) { + throw new ForbiddenException({ + message: 'You cannot manage a role with permissions you do not have', + code: ErrorCode.ROLE_ESCALATION, + }); + } + } + + private assertNotReservedName(name: string): void { + if (RESERVED_ROLE_NAMES_LOWER.has(name.toLowerCase())) { + throw new ConflictException('This role name is reserved'); + } + } + + private async assertCustomRoleLimit( + tx: Prisma.TransactionClient, + spaceId: string + ): Promise { + const customCount = await tx.role.count({ + where: { spaceId, isSystem: false }, + }); + if (customCount >= MAX_CUSTOM_ROLES) { + throw new ConflictException({ + message: `A workspace can have at most ${MAX_CUSTOM_ROLES} custom permission templates`, + code: ErrorCode.ROLE_LIMIT_REACHED, + }); + } + } + + private async assertUniqueName( + tx: Prisma.TransactionClient, + spaceId: string, + name: string, + excludeId?: string + ): Promise { + const existing = await tx.role.findFirst({ + where: { + spaceId, + name, + ...(excludeId ? { id: { not: excludeId } } : {}), + }, + select: { id: true }, + }); + if (existing) { + throw new ConflictException('A role with this name already exists'); + } + } + + private async requireRole( + tx: Prisma.TransactionClient, + spaceId: string, + roleId: string + ): Promise<{ + id: string; + name: string; + permissions: string[]; + isSystem: boolean; + }> { + if (!isUuid(roleId)) { + throw new NotFoundException('Role not found'); + } + const role = await tx.role.findFirst({ + where: { id: roleId, spaceId }, + select: { + id: true, + name: true, + permissions: true, + isSystem: true, + }, + }); + if (!role) { + throw new NotFoundException('Role not found'); + } + return role; + } + + private async isDefaultRole( + tx: Prisma.TransactionClient, + spaceId: string, + roleId: string + ): Promise { + const space = await tx.space.findFirstOrThrow({ + where: { id: spaceId }, + select: { defaultRoleId: true }, + }); + return space.defaultRoleId === roleId; + } + + private toOption( + row: { + id: string; + name: string; + permissions: string[]; + isSystem: boolean; + _count: { memberships: number }; + }, + defaultRoleId: string | null + ): SpaceRoleOption { + return { + id: row.id, + name: row.name, + permissions: row.permissions as Permission[], + isSystem: row.isSystem, + isDefault: row.id === defaultRoleId, + memberCount: row._count.memberships, + }; + } +} diff --git a/libs/api/spaces/src/spaces.module.ts b/libs/api/spaces/src/spaces.module.ts index 75afcf2..9dfc0a3 100644 --- a/libs/api/spaces/src/spaces.module.ts +++ b/libs/api/spaces/src/spaces.module.ts @@ -9,10 +9,13 @@ import { SpaceBrandingController } from './controllers/space-branding.controller import { SpaceInvitesController } from './controllers/space-invites.controller'; import { SpaceMembersController } from './controllers/space-members.controller'; import { SpaceRolesController } from './controllers/space-roles.controller'; +import { SpaceTeamsController } from './controllers/space-teams.controller'; import { SpacesController } from './controllers/spaces.controller'; import { InvitesService } from './invites.service'; import { MembersService } from './members.service'; +import { RolesService } from './roles.service'; import { SpacesService } from './spaces.service'; +import { TeamsService } from './teams.service'; @Module({ imports: [DatabaseModule, AccessModule, StorageModule, MailModule], @@ -22,10 +25,23 @@ import { SpacesService } from './spaces.service'; SpaceBrandingController, SpaceMembersController, SpaceRolesController, + SpaceTeamsController, SpaceInvitesController, InvitesController, ], - providers: [SpacesService, MembersService, InvitesService], - exports: [SpacesService, MembersService, InvitesService], + providers: [ + SpacesService, + MembersService, + RolesService, + TeamsService, + InvitesService, + ], + exports: [ + SpacesService, + MembersService, + RolesService, + TeamsService, + InvitesService, + ], }) export class SpacesModule {} diff --git a/libs/api/spaces/src/system-roles.ts b/libs/api/spaces/src/system-roles.ts index 3d07b54..b25eff6 100644 --- a/libs/api/spaces/src/system-roles.ts +++ b/libs/api/spaces/src/system-roles.ts @@ -16,7 +16,7 @@ export type SystemRoleRow = { }; /** - * Owner / Admin / Member rows for a new workspace (WH-038 / WH-040). + * Owner / Admin / Manager / Member rows for a new workspace (WH-038 / WH-058). * Shared by `SpacesService.create` and the Acme seed so permissions stay in sync. */ export function systemRolesFor(spaceId: string): { @@ -27,6 +27,7 @@ export function systemRolesFor(spaceId: string): { const roleIds = { [SYSTEM_ROLE_NAMES.OWNER]: uuidv7(), [SYSTEM_ROLE_NAMES.ADMIN]: uuidv7(), + [SYSTEM_ROLE_NAMES.MANAGER]: uuidv7(), [SYSTEM_ROLE_NAMES.MEMBER]: uuidv7(), } as const; diff --git a/libs/api/spaces/src/teams.integration.spec.ts b/libs/api/spaces/src/teams.integration.spec.ts new file mode 100644 index 0000000..ed61805 --- /dev/null +++ b/libs/api/spaces/src/teams.integration.spec.ts @@ -0,0 +1,238 @@ +import { ConfigModule } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { + SPACE_ACCESS_KEY, + SpaceGuard, + type SpaceAccess, +} from '@work-hub/access'; +import { + ErrorCode, + PERMISSIONS, + SYSTEM_ROLE_NAMES, +} from '@work-hub/constants'; +import { DatabaseModule, PrismaService } from '@work-hub/database'; +import { StorageModule } from '@work-hub/storage'; +import { config } from 'dotenv'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { SpacesService } from './spaces.service'; +import { TeamsService } from './teams.service'; + +const serverRoot = resolve(import.meta.dirname, '../../../../apps/server'); +config({ + path: [resolve(serverRoot, '.env.local'), resolve(serverRoot, '.env')], + quiet: true, +}); + +const describeDb = process.env.DATABASE_URL ? describe : describe.skip; + +describeDb('TeamsService + RLS (DB)', () => { + let prisma: PrismaService; + let spaces: SpacesService; + let teams: TeamsService; + let spaceGuard: SpaceGuard; + + const ownerId = randomUUID(); + const memberId = randomUUID(); + const outsiderId = randomUUID(); + let spaceAId = ''; + let spaceBId = ''; + let ownerMembershipId = ''; + let memberMembershipId = ''; + const slugA = `teams-a-${Date.now().toString(36)}`; + const slugB = `teams-b-${Date.now().toString(36)}`; + + async function loadAccess( + spaceId: string, + userId: string + ): Promise { + const request: Record = { + params: { spaceId }, + user: { id: userId }, + }; + await spaceGuard.canActivate({ + switchToHttp: () => ({ + getRequest: () => request, + }), + } as never); + return request[SPACE_ACCESS_KEY] as SpaceAccess; + } + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + cache: true, + ignoreEnvFile: true, + load: [ + () => ({ + databaseUrl: process.env.DATABASE_URL, + storage: { + endpoint: + process.env.STORAGE_ENDPOINT ?? 'http://localhost:9000', + region: process.env.STORAGE_REGION ?? 'us-east-1', + bucket: process.env.STORAGE_BUCKET ?? 'workhub', + accessKeyId: process.env.STORAGE_ACCESS_KEY ?? 'workhub', + secretAccessKey: + process.env.STORAGE_SECRET_KEY ?? 'workhubsecret', + publicBaseUrl: + process.env.STORAGE_PUBLIC_BASE_URL ?? + 'http://localhost:9000/workhub', + forcePathStyle: true, + }, + }), + ], + }), + DatabaseModule, + StorageModule, + ], + providers: [SpacesService, TeamsService, SpaceGuard], + }).compile(); + + prisma = moduleRef.get(PrismaService); + spaces = moduleRef.get(SpacesService); + teams = moduleRef.get(TeamsService); + spaceGuard = moduleRef.get(SpaceGuard); + await prisma.$connect(); + + await prisma.user.createMany({ + data: [ + { + id: ownerId, + name: 'Teams Owner', + email: `teams-owner-${ownerId}@workhub.test`, + emailVerified: true, + }, + { + id: memberId, + name: 'Teams Member', + email: `teams-member-${memberId}@workhub.test`, + emailVerified: true, + }, + { + id: outsiderId, + name: 'Teams Outsider', + email: `teams-out-${outsiderId}@workhub.test`, + emailVerified: true, + }, + ], + }); + + spaceAId = ( + await spaces.create(ownerId, { name: 'Teams A', slug: slugA }) + ).id; + spaceBId = ( + await spaces.create(outsiderId, { name: 'Teams B', slug: slugB }) + ).id; + + await prisma.withTenant( + { userId: ownerId, spaceId: spaceAId }, + async (tx) => { + const memberRole = await tx.role.findFirstOrThrow({ + where: { spaceId: spaceAId, name: SYSTEM_ROLE_NAMES.MEMBER }, + }); + const managerRole = await tx.role.findFirstOrThrow({ + where: { spaceId: spaceAId, name: SYSTEM_ROLE_NAMES.MANAGER }, + }); + expect(managerRole.permissions).toContain(PERMISSIONS.LEAVE_APPROVE); + + const ownerMembership = await tx.membership.findFirstOrThrow({ + where: { spaceId: spaceAId, userId: ownerId }, + }); + ownerMembershipId = ownerMembership.id; + + const created = await tx.membership.create({ + data: { + spaceId: spaceAId, + userId: memberId, + roleId: memberRole.id, + }, + }); + memberMembershipId = created.id; + } + ); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it('supports group CRUD with members and managers', async () => { + const ownerAccess = await loadAccess(spaceAId, ownerId); + + const created = await teams.create(ownerAccess, { + name: 'Engineering', + memberIds: [memberMembershipId], + managerIds: [ownerMembershipId], + }); + expect(created.name).toBe('Engineering'); + expect(created.members.map((m) => m.membershipId)).toEqual([ + memberMembershipId, + ]); + expect(created.managers.map((m) => m.membershipId)).toEqual([ + ownerMembershipId, + ]); + + const listed = await teams.list(ownerAccess); + expect(listed.items.some((t) => t.id === created.id)).toBe(true); + + const renamed = await teams.update(ownerAccess, created.id, { + name: 'Platform', + memberIds: [memberMembershipId, ownerMembershipId], + managerIds: [ownerMembershipId], + }); + expect(renamed.name).toBe('Platform'); + expect(renamed.members).toHaveLength(2); + expect(renamed.managers.map((m) => m.membershipId)).toEqual([ + ownerMembershipId, + ]); + + await expect( + teams.update(ownerAccess, created.id, { + managerIds: [memberMembershipId], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.VALIDATION_FAILED }, + }); + + await teams.delete(ownerAccess, created.id); + const after = await teams.list(ownerAccess); + expect(after.items.some((t) => t.id === created.id)).toBe(false); + }); + + it('rejects duplicate names and cross-workspace team ids', async () => { + const ownerAccess = await loadAccess(spaceAId, ownerId); + const outsiderAccess = await loadAccess(spaceBId, outsiderId); + + const created = await teams.create(ownerAccess, { name: 'Unique Group' }); + + await expect( + teams.create(ownerAccess, { name: 'Unique Group' }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.TEAM_NAME_TAKEN }, + }); + + await expect( + teams.update(outsiderAccess, created.id, { name: 'Stolen' }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.NOT_FOUND }, + }); + + await teams.delete(ownerAccess, created.id); + }); + + it('hides teams from other workspaces via RLS', async () => { + const ownerAccess = await loadAccess(spaceAId, ownerId); + const created = await teams.create(ownerAccess, { name: 'Private A' }); + + const leaked = await prisma.withTenant( + { userId: outsiderId, spaceId: spaceBId }, + (tx) => tx.team.findMany({ where: { id: created.id } }) + ); + expect(leaked).toEqual([]); + + await teams.delete(ownerAccess, created.id); + }); +}); diff --git a/libs/api/spaces/src/teams.service.spec.ts b/libs/api/spaces/src/teams.service.spec.ts new file mode 100644 index 0000000..bd293a3 --- /dev/null +++ b/libs/api/spaces/src/teams.service.spec.ts @@ -0,0 +1,179 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { ErrorCode, PERMISSIONS, SYSTEM_ROLE_NAMES } from '@work-hub/constants'; +import { Prisma, type PrismaService } from '@work-hub/database'; +import { describe, expect, it, vi } from 'vitest'; +import { TeamsService } from './teams.service'; + +const spaceId = '01936b3c-0000-7000-8000-0000000000aa'; +const actorId = '01936b3c-0000-7000-8000-0000000000bb'; +const teamId = '01936b3c-0000-7000-8000-0000000000t1'; +const membershipId = '01936b3c-0000-7000-8000-0000000000m1'; +const userId = '01936b3c-0000-7000-8000-0000000000u1'; + +const access = { + spaceId, + userId: actorId, + membershipId: '01936b3c-0000-7000-8000-0000000000ff', + membershipStatus: 'active' as const, + roleId: '01936b3c-0000-7000-8000-0000000000a1', + roleName: SYSTEM_ROLE_NAMES.ADMIN, + permissions: [PERMISSIONS.TEAMS_WRITE, PERMISSIONS.MEMBERS_READ], +}; + +function mockPrisma(tx: Record) { + const withTenant = vi.fn( + async ( + _ctx: unknown, + fn: (client: Record) => Promise + ) => fn(tx) + ); + return { prisma: { withTenant } as unknown as PrismaService, withTenant }; +} + +const personMembership = { + id: membershipId, + userId, + user: { + id: userId, + name: 'Ada', + email: 'ada@workhub.test', + image: null, + }, +}; + +describe('TeamsService', () => { + it('lists teams with members and managers', async () => { + const { prisma } = mockPrisma({ + team: { + findMany: vi.fn().mockResolvedValue([ + { + id: teamId, + name: 'Engineering', + createdAt: new Date('2026-10-18T12:00:00.000Z'), + members: [{ membership: personMembership }], + managers: [{ membership: personMembership }], + }, + ]), + }, + }); + const service = new TeamsService(prisma); + + await expect(service.list(access)).resolves.toEqual({ + items: [ + { + id: teamId, + name: 'Engineering', + createdAt: '2026-10-18T12:00:00.000Z', + members: [ + { + membershipId, + userId, + name: 'Ada', + email: 'ada@workhub.test', + image: null, + }, + ], + managers: [ + { + membershipId, + userId, + name: 'Ada', + email: 'ada@workhub.test', + image: null, + }, + ], + }, + ], + }); + }); + + it('rejects memberships that are not active in the space', async () => { + const { prisma } = mockPrisma({ + membership: { + findMany: vi.fn().mockResolvedValue([]), + }, + team: { + create: vi.fn(), + }, + }); + const service = new TeamsService(prisma); + + await expect( + service.create(access, { + name: 'Ops', + memberIds: [membershipId], + }) + ).rejects.toBeInstanceOf(BadRequestException); + + await expect( + service.create(access, { + name: 'Ops', + memberIds: [membershipId], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.MEMBERSHIP_NOT_IN_SPACE }, + }); + }); + + it('rejects managers whose role lacks leave.approve', async () => { + const { prisma } = mockPrisma({ + membership: { + findMany: vi + .fn() + .mockResolvedValueOnce([{ id: membershipId }]) + .mockResolvedValueOnce([ + { + id: membershipId, + role: { permissions: [PERMISSIONS.MEMBERS_READ] }, + }, + ]), + }, + team: { + create: vi.fn(), + }, + }); + const service = new TeamsService(prisma); + + await expect( + service.create(access, { + name: 'Ops', + managerIds: [membershipId], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.VALIDATION_FAILED }, + }); + }); + + it('maps unique name conflicts to TEAM_NAME_TAKEN', async () => { + const { prisma } = mockPrisma({ + membership: { + findMany: vi.fn().mockResolvedValue([{ id: membershipId }]), + }, + team: { + create: vi.fn().mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('dup', { + code: 'P2002', + clientVersion: 'test', + }) + ), + }, + }); + const service = new TeamsService(prisma); + + await expect( + service.create(access, { + name: 'Engineering', + memberIds: [membershipId], + }) + ).rejects.toBeInstanceOf(ConflictException); + + await expect( + service.create(access, { + name: 'Engineering', + memberIds: [membershipId], + }) + ).rejects.toMatchObject({ + response: { code: ErrorCode.TEAM_NAME_TAKEN }, + }); + }); +}); diff --git a/libs/api/spaces/src/teams.service.ts b/libs/api/spaces/src/teams.service.ts new file mode 100644 index 0000000..e509022 --- /dev/null +++ b/libs/api/spaces/src/teams.service.ts @@ -0,0 +1,366 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { SpaceAccess } from '@work-hub/access'; +import { ErrorCode, PERMISSIONS } from '@work-hub/constants'; +import type { + CreateSpaceTeam, + ListSpaceTeamsResponse, + PatchSpaceTeam, + SpaceTeam, + SpaceTeamPerson, +} from '@work-hub/contracts'; +import { Prisma, PrismaService, uuidv7 } from '@work-hub/database'; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(value: string): boolean { + return UUID_RE.test(value); +} + +type MembershipPersonRow = { + id: string; + userId: string; + user: { + id: string; + name: string; + email: string; + image: string | null; + }; +}; + +type TeamRow = { + id: string; + name: string; + createdAt: Date; + members: Array<{ membership: MembershipPersonRow }>; + managers: Array<{ membership: MembershipPersonRow }>; +}; + +@Injectable() +export class TeamsService { + constructor(private readonly prisma: PrismaService) {} + + async list(access: SpaceAccess): Promise { + return this.prisma.withTenant( + { spaceId: access.spaceId, userId: access.userId }, + async (tx) => { + const rows = await tx.team.findMany({ + where: { spaceId: access.spaceId }, + orderBy: { name: 'asc' }, + include: this.teamInclude(), + }); + return { items: rows.map((row) => this.toTeam(row)) }; + } + ); + } + + async create( + access: SpaceAccess, + input: CreateSpaceTeam + ): Promise { + const memberIds = uniqueIds(input.memberIds ?? []); + const managerIds = uniqueIds(input.managerIds ?? []); + + return this.prisma.withTenant( + { spaceId: access.spaceId, userId: access.userId }, + async (tx) => { + await this.assertActiveMemberships( + tx, + access.spaceId, + uniqueIds([...memberIds, ...managerIds]) + ); + await this.assertManagersCanApprove(tx, access.spaceId, managerIds); + + try { + const created = await tx.team.create({ + data: { + id: uuidv7(), + spaceId: access.spaceId, + name: input.name, + }, + }); + await this.replaceMembers(tx, access.spaceId, created.id, memberIds); + await this.replaceManagers( + tx, + access.spaceId, + created.id, + managerIds + ); + return this.loadTeam(tx, access.spaceId, created.id); + } catch (error) { + this.rethrowNameConflict(error); + throw error; + } + } + ); + } + + async update( + access: SpaceAccess, + teamId: string, + input: PatchSpaceTeam + ): Promise { + if (!isUuid(teamId)) { + throw new NotFoundException({ + message: 'Team not found', + code: ErrorCode.NOT_FOUND, + }); + } + + const memberIds = + input.memberIds !== undefined ? uniqueIds(input.memberIds) : undefined; + const managerIds = + input.managerIds !== undefined ? uniqueIds(input.managerIds) : undefined; + + return this.prisma.withTenant( + { spaceId: access.spaceId, userId: access.userId }, + async (tx) => { + const existing = await tx.team.findFirst({ + where: { id: teamId, spaceId: access.spaceId }, + select: { id: true }, + }); + if (!existing) { + throw new NotFoundException({ + message: 'Team not found', + code: ErrorCode.NOT_FOUND, + }); + } + + await this.assertActiveMemberships( + tx, + access.spaceId, + uniqueIds([...(memberIds ?? []), ...(managerIds ?? [])]) + ); + if (managerIds !== undefined) { + await this.assertManagersCanApprove(tx, access.spaceId, managerIds); + } + + try { + if (input.name !== undefined) { + await tx.team.update({ + where: { id: teamId }, + data: { name: input.name }, + }); + } + } catch (error) { + this.rethrowNameConflict(error); + throw error; + } + + if (memberIds !== undefined) { + await this.replaceMembers(tx, access.spaceId, teamId, memberIds); + } + if (managerIds !== undefined) { + await this.replaceManagers(tx, access.spaceId, teamId, managerIds); + } + + return this.loadTeam(tx, access.spaceId, teamId); + } + ); + } + + async delete(access: SpaceAccess, teamId: string): Promise { + if (!isUuid(teamId)) { + throw new NotFoundException({ + message: 'Team not found', + code: ErrorCode.NOT_FOUND, + }); + } + + await this.prisma.withTenant( + { spaceId: access.spaceId, userId: access.userId }, + async (tx) => { + const result = await tx.team.deleteMany({ + where: { id: teamId, spaceId: access.spaceId }, + }); + if (result.count === 0) { + throw new NotFoundException({ + message: 'Team not found', + code: ErrorCode.NOT_FOUND, + }); + } + } + ); + } + + private teamInclude() { + return { + members: { + include: { + membership: { + select: { + id: true, + userId: true, + user: { + select: { id: true, name: true, email: true, image: true }, + }, + }, + }, + }, + orderBy: { membership: { user: { name: 'asc' as const } } }, + }, + managers: { + include: { + membership: { + select: { + id: true, + userId: true, + user: { + select: { id: true, name: true, email: true, image: true }, + }, + }, + }, + }, + orderBy: { membership: { user: { name: 'asc' as const } } }, + }, + } as const; + } + + private async loadTeam( + tx: Prisma.TransactionClient, + spaceId: string, + teamId: string + ): Promise { + const row = await tx.team.findFirstOrThrow({ + where: { id: teamId, spaceId }, + include: this.teamInclude(), + }); + return this.toTeam(row); + } + + private async assertActiveMemberships( + tx: Prisma.TransactionClient, + spaceId: string, + membershipIds: string[] + ): Promise { + if (membershipIds.length === 0) { + return; + } + const found = await tx.membership.findMany({ + where: { + spaceId, + id: { in: membershipIds }, + status: 'active', + }, + select: { id: true }, + }); + if (found.length !== membershipIds.length) { + throw new BadRequestException({ + message: 'One or more members are not active in this workspace', + code: ErrorCode.MEMBERSHIP_NOT_IN_SPACE, + }); + } + } + + /** Team managers must be able to approve leave (FR-MEM-06). */ + private async assertManagersCanApprove( + tx: Prisma.TransactionClient, + spaceId: string, + membershipIds: string[] + ): Promise { + if (membershipIds.length === 0) { + return; + } + const rows = await tx.membership.findMany({ + where: { + spaceId, + id: { in: membershipIds }, + status: 'active', + }, + select: { + id: true, + role: { select: { permissions: true } }, + }, + }); + const cannotApprove = rows.some( + (row) => !row.role.permissions.includes(PERMISSIONS.LEAVE_APPROVE) + ); + if (cannotApprove || rows.length !== membershipIds.length) { + throw new BadRequestException({ + message: "This person's role can't approve requests", + code: ErrorCode.VALIDATION_FAILED, + }); + } + } + + private async replaceMembers( + tx: Prisma.TransactionClient, + spaceId: string, + teamId: string, + membershipIds: string[] + ): Promise { + await tx.teamMember.deleteMany({ where: { teamId, spaceId } }); + if (membershipIds.length === 0) { + return; + } + await tx.teamMember.createMany({ + data: membershipIds.map((membershipId) => ({ + id: uuidv7(), + spaceId, + teamId, + membershipId, + })), + }); + } + + private async replaceManagers( + tx: Prisma.TransactionClient, + spaceId: string, + teamId: string, + membershipIds: string[] + ): Promise { + await tx.teamManager.deleteMany({ where: { teamId, spaceId } }); + if (membershipIds.length === 0) { + return; + } + await tx.teamManager.createMany({ + data: membershipIds.map((membershipId) => ({ + id: uuidv7(), + spaceId, + teamId, + membershipId, + })), + }); + } + + private toTeam(row: TeamRow): SpaceTeam { + return { + id: row.id, + name: row.name, + createdAt: row.createdAt.toISOString(), + members: row.members.map((link) => this.toPerson(link.membership)), + managers: row.managers.map((link) => this.toPerson(link.membership)), + }; + } + + private toPerson(membership: MembershipPersonRow): SpaceTeamPerson { + return { + membershipId: membership.id, + userId: membership.userId, + name: membership.user.name, + email: membership.user.email, + image: membership.user.image, + }; + } + + private rethrowNameConflict(error: unknown): void { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new ConflictException({ + message: 'A group with this name already exists', + code: ErrorCode.TEAM_NAME_TAKEN, + }); + } + } +} + +function uniqueIds(ids: string[]): string[] { + return [...new Set(ids)]; +} diff --git a/libs/shared/constants/src/error-codes.ts b/libs/shared/constants/src/error-codes.ts index 26d87f0..e727fca 100644 --- a/libs/shared/constants/src/error-codes.ts +++ b/libs/shared/constants/src/error-codes.ts @@ -19,10 +19,16 @@ export const ErrorCode = { IMAGE_UNSUPPORTED_TYPE: 'IMAGE_UNSUPPORTED_TYPE', IMAGE_TOO_SMALL: 'IMAGE_TOO_SMALL', - // Members errors + // Members / roles errors CANNOT_MODIFY_OWNER: 'CANNOT_MODIFY_OWNER', CANNOT_DEACTIVATE_SELF: 'CANNOT_DEACTIVATE_SELF', CANNOT_REMOVE_SELF: 'CANNOT_REMOVE_SELF', + CANNOT_CHANGE_OWN_ROLE: 'CANNOT_CHANGE_OWN_ROLE', + CANNOT_ASSIGN_OWNER: 'CANNOT_ASSIGN_OWNER', + ROLE_ESCALATION: 'ROLE_ESCALATION', + ROLE_IN_USE: 'ROLE_IN_USE', + ROLE_LIMIT_REACHED: 'ROLE_LIMIT_REACHED', + SYSTEM_ROLE_LOCKED: 'SYSTEM_ROLE_LOCKED', LAST_OWNER: 'LAST_OWNER', INVITE_NOT_FOUND: 'INVITE_NOT_FOUND', INVITE_EXPIRED: 'INVITE_EXPIRED', @@ -30,6 +36,10 @@ export const ErrorCode = { INVITE_EXHAUSTED: 'INVITE_EXHAUSTED', INVITE_EMAIL_MISMATCH: 'INVITE_EMAIL_MISMATCH', ALREADY_MEMBER: 'ALREADY_MEMBER', + + // Teams / groups (WH-058) + TEAM_NAME_TAKEN: 'TEAM_NAME_TAKEN', + MEMBERSHIP_NOT_IN_SPACE: 'MEMBERSHIP_NOT_IN_SPACE', } as const; export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; diff --git a/libs/shared/constants/src/index.ts b/libs/shared/constants/src/index.ts index 5505013..ca969e6 100644 --- a/libs/shared/constants/src/index.ts +++ b/libs/shared/constants/src/index.ts @@ -9,6 +9,7 @@ export { } from './cover-presets'; export { DEFAULT_LOCALE, LOCALES, RTL_LOCALES, type Locale } from './locales'; export { + MAX_CUSTOM_ROLES, PERMISSION_LIST, PERMISSIONS, SYSTEM_ROLE_NAME_LIST, @@ -17,4 +18,5 @@ export { systemRolePresetsAreConsistent, type Permission, type SystemRoleName, -} from './permissions'; \ No newline at end of file +} from './permissions'; +export { permissionsAreSubset } from './permission-subset'; \ No newline at end of file diff --git a/libs/shared/constants/src/permission-subset.ts b/libs/shared/constants/src/permission-subset.ts new file mode 100644 index 0000000..d6740dc --- /dev/null +++ b/libs/shared/constants/src/permission-subset.ts @@ -0,0 +1,8 @@ +/** True when every permission in `granted` also appears in `actor`. */ +export function permissionsAreSubset( + granted: readonly string[], + actor: readonly string[] +): boolean { + const allowed = new Set(actor); + return granted.every((permission) => allowed.has(permission)); +} diff --git a/libs/shared/constants/src/permissions.spec.ts b/libs/shared/constants/src/permissions.spec.ts index 90bec06..5d0764c 100644 --- a/libs/shared/constants/src/permissions.spec.ts +++ b/libs/shared/constants/src/permissions.spec.ts @@ -8,7 +8,7 @@ import { } from './permissions'; describe('permission catalog', () => { - it('keeps system presets inside the catalog with Owner ⊇ Admin ⊇ Member', () => { + it('keeps system presets inside the catalog with Owner ⊇ Admin ⊇ Manager ⊇ Member', () => { expect(systemRolePresetsAreConsistent()).toBe(true); }); @@ -27,6 +27,17 @@ describe('permission catalog', () => { ).not.toContain(PERMISSIONS.SPACE_SETTINGS_WRITE); }); + it('gives Manager the employee baseline plus approval permissions', () => { + expect(SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLE_NAMES.MANAGER]).toEqual([ + PERMISSIONS.MEMBERS_READ, + PERMISSIONS.CALENDAR_READ, + PERMISSIONS.REQUESTS_CREATE, + PERMISSIONS.PROFILE_WRITE, + PERMISSIONS.LEAVE_APPROVE, + PERMISSIONS.DOCUMENTS_FULFIL, + ]); + }); + it('gives Member only the employee baseline', () => { expect(SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLE_NAMES.MEMBER]).toEqual([ PERMISSIONS.MEMBERS_READ, diff --git a/libs/shared/constants/src/permissions.ts b/libs/shared/constants/src/permissions.ts index 57c5b1f..0f7bb59 100644 --- a/libs/shared/constants/src/permissions.ts +++ b/libs/shared/constants/src/permissions.ts @@ -25,10 +25,17 @@ export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export const PERMISSION_LIST = Object.values(PERMISSIONS); -/** Built-in roles created with every workspace (WH-038 / WH-039). */ +/** + * Max custom permission templates (non-system roles) per workspace. + * Owner and Admin create these via `roles.write` (WH-011). + */ +export const MAX_CUSTOM_ROLES = 3; + +/** Built-in roles created with every workspace (WH-038 / WH-039 / WH-058). */ export const SYSTEM_ROLE_NAMES = { OWNER: 'Owner', ADMIN: 'Admin', + MANAGER: 'Manager', MEMBER: 'Member', } as const; @@ -50,6 +57,13 @@ const MEMBER_PERMISSIONS: readonly Permission[] = [ PERMISSIONS.PROFILE_WRITE, ]; +/** Manager = Member + leave/document approval (PRD §4 / WH-058). */ +const MANAGER_PERMISSIONS: readonly Permission[] = [ + ...MEMBER_PERMISSIONS, + PERMISSIONS.LEAVE_APPROVE, + PERMISSIONS.DOCUMENTS_FULFIL, +]; + /** Permissions granted to each system role (PRD §4 Roles). */ export const SYSTEM_ROLE_PERMISSIONS: Record< SystemRoleName, @@ -57,10 +71,14 @@ export const SYSTEM_ROLE_PERMISSIONS: Record< > = { [SYSTEM_ROLE_NAMES.OWNER]: PERMISSION_LIST, [SYSTEM_ROLE_NAMES.ADMIN]: PERMISSION_LIST.filter((p) => !OWNER_ONLY.has(p)), + [SYSTEM_ROLE_NAMES.MANAGER]: MANAGER_PERMISSIONS, [SYSTEM_ROLE_NAMES.MEMBER]: MEMBER_PERMISSIONS, }; -/** True when every preset permission is in the catalog (and Member ⊆ Admin ⊆ Owner). */ +/** + * True when every preset permission is in the catalog and + * Member ⊆ Manager ⊆ Admin ⊆ Owner. + */ export function systemRolePresetsAreConsistent(): boolean { const catalog = new Set(PERMISSION_LIST); const owner = new Set( @@ -69,6 +87,9 @@ export function systemRolePresetsAreConsistent(): boolean { const admin = new Set( SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLE_NAMES.ADMIN] ); + const manager = new Set( + SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLE_NAMES.MANAGER] + ); const member = new Set( SYSTEM_ROLE_PERMISSIONS[SYSTEM_ROLE_NAMES.MEMBER] ); @@ -96,11 +117,17 @@ export function systemRolePresetsAreConsistent(): boolean { } } - for (const permission of member) { + for (const permission of manager) { if (!admin.has(permission)) { return false; } } + for (const permission of member) { + if (!manager.has(permission)) { + return false; + } + } + return true; } diff --git a/libs/shared/contracts/src/index.ts b/libs/shared/contracts/src/index.ts index 7520cb6..8913608 100644 --- a/libs/shared/contracts/src/index.ts +++ b/libs/shared/contracts/src/index.ts @@ -18,10 +18,12 @@ export { export { listSpaceMembersQuerySchema, listSpaceMembersResponseSchema, + patchSpaceMemberSchema, spaceMemberRoleSchema, spaceMemberSchema, type ListSpaceMembersQuery, type ListSpaceMembersResponse, + type PatchSpaceMember, type SpaceMember, type SpaceMemberRole, } from './members'; @@ -77,4 +79,22 @@ export { type SpaceLogo, type SpaceResponse, type SpaceRole, -} from './spaces'; \ No newline at end of file +} from './spaces'; +export { + createSpaceRoleSchema, + patchSpaceRoleSchema, + type CreateSpaceRole, + type PatchSpaceRole, +} from './roles'; +export { + createSpaceTeamSchema, + listSpaceTeamsResponseSchema, + patchSpaceTeamSchema, + spaceTeamPersonSchema, + spaceTeamSchema, + type CreateSpaceTeam, + type ListSpaceTeamsResponse, + type PatchSpaceTeam, + type SpaceTeam, + type SpaceTeamPerson, +} from './teams'; \ No newline at end of file diff --git a/libs/shared/contracts/src/invites.ts b/libs/shared/contracts/src/invites.ts index d6357c3..131a1a1 100644 --- a/libs/shared/contracts/src/invites.ts +++ b/libs/shared/contracts/src/invites.ts @@ -141,13 +141,19 @@ export const acceptInviteResponseSchema = z .meta({ id: 'AcceptInviteResponse' }); export type AcceptInviteResponse = z.infer; -/** GET /spaces/:spaceId/roles — assignable roles for invite dialog (excludes Owner). */ +/** + * GET /spaces/:spaceId/roles — workspace roles (WH-011). + * Includes Owner (read-only in UI). Invite dialog filters Owner client-side. + */ export const spaceRoleOptionSchema = z .object({ id: z.uuid(), name: z.string(), + permissions: z.array(z.string()), + isSystem: z.boolean(), /** Workspace default invite role (`spaces.default_role_id`). */ isDefault: z.boolean(), + memberCount: z.number().int().nonnegative(), }) .meta({ id: 'SpaceRoleOption' }); export type SpaceRoleOption = z.infer; diff --git a/libs/shared/contracts/src/members.spec.ts b/libs/shared/contracts/src/members.spec.ts index 32ebb67..4ac939a 100644 --- a/libs/shared/contracts/src/members.spec.ts +++ b/libs/shared/contracts/src/members.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { listSpaceMembersQuerySchema, listSpaceMembersResponseSchema, + patchSpaceMemberSchema, spaceMemberSchema, } from './members'; @@ -14,6 +15,12 @@ const sampleMember = { role: { id: '01936b1e-8c0a-7b2e-9f3d-4a5b6c7d8e91', name: 'Member', + permissions: [ + 'members.read', + 'calendar.read', + 'requests.create', + 'profile.write', + ], }, membershipStatus: 'active' as const, createdAt: '2026-10-17T12:00:00.000Z', @@ -59,3 +66,17 @@ describe('listSpaceMembersResponseSchema', () => { ).toEqual({ items: [sampleMember], nextCursor: null }); }); }); + +describe('patchSpaceMemberSchema', () => { + it('requires a roleId uuid', () => { + expect( + patchSpaceMemberSchema.parse({ + roleId: '01936b1e-8c0a-7b2e-9f3d-4a5b6c7d8e91', + }) + ).toEqual({ roleId: '01936b1e-8c0a-7b2e-9f3d-4a5b6c7d8e91' }); + expect(() => patchSpaceMemberSchema.parse({})).toThrow(); + expect(() => + patchSpaceMemberSchema.parse({ roleId: 'not-a-uuid' }) + ).toThrow(); + }); +}); diff --git a/libs/shared/contracts/src/members.ts b/libs/shared/contracts/src/members.ts index 34e5817..871ff4d 100644 --- a/libs/shared/contracts/src/members.ts +++ b/libs/shared/contracts/src/members.ts @@ -1,12 +1,20 @@ +import { PERMISSION_LIST, type Permission } from '@work-hub/constants'; import { z } from 'zod'; import { cursorPageSchema } from './pagination'; import { membershipStatusSchema } from './spaces'; -/** Role summary on a member row (full permissions live on space access / WH-011). */ +const permissionTuple = PERMISSION_LIST as unknown as [ + Permission, + ...Permission[], +]; +const permissionSchema = z.enum(permissionTuple); + +/** Role summary on a member row (includes permissions for client-side filters, e.g. team managers). */ export const spaceMemberRoleSchema = z .object({ id: z.uuid(), name: z.string(), + permissions: z.array(permissionSchema), }) .meta({ id: 'SpaceMemberRole' }); export type SpaceMemberRole = z.infer; @@ -45,3 +53,13 @@ export const listSpaceMembersResponseSchema = cursorPageSchema( export type ListSpaceMembersResponse = z.infer< typeof listSpaceMembersResponseSchema >; + +/** + * PATCH /spaces/:spaceId/members/:membershipId — change role (WH-011). + * Plain object (no `.meta({ id })`): nestjs-zod rejects named/$ref for bodies + * that createZodDto extends in some Nest versions. + */ +export const patchSpaceMemberSchema = z.object({ + roleId: z.uuid(), +}); +export type PatchSpaceMember = z.infer; diff --git a/libs/shared/contracts/src/roles.ts b/libs/shared/contracts/src/roles.ts new file mode 100644 index 0000000..ca2293e --- /dev/null +++ b/libs/shared/contracts/src/roles.ts @@ -0,0 +1,38 @@ +import { PERMISSION_LIST, type Permission } from '@work-hub/constants'; +import { z } from 'zod'; + +const permissionTuple = PERMISSION_LIST as unknown as [ + Permission, + ...Permission[], +]; +const permissionSchema = z.enum(permissionTuple); + +/** + * POST /spaces/:spaceId/roles — create a custom role (WH-011). + * Plain object for createZodDto (no `.meta({ id })` on the body schema). + */ +export const createSpaceRoleSchema = z.object({ + name: z.string().trim().min(1).max(100), + permissions: z.array(permissionSchema).min(1), + isDefault: z.boolean().optional(), +}); +export type CreateSpaceRole = z.infer; + +/** + * PATCH /spaces/:spaceId/roles/:roleId + * At least one field required. + */ +export const patchSpaceRoleSchema = z + .object({ + name: z.string().trim().min(1).max(100).optional(), + permissions: z.array(permissionSchema).min(1).optional(), + isDefault: z.boolean().optional(), + }) + .refine( + (value) => + value.name !== undefined || + value.permissions !== undefined || + value.isDefault !== undefined, + { message: 'At least one field is required' } + ); +export type PatchSpaceRole = z.infer; diff --git a/libs/shared/contracts/src/teams.ts b/libs/shared/contracts/src/teams.ts new file mode 100644 index 0000000..987e4ee --- /dev/null +++ b/libs/shared/contracts/src/teams.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +/** Person summary on a team (member or manager). */ +export const spaceTeamPersonSchema = z + .object({ + membershipId: z.uuid(), + userId: z.uuid(), + name: z.string(), + email: z.email(), + image: z.string().nullable(), + }) + .meta({ id: 'SpaceTeamPerson' }); +export type SpaceTeamPerson = z.infer; + +/** One workspace group (UI: Groups; API: teams) — WH-058. */ +export const spaceTeamSchema = z + .object({ + id: z.uuid(), + name: z.string(), + createdAt: z.iso.datetime(), + members: z.array(spaceTeamPersonSchema), + managers: z.array(spaceTeamPersonSchema), + }) + .meta({ id: 'SpaceTeam' }); +export type SpaceTeam = z.infer; + +export const listSpaceTeamsResponseSchema = z + .object({ + items: z.array(spaceTeamSchema), + }) + .meta({ id: 'ListSpaceTeamsResponse' }); +export type ListSpaceTeamsResponse = z.infer< + typeof listSpaceTeamsResponseSchema +>; + +/** + * POST /spaces/:spaceId/teams + * Plain object for createZodDto. + */ +export const createSpaceTeamSchema = z.object({ + name: z.string().trim().min(1).max(100), + memberIds: z.array(z.uuid()).max(500).optional(), + managerIds: z.array(z.uuid()).max(100).optional(), +}); +export type CreateSpaceTeam = z.infer; + +/** + * PATCH /spaces/:spaceId/teams/:teamId + * `memberIds` / `managerIds` replace the full set when present. + */ +export const patchSpaceTeamSchema = z + .object({ + name: z.string().trim().min(1).max(100).optional(), + memberIds: z.array(z.uuid()).max(500).optional(), + managerIds: z.array(z.uuid()).max(100).optional(), + }) + .refine( + (value) => + value.name !== undefined || + value.memberIds !== undefined || + value.managerIds !== undefined, + { message: 'At least one field is required' } + ); +export type PatchSpaceTeam = z.infer; diff --git a/tools/backlog/src/backlog.mjs b/tools/backlog/src/backlog.mjs index d0fba8c..c320129 100644 --- a/tools/backlog/src/backlog.mjs +++ b/tools/backlog/src/backlog.mjs @@ -133,11 +133,15 @@ export function committedDoneTickets(tickets, headMarkdown) { } /** - * The next implementation: the first `start` ticket(s) in list order (priority, then - * planned order), moved to `in progress` right after a commit closes the done tickets. + * The next implementations: every `start` ticket at the highest remaining priority (the + * current milestone), in planned order. They move to `in progress` right after a commit + * closes the done tickets. */ -export function nextStartTickets(tickets, count = 1) { - return tickets.filter((t) => t.status === 'start').slice(0, count); +export function nextStartTickets(tickets) { + const starts = tickets.filter((t) => t.status === 'start' && t.priority !== null); + if (starts.length === 0) return []; + const top = Math.min(...starts.map((t) => t.priority)); + return starts.filter((t) => t.priority === top); } /** @@ -210,7 +214,7 @@ export function hookContext({ event, tickets, problems, pending, committedDone = const next = nextStartTickets(tickets); lines.push( `${committedDone.length} done ticket(s) are committed (done in HEAD's BACKLOG.md): delete them from BACKLOG.md now: ${committedDone.map((t) => t.id).join(', ')}.`, - `Then move the next implementation from \`start\` to \`in progress\` (first start ticket by priority and planned order${next.length ? `: ${next.map((t) => t.id).join(', ')}` : ''}), and tell the user what's next.` + `Then move the next implementations from \`start\` to \`in progress\` (every start ticket at the highest remaining priority${next.length ? `: ${next.map((t) => t.id).join(', ')}` : ''}), and tell the user what's next.` ); } if (problems.length) { diff --git a/tools/backlog/src/backlog.test.mjs b/tools/backlog/src/backlog.test.mjs index b04bd1b..869df97 100644 --- a/tools/backlog/src/backlog.test.mjs +++ b/tools/backlog/src/backlog.test.mjs @@ -92,10 +92,12 @@ describe('validate', () => { }); describe('nextStartTickets', () => { - it('returns the first start tickets in list order', () => { - const tickets = parseTickets(backlog({ tickets: [ticket('WH-002', 1, 'in progress'), ticket('WH-003', 1, 'start'), ticket('WH-001', 2, 'start')] })); - assert.deepEqual(nextStartTickets(tickets).map((t) => t.id), ['WH-003']); - assert.deepEqual(nextStartTickets(tickets, 2).map((t) => t.id), ['WH-003', 'WH-001']); + it('returns every start ticket at the highest remaining priority, in list order', () => { + const tickets = parseTickets( + backlog({ tickets: [ticket('WH-002', 1, 'in progress'), ticket('WH-003', 1, 'start'), ticket('WH-005', 1, 'start'), ticket('WH-001', 2, 'start')] }) + ); + assert.deepEqual(nextStartTickets(tickets).map((t) => t.id), ['WH-003', 'WH-005']); + assert.deepEqual(nextStartTickets(parseTickets(backlog({ tickets: [ticket('WH-001', 2, 'in progress')] }))), []); }); }); @@ -152,7 +154,7 @@ describe('review tracking', () => { const withNext = parseTickets(backlog({ tickets: [ticket('WH-005', 1, 'start'), ticket('WH-004', 2, 'done')] })); const text = hookContext({ event: 'prompt', tickets: withNext, problems: [], pending: [], committedDone: done }); assert.match(text, /delete them from BACKLOG.md now: WH-004/); - assert.match(text, /move the next implementation from `start` to `in progress`.*: WH-005/); + assert.match(text, /move the next implementations from `start` to `in progress`.*: WH-005/); assert.match(loud, /give the user a commit message/); }); }); diff --git a/tools/quality/src/checks.mjs b/tools/quality/src/checks.mjs index 688c8ad..8ec5a2a 100644 --- a/tools/quality/src/checks.mjs +++ b/tools/quality/src/checks.mjs @@ -59,7 +59,7 @@ function escapeRegExp(value) { /** * Keys whose last segment never appears as a quoted string in the code. - * A key also counts as used when its parent is read dynamically (`t(\`parent.${x}\`)`) + * A key also counts as used when one of its ancestors is read dynamically (`t(\`parent.${x}\`)`) * or when it matches an allowlisted prefix (keys read with a variable, e.g. `t(locale)`). * Heuristic by design: it can miss an unused key, but it doesn't flag a used one. */ @@ -74,14 +74,11 @@ export function findUnusedMessageKeys(messages, code, allowPrefixes = []) { if (quoted.test(code)) { return false; } - const parent = segments[segments.length - 2]; - if (parent) { - const dynamic = new RegExp(`['"\`]([\\w.]*\\.)?${escapeRegExp(parent)}\\.\\$\\{`); - if (dynamic.test(code)) { - return false; - } - } - return true; + // Any ancestor read with a template (`t(\`permissions.${p}\`)` where `p` itself + // contains dots, e.g. `space.settings.write`) makes the whole subtree used. + return !segments.slice(0, -1).some((ancestor) => + new RegExp(`['"\`]([\\w.]*\\.)?${escapeRegExp(ancestor)}\\.\\$\\{`).test(code) + ); }); } diff --git a/tools/quality/src/checks.test.mjs b/tools/quality/src/checks.test.mjs index a4b5f41..edb649b 100644 --- a/tools/quality/src/checks.test.mjs +++ b/tools/quality/src/checks.test.mjs @@ -55,6 +55,11 @@ describe('findUnusedMessageKeys', () => { assert.deepEqual(findUnusedMessageKeys(messages, code, ['Locale']), ['Shell.old']); }); + it('treats a subtree as used when an ancestor is read with a dotted variable', () => { + const perms = { Roles: { permissions: { space: { settings: { write: 'Edit' } }, members: { read: 'View' } } } }; + assert.deepEqual(findUnusedMessageKeys(perms, 't(`permissions.${permission}`)'), []); + }); + it('matches a leaf inside a dotted key', () => { assert.deepEqual(findUnusedMessageKeys({ A: { b: 'x' } }, "t('A.b')"), []); });