Add group sharing and member management UI #73

Merged
rob merged 3 commits from feat/member-management-ui into main 2026-08-05 20:24:30 +00:00
Owner

Implements task 96: invite by email, roster (roles + invitation status), role changes, promote to Owner, and member removal with confirmation.

Where it lives: a new "Groups" section in the drawer, below the place accordion (task 91 is still held, ADR-0062 already set the panel-not-page precedent). Selecting a group opens GroupMembersPanel, floating over the map outside the drawer's own inert subtree, the same shape PlaceDetailPanel established.

API/Infra change: GroupMemberResponse gained UserId. The existing roster response (ADR-0046) had no way to name a specific row — PUT/DELETE/promote-to-owner all key by {userId}, and two members can share a display name. ADR-0065 records why this is additive to ADR-0046, not a reopening of it (email stays withheld).

Invite wording: the API answers 202 uniformly whether or not the address resolves (ADR-0050). The confirmation text is deliberately conditional — "If {email} has a PlaceMark account, they've been invited to this group — you won't see a difference either way" — and the roster reloads immediately after, which is the indirect signal ADR-0050 itself names.

Promotion: the role selector never offers Owner (Editor/Viewer only); a separate "Make owner" button routes to promote-to-owner, with its own confirmation — not required by the acceptance criteria, but justified by ADR-0050/0052/0054 all treating a second Owner as a materially bigger, harder-to-reverse grant.

Confirmation: inline per-row (swap to prompt + Yes/Cancel), not a modal — the confirming button is focused immediately and carries aria-describedby naming what it does; Escape cancels. Reasoning and alternatives (including why not <dialog>) are in ADR-0065.

Scope cut: "leave group" (removing yourself) is not built — the acceptance criteria frame removal as Owner-only, and self-service leaving is a distinct, unbuilt feature on the same endpoint (ADR-0053). Also not built: focus returning to the specific drawer button that opened the panel on close (no supported way to @ref a dynamic per-row element; PlaceDetailPanel sets the same no-return-focus precedent).

Not verified: anything needing a real browser — actual focus movement, screen-reader announcement, visual layout on a narrow viewport (task 156, same limit every WebUI ADR since 0059 has carried). bUnit proves the component asks the browser to do the right thing, not that it lands.

See ADR-0065 for full reasoning, including the alternatives considered.

Implements task 96: invite by email, roster (roles + invitation status), role changes, promote to Owner, and member removal with confirmation. **Where it lives**: a new "Groups" section in the drawer, below the place accordion (task 91 is still held, ADR-0062 already set the panel-not-page precedent). Selecting a group opens `GroupMembersPanel`, floating over the map outside the drawer's own `inert` subtree, the same shape `PlaceDetailPanel` established. **API/Infra change**: `GroupMemberResponse` gained `UserId`. The existing roster response (ADR-0046) had no way to name a specific row — `PUT`/`DELETE`/`promote-to-owner` all key by `{userId}`, and two members can share a display name. ADR-0065 records why this is additive to ADR-0046, not a reopening of it (email stays withheld). **Invite wording**: the API answers `202` uniformly whether or not the address resolves (ADR-0050). The confirmation text is deliberately conditional — "If {email} has a PlaceMark account, they've been invited to this group — you won't see a difference either way" — and the roster reloads immediately after, which is the indirect signal ADR-0050 itself names. **Promotion**: the role selector never offers Owner (Editor/Viewer only); a separate "Make owner" button routes to `promote-to-owner`, with its own confirmation — not required by the acceptance criteria, but justified by ADR-0050/0052/0054 all treating a second Owner as a materially bigger, harder-to-reverse grant. **Confirmation**: inline per-row (swap to prompt + Yes/Cancel), not a modal — the confirming button is focused immediately and carries `aria-describedby` naming what it does; Escape cancels. Reasoning and alternatives (including why not `<dialog>`) are in ADR-0065. **Scope cut**: "leave group" (removing yourself) is not built — the acceptance criteria frame removal as Owner-only, and self-service leaving is a distinct, unbuilt feature on the same endpoint (ADR-0053). Also not built: focus returning to the specific drawer button that opened the panel on close (no supported way to `@ref` a dynamic per-row element; `PlaceDetailPanel` sets the same no-return-focus precedent). **Not verified**: anything needing a real browser — actual focus movement, screen-reader announcement, visual layout on a narrow viewport (task 156, same limit every WebUI ADR since 0059 has carried). bUnit proves the component asks the browser to do the right thing, not that it lands. See ADR-0065 for full reasoning, including the alternatives considered.
Merge remote-tracking branch 'origin/main' into feat/member-management-ui
All checks were successful
CI / build (pull_request) Successful in 2m5s
2cb139c721
# Conflicts:
#	docs/adr/README.md
rob left a comment

