Skip to content

fix(webapp): confirm before minting an org invite, then show the link (BEA-192) - #202

Open
ssowonny wants to merge 3 commits into
mainfrom
bea-192-ph-scan-bug-invite-a-teammate-mints-a-live-invite-and-then
Open

fix(webapp): confirm before minting an org invite, then show the link (BEA-192)#202
ssowonny wants to merge 3 commits into
mainfrom
bea-192-ph-scan-bug-invite-a-teammate-mints-a-live-invite-and-then

Conversation

@ssowonny

@ssowonny ssowonny commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • Clicking Invite a teammate used to mint a real, redeemable org invite before you'd agreed to anything, and then not show it to you. Now you get a dialog first, and the link lands on the page.
  • The dialog is the Share dialog's shape moved one step earlier: say what it grants, pick an expiry, then mint.
  • The failure branch was the nasty one — its toast sent you to Organization settings, which stores the link without ?p=<project-id>, so following the advice quietly handed you a link that no longer opened the project. That promise is gone.
  • Losing the clipboard now costs you a button label, not the link. And a double-clicked Create mints one invite — it minted two while I was testing, so there's a latch.
  • Known gap: the project-scoped ?p= form still exists only in this dialog. Recovering it later means persisting the project on the invite record — out of scope here, worth its own issue.

The bug, and which half of it was actually broken

One click, two paths, and the reported one was the bad half:

flowchart TB
    Click["click <b>Invite a teammate</b><br/>(no dialog, no confirmation)"]
    Mint["POST /api/orgs/&lt;org&gt;/invites<br/><b>a live, redeemable invite exists now</b>"]
    Copy{"copyText(url + &quot;?p=&quot; + project.id)"}
    OK["<b>copy ok</b><br/>toast: &quot;Invite link copied&quot;<br/>link is on the clipboard<br/>and nowhere on screen"]
    Bad["<b>copy failed</b><br/>toast: &quot;copy it from Organization settings&quot;<br/>not on screen. not on the clipboard.<br/>and the copy in Org settings<br/>has no &quot;?p=&quot;"]
    Click --> Mint --> Copy
    Copy -->|ok| OK
    Copy -->|failed| Bad
    classDef bad fill:#ef444422,stroke:#ef4444,stroke-width:2px
    classDef warn fill:#eab30822,stroke:#eab308,stroke-width:2px
    class Bad bad
    class Mint warn
Loading

Priya's report named the failure branch's toast. That branch is real — her automated browser had no clipboard, and it is the one with no recovery at all — but two things are true on both paths, and they're the substance of the fix: nothing asks before minting, and the link is never rendered.

The third defect isn't in the original report; it surfaced reading ProjectSettings.tsx against OrgAdmin.tsx. The stored invite is org-scoped. OrgAdmin lists it by inv.url, which carries no ?p=. So the toast's own advice downgrades the link — and per the code comment that ?p= "is the whole feature": it lands the recipient on this project's install page instead of a nameless project list.

What changed

New components/InviteDialog.tsx, ShareDialog's two-phase shape moved a step earlier so nothing exists before you say yes:

step before now
before mint nothing dialog names the grant, offers 24h / 7d / 30d expiry
Cancel n/a no POST at all
on mint toast only the full ?p= URL on the page, Copy / Open / Done
copy fails dead end button says "Copy link" instead of "Copied ✓"; link unmoved
recovery elsewhere toast → Org settings (wrong link) promise retired

The one thing worth a second look: disabled={busy} is not enough on a control that mints a credential. Two clicks dispatched inside one task both run mint() before React repaints the button — I wrote the test expecting it to pass and it reported 2 invites. There is now a useRef latch, and the test fails without it (verified by removing it and re-running).

ProjectSettings keeps its org?.role === "owner" gate and its #ps-invite handle untouched — the gate is on the org role deliberately, since handleInviteCreate 403s a plain member, and a project admin who is a plain org member is exactly who'd otherwise be shown a button that fails.

Expiry sits pre-mint here, the opposite of ShareDialog. That's deliberate and the comment says why: ShareDialog's ordering is about ShareDB.Create reusing an existing permanent link, so minting with a TTL would hand out a second one. CreateInvite always mints a fresh token, so there's no second link to dodge.

Why the Org-settings promise was retired rather than repaired (AC 4 allowed either): making that list carry ?p= needs an OrgInvite.Project field, handleInviteCreate accepting it, handleInviteList emitting it, and both MetaStore backends including a schema migration — for a convenience. The spec put it out of scope. OrgAdmin's own mint button keeps its toast: its copy-failure branch points at the list directly below it, which really does show the link.

Evidence

Driving the real UI on the seeded e2e hub and counting invites through GET /api/orgs/<org>/invites:

click Invite a teammate after confirming
before 0 → 1
after 0 → 0 0 → 1
before — one click, live invite, dead-end toast after — confirm first
before after

The handoff phase, in a context with no clipboard — the link is on the page regardless:

after link

