feat(webapp): an invite link can name the project it was sent about (BEA-170)

An org owner minted /join/<token>, pasted it into Slack, and the recipient
signed up and landed on a list of projects with nothing saying which one they
were invited for. The page that finishes the job — /<project-id>/install, which
bakes this hub's origin and this project's id into the agent paste prompt — was
one click away and nobody told them to click it.

The link may now carry ?p=<project-id>, minted from a project's Settings, and
the joiner lands on that project's install page.

Three edits and one hook:

- inviteTokenFromNext cuts `next` at the FIRST "?" before the /join/<hex>
  check. Without it a logged-out invitee on an invite-only hub (the default
  posture) silently loses the account-creation form — the recipient who most
  needs the feature is the one it broke. Every existing negative stays closed:
  "/wiki/note.md?x=/join/<tok>" cuts to "/wiki/note.md" and still fails the
  prefix.
- useFetchProjects: useProjects is disabled while the join screen is up and
  invalidateQueries never fetches a disabled query, so the "does p resolve"
  check had to fetch rather than refresh — otherwise it silently always fails.
- HubApp navigates to /<p>/install only when p is in the list the server just
  returned. That resolve IS the validator: p="/evil.com" would build
  "//evil.com/install" and pushState throws on a cross-origin target. Anything
  unresolvable lands on "/", never on "Project not found" — right for a typed
  URL, wrong as a new teammate's first screen.
- ProjectSettings People card gains an owners-only "Invite a teammate" button,
  gated on org.role (handleInviteCreate 403s a project admin who is a plain org
  member).

No invite-record change, no schema change, no new route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee
2026-08-24 09:16:54 -07:00
co-authored by Claude Opus 5
parent 82e23f2382
commit fc5f7e8ddf
11 changed files with 352 additions and 129 deletions
+6
View File
@@ -896,6 +896,12 @@ func safeNext(next string) string {
// arriving at /join/.
func inviteTokenFromNext(next string) string {
const marker = "/join/"
// A project-scoped invite is "/join/<tok>?p=<project-id>", so the query
// is cut BEFORE the prefix check: it is not part of the route the token
// has to BE, and cutting at the FIRST "?" keeps every negative below
// closed — "/wiki/note.md?x=/join/<tok>" cuts to "/wiki/note.md" and
// still fails the prefix.
next, _, _ = strings.Cut(next, "?")
if !strings.HasPrefix(next, marker) {
return ""
}