Verdict: mergeable

Build and full test suite (WebUI, Api, Infrastructure, Architecture — 1105 tests) pass clean with the pinned 10.0.100 SDK.

Contract change (UserId): genuinely populated end to end — SelectMembersForGroup selects gm.user_id, flows through GroupMemberForResponse/GroupMemberResponse, and is covered both at the repository level (GroupMembershipRepositoryTests) and the API integration level (GetGroupEndpointTests), against real PostgreSQL. GetGroup_TheRoster_NeverCarriesAnEmailAddress still passes and nothing else leaked in — the roster's shape is otherwise unchanged. Exposing an opaque UserId is the right call: it's what the mutating routes key by, and nothing about it is more sensitive than the display name already on the wire.

Anti-enumeration wording: checked the actual strings. "Request sent. If {email} has a PlaceMark account..." is unconditionally true and never branches on the outcome; the roster reload afterwards is not a new leak — an Owner already has standing to read the roster at will, this just automates a check ADR-0050's own Consequences section already names as available. No success/failure divergence, no timing tell (one awaited call either way). Test GroupMembersPanel_InviteSubmittedSuccessfully_ShowsTheUniformWordingRatherThanClaimingSuccess pins the wording.

Promotion seam: confirmed "Make owner" calls POST .../promote-to-owner, never the role PUT (separate code path, separate test asserting the POST hits /promote-to-owner). The role selector never offers Owner. Reads as one action ("make this person an Owner") plus a two-option role dropdown, not three unrelated controls.

Last Owner, personal groups, courtesy controls, confirmation: all as claimed. The 409 detail ("This member is the group's last Owner...") is shown verbatim, not genericised. GroupsSection omits personal groups outright (tested). CallerIsOwner is derived from a live roster fetch each load, so a stale-role 403 is just another ApiException caught and shown — no special-casing needed or missing. Removal confirmation is a real two-step flow with its own tests asserting the intermediate state (click ≠ immediate removal), aria-describedby, and a focus call — a regression here would fail those tests, not just silently pass.

One defect: src/PlaceMark.Contracts/Groups/GroupMemberResponse.cs and src/PlaceMark.Infrastructure/Groups/GroupMemberForResponse.cs both link ADR-0065 as docs/adr/0065-put-a-members-own-user-id-on-the-roster.md, which doesn't exist — the file this PR actually adds is 0065-group-membership-panel-roster-user-id-and-invite-wording.md (correctly referenced from the two WebUI files that cite it). Fix the two dead links.

Stated omissions: both read as reasonable trade-offs rather than gaps to fix now. No self-service leave matches the acceptance criteria's own Owner-only framing for removal, and ADR-0065 names it explicitly rather than leaving it to be rediscovered — though it's worth a follow-up ticket, since a non-Owner currently has no in-app way to leave a group they no longer want to be in. Not returning focus to the drawer's per-row trigger is a real, argued technical constraint (no supported per-iteration @ref), consistent with PlaceDetailPanel's precedent, and not silently dropped.

Not verifiable here: real focus movement, what a screen reader actually announces from aria-describedby, and layout/z-index behaviour on a narrow viewport — bUnit proves the component asks the browser to do the right thing, not that it lands. Consistent with every WebUI ADR since 0059 and named as such in ADR-0065 itself.

Verdict: mergeable Build and full test suite (WebUI, Api, Infrastructure, Architecture — 1105 tests) pass clean with the pinned 10.0.100 SDK. **Contract change (`UserId`)**: genuinely populated end to end — `SelectMembersForGroup` selects `gm.user_id`, flows through `GroupMemberForResponse`/`GroupMemberResponse`, and is covered both at the repository level (`GroupMembershipRepositoryTests`) and the API integration level (`GetGroupEndpointTests`), against real PostgreSQL. `GetGroup_TheRoster_NeverCarriesAnEmailAddress` still passes and nothing else leaked in — the roster's shape is otherwise unchanged. Exposing an opaque `UserId` is the right call: it's what the mutating routes key by, and nothing about it is more sensitive than the display name already on the wire. **Anti-enumeration wording**: checked the actual strings. "Request sent. If {email} has a PlaceMark account..." is unconditionally true and never branches on the outcome; the roster reload afterwards is not a new leak — an Owner already has standing to read the roster at will, this just automates a check ADR-0050's own Consequences section already names as available. No success/failure divergence, no timing tell (one awaited call either way). Test `GroupMembersPanel_InviteSubmittedSuccessfully_ShowsTheUniformWordingRatherThanClaimingSuccess` pins the wording. **Promotion seam**: confirmed "Make owner" calls `POST .../promote-to-owner`, never the role PUT (separate code path, separate test asserting the POST hits `/promote-to-owner`). The role selector never offers Owner. Reads as one action ("make this person an Owner") plus a two-option role dropdown, not three unrelated controls. **Last Owner, personal groups, courtesy controls, confirmation**: all as claimed. The 409 detail ("This member is the group's last Owner...") is shown verbatim, not genericised. `GroupsSection` omits personal groups outright (tested). `CallerIsOwner` is derived from a live roster fetch each load, so a stale-role 403 is just another `ApiException` caught and shown — no special-casing needed or missing. Removal confirmation is a real two-step flow with its own tests asserting the intermediate state (click ≠ immediate removal), `aria-describedby`, and a focus call — a regression here would fail those tests, not just silently pass. **One defect**: `src/PlaceMark.Contracts/Groups/GroupMemberResponse.cs` and `src/PlaceMark.Infrastructure/Groups/GroupMemberForResponse.cs` both link ADR-0065 as `docs/adr/0065-put-a-members-own-user-id-on-the-roster.md`, which doesn't exist — the file this PR actually adds is `0065-group-membership-panel-roster-user-id-and-invite-wording.md` (correctly referenced from the two WebUI files that cite it). Fix the two dead links. **Stated omissions**: both read as reasonable trade-offs rather than gaps to fix now. No self-service leave matches the acceptance criteria's own Owner-only framing for removal, and ADR-0065 names it explicitly rather than leaving it to be rediscovered — though it's worth a follow-up ticket, since a non-Owner currently has no in-app way to leave a group they no longer want to be in. Not returning focus to the drawer's per-row trigger is a real, argued technical constraint (no supported per-iteration `@ref`), consistent with `PlaceDetailPanel`'s precedent, and not silently dropped. **Not verifiable here**: real focus movement, what a screen reader actually announces from `aria-describedby`, and layout/z-index behaviour on a narrow viewport — bUnit proves the component asks the browser to do the right thing, not that it lands. Consistent with every WebUI ADR since 0059 and named as such in ADR-0065 itself.
Fix dead ADR-0065 links in the roster contract types
All checks were successful
CI / build (pull_request) Successful in 2m24s
3f7ed8dc50
rob merged commit c5a0fd2312 into main 2026-08-05 20:24:30 +00:00
rob deleted branch feat/member-management-ui 2026-08-05 20:24:30 +00:00
rob referenced this pull request from a commit 2026-08-05 20:24:31 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!73
No description provided.