What was run

  • npm run e2e205 passed, 1 pre-existing skip. admin.spec.ts's invite spec rewritten (asserts the count is unchanged while the dialog is open, then that the visible .modal-url carries /join/ and ?p=<pid>, then that a double-clicked Copy still leaves exactly one invite), plus three new cases: Cancel mints nothing, a synchronous double-click on Create mints one, and a permissions: [] context still shows the link.
    • One flaky failure in org admin: members with roles… while go test ./... was saturating the machine; it passes 4/4 since, and on origin/main too. Untouched by this branch.
  • go test ./... — all packages pass.
  • go vet ./..., go build ./... — clean.
  • npm test — 115 frontend unit tests pass.
  • npm run build re-committed internal/webapp/static; frontend/check-dist.sh reports fresh.

Architecture changes

architecture/webapp-frontend.md: the components class gains InviteDialog, and one note records the seam — that it is ShareDialog's shape moved earlier, that the ?p= form of the link exists only there, and why expiry sits pre-mint. ProjectSettings' click handler changes from "POST + copy + toast" to "open the dialog". No other diagram is in scope; nothing in internal/webapp's Go types moved.

flowchart TB
    components["<div style='text-align:left'><b>components</b> (excerpt)<br/>Insights &nbsp; ShareDialog &nbsp; NewProjectDialog<br/>OrgAdmin &nbsp; HubSettings &nbsp; ProjectSettings<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ InviteDialog</span></div>"]
    InviteDialog["<div style='text-align:left'><b>InviteDialog</b><br/>props: orgId, orgName, projectId, projectName<br/>phase 1: names the grant + expiry &lt;select&gt;<br/>phase 1: Cancel ⇒ no POST<br/>phase 2: the &quot;?p=&quot; URL in .modal-url<br/>phase 2: Copy link / Open / Done</div>"]
    ProjectSettings["<div style='text-align:left'><b>ProjectSettings</b><br/>#ps-invite, org?.role === &quot;owner&quot;<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ onClick ⇒ setInviting(true)</span><br/><span style='background:#ef444455;padding:0 4px;border-radius:3px'>❌ <s>onClick ⇒ postJSON + copyText + toast</s></span></div>"]
    ShareDialog["<div style='text-align:left'><b>ShareDialog</b><br/>the shape InviteDialog follows<br/>(expiry stays POST-mint here)</div>"]
    Note["InviteDialog is ShareDialog's two-phase shape,<br/>moved one step EARLIER: nothing is minted<br/>until the user confirms.<br/>The &quot;?p=&lt;project-id&gt;&quot; form exists ONLY here —<br/>the stored invite OrgAdmin lists is org-scoped,<br/>so the retired copy-failure toast pointed at a<br/>link that had silently lost its project.<br/>Expiry is pre-mint (opposite of ShareDialog):<br/>CreateInvite always mints a fresh token, so<br/>there is no already-copied link to keep valid."]
    components --> InviteDialog
    components --> ProjectSettings
    components --> ShareDialog
    ProjectSettings -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ opens on confirm-first click</span>" --> InviteDialog
    InviteDialog -. modelled on .-> ShareDialog
    InviteDialog -.- Note
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class InviteDialog added
    class Note noteBox
    linkStyle 3 stroke:#22c55e,stroke-width:2px
Loading

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

Deviations from the reviewed plan

One, and it's cosmetic: the plan had the post-mint Copy button toast "Select and copy the link above" on failure. It still does — but the button label also reverts to "Copy link" rather than claiming "Copied ✓", which the plan didn't specify. Everything else follows the plan as written.

Not in this PR

OrgInvite gaining a project, so a link re-copied from Organization settings keeps its ?p=. That is a schema migration across both MetaStore backends and the spec ruled it out; the dialog is now the one place the project-scoped link is handed over. Worth its own issue if the recovery path matters.

Build session

cd $(git worktree list | grep bea-192 | awk '{print $1}') && claude --resume e4d554eb-a75a-45f2-8084-41103862ab0f

(Only works on the machine this ran on.)

ssowonny and others added 3 commits September 5, 2026 19:10
… (BEA-192)

One click on "Invite a teammate" in Project settings minted a live,
redeemable org invite and then showed it nowhere on the page. On the
copy-failure branch the toast pointed at Organization settings — which
lists the ORG-scoped invite, without the "?p=" that is the whole feature,
so following the advice silently downgraded the link.

Replace the one-click mint with the two-phase dialog ShareDialog already
uses, moved a step earlier: phase 1 names what the link grants and offers
an expiry, and nothing is POSTed until the user confirms; phase 2 renders
the project-scoped URL on the page with Copy / Open / Done. A failed copy
now only changes a button label — the link never depended on the clipboard.

The "copy it from Organization settings" promise is retired rather than
repaired: making the Org list carry "?p=" needs the project persisted on
the invite record, which the spec puts out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e (BEA-192)

disabled={busy} only lands after a repaint, so two clicks dispatched in one
task both ran mint() — verified: the new e2e case sees 2 invites without the
latch and 1 with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant