Invitation lifecycle UI: expiry display + invitee inbox/accept page #362

Closed
opened 2026-09-12 00:49:01 +00:00 by crueber · 3 comments
Owner

Survey: crueber/walhub#349 candidate 10.

Evidence

  • Backend is complete: 7-day TTLs (internal/identity/http_invites.go:182,300), inviteSummaries serve expires_at (http_invites.go:352-358), preview/accept/decline endpoints exist, SDK covers invites.mine/list/accept/cancel (web/sdk/src/invites.js).
  • Owner-side gap: Org.jsx InvitesTab (329-419) lists subject/role/invited_by with cancel, but shows NO expiry — the data is served, just not rendered.
  • Invitee-side gap: no inbox/accept page exists — web/src/index.jsx has no /invitations route, no component reads invites.mine(), and acceptURL is an API path (/api/v1/invitations/{id}?token=, http_invites.go:381-383), so invitees have no usable accept UI; the create form just prints the API link.

Design

  • Render expires_at (+ expired state) in the org Invitations tab and repo invite lists.
  • Add an invitee page (e.g. /invitations) listing my pending invites with preview/accept/decline via the existing endpoints; link it from the header/tray for authenticated users.

Acceptance criteria

  • Pending invites show expiry; expired invites read as expired before accept fails closed.
  • An authenticated invitee can list, preview, accept, and decline invites entirely in the UI.
  • Real-browser check per AGENTS.md ladder (browser-facing surface).
Survey: crueber/walhub#349 candidate 10. ## Evidence - Backend is complete: 7-day TTLs (internal/identity/http_invites.go:182,300), inviteSummaries serve expires_at (http_invites.go:352-358), preview/accept/decline endpoints exist, SDK covers invites.mine/list/accept/cancel (web/sdk/src/invites.js). - Owner-side gap: Org.jsx InvitesTab (329-419) lists subject/role/invited_by with cancel, but shows NO expiry — the data is served, just not rendered. - Invitee-side gap: no inbox/accept page exists — web/src/index.jsx has no /invitations route, no component reads invites.mine(), and acceptURL is an API path (/api/v1/invitations/{id}?token=, http_invites.go:381-383), so invitees have no usable accept UI; the create form just prints the API link. ## Design - Render expires_at (+ expired state) in the org Invitations tab and repo invite lists. - Add an invitee page (e.g. /invitations) listing my pending invites with preview/accept/decline via the existing endpoints; link it from the header/tray for authenticated users. ## Acceptance criteria - [ ] Pending invites show expiry; expired invites read as expired before accept fails closed. - [ ] An authenticated invitee can list, preview, accept, and decline invites entirely in the UI. - [ ] Real-browser check per AGENTS.md ladder (browser-facing surface).
crueber added this to the v1 milestone 2026-09-12 00:49:01 +00:00
Author
Owner

Fixed by PR #369 (#369) — expiry column in org Invitations tab, new /invitations inbox (list/preview/accept/decline + shared-link deep link), header link for authenticated users; inbox rows now serve expires_at. Tests green; real-browser check recorded open (loopback guard).

Fixed by PR #369 (https://git.packden.us/crueber/walhub/pulls/369) — expiry column in org Invitations tab, new /invitations inbox (list/preview/accept/decline + shared-link deep link), header link for authenticated users; inbox rows now serve expires_at. Tests green; real-browser check recorded open (loopback guard).
Author
Owner

Review of PR #369 (fix/issue-362), verified in a scratch worktree (created/removed by me; main worktree left clean, read-only):

(1) Expires column — CORRECT. web/src/pages/Org.jsx InvitesTab: DateTime while pending, expired chip once isInviteExpired fires (at-or-before-now boundary, tested), em-dash fallback when the row predates the field. Cancel stays on expired rows. Matches law 5 (display fails open, server fails closed).
(2) Inbox page — CORRECT. web/src/pages/Invitations.jsx lists invites.mine() rows (org+repo, kind/scope/role/inviter/expiry cells), subject-authorized preview without token, accept disabled once expired with 409-fail-closed title (server enforces at the issuer object in findInvite, internal/identity/invites.go:370-372), decline via top-level DELETE, ?id=&token= deep-link token-authorized preview on top. Anonymous gets the friendly sign-in message (handler 401s anonymous at http_invites.go:28-31,57-60 — pre-existing backend, page handles it).
(3) Header link gating — CORRECT. web/src/App.jsx gates on shared unreadCount() !== null from Notifications.jsx:12; null exactly when the authenticated unread_count probe failed, so zero new requests and anonymous never sees the link.
(4) InboxEntry.expires_at — CORRECT. internal/identity/invites.go:50 omitempty append-only; both create sites stamp (org :191, repo :231); no fan-out (MyInvites returns rows directly). Pre-#362 rows decode '' and isInviteExpired fails open (never expired on display) while accept still 409s via the issuer object. Documented in features/01 §7 + Decisions.
(5) Route ordering — CORRECT. /invitations (index.jsx:66) sits with the other statics before /:owner (:72) with the standard comment.
(6) Repo-admin panel out-of-scope — TRUE. No repo invite list UI exists (Access.jsx is bindings-only; repo invites are create/cancel via API); inbox covers repo-kind rows with expiry; follow-up noted in 12_web_ui.md Decisions.
(7) Accept/decline auth — CORRECT, invitee-only. AcceptInvite (:408-416) resolves through the caller's own inbox (findInvite :328) plus subject match; top-level DELETE cancelInvite (http_invites.go:109-121) does the same. Others' invites are unreachable (409/403).
(8) Checks — identity -race green; cover 96.3% (gate holds); gofmt/vet clean; go build ./... green; node --test 718 pass / 0 fail / 3 skipped (smoke only); vite build + esbuild bundle green; go.mod + web/package.json untouched (no new deps); docs (features/01 §7 + Decisions, 12_web_ui Decisions) accurate.

Notes (non-blocking): node smoke tests only skip when the base URL is unreachable — something on this machine's :8080 answers /healthz 200 but /repos.js 503 (setup-only-mode shape); I did not touch it and ran unit tests with an unreachable base instead. Real-browser check still open per the PR description (shared obscura daemon loopback guard; no private daemon per workspace rules) — noted explicitly, no browser used.

No fixes needed — nothing pushed. MERGE RECOMMENDATION: ready to merge.

Review of PR #369 (fix/issue-362), verified in a scratch worktree (created/removed by me; main worktree left clean, read-only): (1) Expires column — CORRECT. web/src/pages/Org.jsx InvitesTab: DateTime while pending, expired chip once isInviteExpired fires (at-or-before-now boundary, tested), em-dash fallback when the row predates the field. Cancel stays on expired rows. Matches law 5 (display fails open, server fails closed). (2) Inbox page — CORRECT. web/src/pages/Invitations.jsx lists invites.mine() rows (org+repo, kind/scope/role/inviter/expiry cells), subject-authorized preview without token, accept disabled once expired with 409-fail-closed title (server enforces at the issuer object in findInvite, internal/identity/invites.go:370-372), decline via top-level DELETE, ?id=\&token= deep-link token-authorized preview on top. Anonymous gets the friendly sign-in message (handler 401s anonymous at http_invites.go:28-31,57-60 — pre-existing backend, page handles it). (3) Header link gating — CORRECT. web/src/App.jsx gates on shared unreadCount() !== null from Notifications.jsx:12; null exactly when the authenticated unread_count probe failed, so zero new requests and anonymous never sees the link. (4) InboxEntry.expires_at — CORRECT. internal/identity/invites.go:50 omitempty append-only; both create sites stamp (org :191, repo :231); no fan-out (MyInvites returns rows directly). Pre-#362 rows decode '' and isInviteExpired fails open (never expired on display) while accept still 409s via the issuer object. Documented in features/01 §7 + Decisions. (5) Route ordering — CORRECT. /invitations (index.jsx:66) sits with the other statics before /:owner (:72) with the standard comment. (6) Repo-admin panel out-of-scope — TRUE. No repo invite list UI exists (Access.jsx is bindings-only; repo invites are create/cancel via API); inbox covers repo-kind rows with expiry; follow-up noted in 12_web_ui.md Decisions. (7) Accept/decline auth — CORRECT, invitee-only. AcceptInvite (:408-416) resolves through the caller's own inbox (findInvite :328) plus subject match; top-level DELETE cancelInvite (http_invites.go:109-121) does the same. Others' invites are unreachable (409/403). (8) Checks — identity -race green; cover 96.3% (gate holds); gofmt/vet clean; go build ./... green; node --test 718 pass / 0 fail / 3 skipped (smoke only); vite build + esbuild bundle green; go.mod + web/package.json untouched (no new deps); docs (features/01 §7 + Decisions, 12_web_ui Decisions) accurate. Notes (non-blocking): node smoke tests only skip when the base URL is unreachable — something on this machine's :8080 answers /healthz 200 but /repos.js 503 (setup-only-mode shape); I did not touch it and ran unit tests with an unreachable base instead. Real-browser check still open per the PR description (shared obscura daemon loopback guard; no private daemon per workspace rules) — noted explicitly, no browser used. No fixes needed — nothing pushed. MERGE RECOMMENDATION: ready to merge.
Author
Owner

Fixed by PR #369 (review clean — all 8 points pass, accept fail-closed, invitee-only, back-compat verified; 718 pass), merged. Closing.

Fixed by PR #369 (review clean — all 8 points pass, accept fail-closed, invitee-only, back-compat verified; 718 pass), merged. Closing.
Sign in to join this conversation.
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
crueber/walhub#362
No description provided.