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
+1 -1
View File
File diff suppressed because one or more lines are too long
+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 ""
}
@@ -181,3 +181,77 @@ test("members table sorts by email", async ({ page }) => {
const after = await emails.allTextContents();
expect([...before].reverse()).toEqual(after);
});
// Project-scoped invites: the link an owner mints from a project's Settings
// carries "?p=<project-id>", so the newcomer lands on that project's install
// page — paste prompt already naming the project — instead of a nameless
// project list. An unresolvable p must fall back to "/", never to the
// "Project not found" page.
// Mints an org invite through the API and returns its bare token.
async function mintInvite(page: import("@playwright/test").Page, orgId: string) {
const out = await (await page.request.post(`/api/orgs/${orgId}/invites`)).json();
return out.url.split("/join/")[1];
}
async function defaultOrgId(page: import("@playwright/test").Page) {
const out = await (await page.request.get("/api/orgs")).json();
return out.orgs.find((o: { name: string }) => o.name === "default").id;
}
test("project settings: an owner mints an invite link scoped to this project", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
await page.click("#ps-invite");
await expectToast(page, "Invite link copied");
const link = await page.evaluate(() => navigator.clipboard.readText());
expect(link).toContain("/join/");
expect(link).toContain(`?p=${pid}`);
// Leave the hub as we found it: the suite shares one hub per run.
const tok = link.split("/join/")[1].split("?")[0];
await page.request.delete(`/api/orgs/${await defaultOrgId(page)}/invites/${tok}`);
});
test("project settings: a non-owner is offered no invite button", async ({ page }) => {
await login(page, MEMBER);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
// The card itself renders — it is only the mint control that is owners-only.
await expect(page.locator(".ps-people")).toBeVisible();
await expect(page.locator("#ps-invite")).toHaveCount(0);
});
test("a ?p= invite lands the joiner on that project's install page", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
const orgId = await defaultOrgId(page);
const tok = await mintInvite(page, orgId);
// Accepting as an existing owner is safe: AddMember never downgrades one.
await page.goto(`/join/${tok}?p=${pid}`);
await page.waitForURL(new RegExp(`/${pid}/install$`));
await expect(page.locator(".guide")).toBeVisible();
// The paste prompt names this project, which is the whole point of ?p=.
await expect(page.locator(".gd-code").first()).toContainText(pid);
await page.request.delete(`/api/orgs/${orgId}/invites/${tok}`);
});
test("an invite naming a project you cannot see falls back to the home view", async ({ page }) => {
await login(page);
const orgId = await defaultOrgId(page);
const tok = await mintInvite(page, orgId);
await page.goto(`/join/${tok}?p=00000000-0000-0000-0000-000000000000`);
await page.waitForURL(/localhost:8993\/$/);
await expect(page.locator("#sidebar")).toBeVisible();
await expect(page.locator("#content")).not.toContainText("Project not found");
await page.request.delete(`/api/orgs/${orgId}/invites/${tok}`);
});
+22 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { postJSON } from "../api/http";
import type { InviteAccepted, Project, ProjectCreated, ServerConfig } from "../api/types";
import { useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub";
import { useFetchProjects, useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub";
import { decodePath, parseRoute, projectByName, urlForPath, urlForView } from "../router";
import { linkProps, navigate, Redirect, useLocationPath } from "../nav";
import { AppShell, Page, Topbar, VaultHeader, closeSidebarOnMobile } from "../components/shell";
@@ -35,6 +35,15 @@ export default function HubApp({ config }: { config: ServerConfig }) {
return m ? m[1] : null;
}, [loc]);
// "/join/<token>?p=<project-id>": the invite says which project it was sent
// about, so the joiner lands on that project's install page instead of a
// nameless list. Only a hint — it is never trusted, see onDone below.
const joinProject = useMemo(
() => new URLSearchParams(loc.split("?")[1] || "").get("p") || "",
[loc],
);
const fetchProjects = useFetchProjects();
const { data: projects } = useProjects(!joinToken);
const { data: orgs } = useOrgs(!joinToken);
const isAdmin = !!config.auth.admin;
@@ -111,7 +120,18 @@ export default function HubApp({ config }: { config: ServerConfig }) {
onDone={async (orgId) => {
setJoinedOrgId(orgId);
await refresh();
navigate("/", { replace: true });
// Only an id the SERVER just handed back may be pasted into a URL:
// p="/evil.com" would build "//evil.com/install", and navigate's
// pushState throws a SecurityError on a cross-origin target.
// Resolving against the joiner's own live list is the validator —
// no regex needed — and it doubles as the "you cannot see that
// project" answer. Anything unresolvable lands on "/", never on
// the "Project not found" page below: that is right for a typed
// URL and wrong as a new teammate's first screen. A fetch is
// needed because useProjects is disabled while this screen is up.
const list = joinProject ? await fetchProjects().catch(() => null) : null;
const ok = !!list?.projects?.some((p) => p.id === joinProject);
navigate(ok ? "/" + joinProject + "/install" : "/", { replace: true });
}}
/>
);
@@ -3,8 +3,9 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useQueryClient } from "@tanstack/react-query";
import { api } from "../api/http";
import { api, postJSON } from "../api/http";
import { modalConfirm, modalPrompt } from "../modal";
import { copyText } from "../util";
import { toast } from "../toast";
import { useHubRefresh, usePermissions, useShares } from "../hooks/useHub";
import { PROJECT_ICONS, ProjectIcon } from "./shell";
@@ -402,6 +403,37 @@ function People({ project, org }: { project: Project; org: Org | null }) {
</CardHeader>
<Separator />
<CardContent>
{/* Minting the link is gated on the ORG role, not on project.perm:
handleInviteCreate 403s anyone who is not an org owner, so a
project admin who is a plain org member is exactly the account
that would be shown a button that fails. The "?p=" is the whole
feature — the recipient lands on this project's install page
instead of a nameless project list. */}
{org?.role === "owner" && (
<p className="ps-row">
<span>Not in {org.name} yet?</span>
<Button
id="ps-invite"
type="button"
variant="subtle"
onClick={async () => {
try {
const out = await postJSON<{ url: string }>(`/api/orgs/${org.id}/invites`);
const ok = await copyText(out.url + "?p=" + project.id);
toast(
ok
? "Invite link copied it opens this project."
: "Invite created copy it from Organization settings.",
);
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Invite a teammate
</Button>
</p>
)}
<p className="ps-row">
<span>Everyone in {org?.name || "this workspace"} can</span>
<select
+18 -2
View File
@@ -6,16 +6,32 @@ import type { OrgList, PendingList, ProjectList, ProjectPerms, ShareInfo } from
// without a reload, matching the classic app's 30s refresh) and the orgs
// the signed-in account belongs to.
const projectsQuery = {
queryKey: ["projects"],
queryFn: () => getJSON<ProjectList>("/api/projects"),
};
export function useProjects(enabled: boolean) {
return useQuery({
queryKey: ["projects"],
queryFn: () => getJSON<ProjectList>("/api/projects"),
...projectsQuery,
enabled,
refetchInterval: 30_000,
select: (d) => d.projects || [],
});
}
// Fetches the project list NOW, even where useProjects is disabled — which is
// the join screen (useProjects(!joinToken)), the one place that has to answer
// "is this ?p= a project I can see" before navigating. useHubRefresh cannot:
// invalidateQueries never fetches a disabled query, so it would resolve with
// the list still empty and every project-scoped invite would fall back to "/".
// It seeds the same ["projects"] entry, so the observer that mounts a tick
// later already has the data.
export function useFetchProjects() {
const qc = useQueryClient();
return () => qc.fetchQuery(projectsQuery);
}
export function useOrgs(enabled: boolean) {
return useQuery({
queryKey: ["orgs"],
+68
View File
@@ -311,6 +311,12 @@ func TestSec_JoinPage_OnlyALiveInviteUnlocksSignupOnAClosedHub(t *testing.T) {
if !offersSignup(open("%2Fjoin%2F" + live)) {
t.Fatalf("a live invite does not unlock signup — the gate cannot be measured")
}
// A project-scoped invite ("/join/<tok>?p=<project-id>") is the same live
// invite with a landing hint attached. The recipient who most needs the
// form is exactly this one, and the query must not hide the token.
if !offersSignup(open("%2Fjoin%2F" + live + "%3Fp%3D" + p.ID)) {
t.Fatalf("a live project-scoped invite does not unlock signup")
}
for _, next := range []string{
"%2Fjoin%2F" + revoked, // revoked
@@ -381,3 +387,65 @@ func TestSec_JoinPage_AnInviteThatUnlocksSignupIsAlsoRedeemed(t *testing.T) {
}
}
}
/*
BEA-170, the logged-out half of a project-scoped invite. A hub ships with
self-signup CLOSED, so the recipient who most needs the account-creation form
is the one arriving from "/join/<tok>?p=<pid>". Everything after the form is
the query surviving safeNext: the 303 has to carry "?p=" back to the join
route, or the newcomer creates an account and lands nowhere in particular —
which is the dead end the whole feature exists to remove.
*/
func TestJoin_ProjectScopedInvite_CarriesTheLoggedOutFlowEndToEnd(t *testing.T) {
h, srv, c, p := permHub(t)
auth, ok := srv.Auth.(*BuiltinAuth)
if !ok {
t.Fatal("fixture has no BuiltinAuth")
}
auth.AllowSignup = false
auth.InviteValid = srv.Dir.ValidInvite
tok := sec12invite(t, h, p.Org, c["alice"])
next := "/join/" + tok + "?p=" + p.ID
// 1. The form is offered at all.
req := httptest.NewRequest("GET", "/auth/signup?next="+url.QueryEscape(next), nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Body.String(), `action="/auth/signup`) {
t.Fatalf("no signup form for a project-scoped invite on a closed hub: %d", rec.Code)
}
// 2. Submitting it activates the account and sends the browser BACK to
// the join route with ?p= intact.
form := url.Values{
"email": {"newbie@x.io"}, "name": {"Newbie"}, "password": {"password1"},
"next": {next},
}
req = httptest.NewRequest("POST", "/auth/signup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("signup did not complete: %d %s", rec.Code, rec.Body)
}
if got := rec.Header().Get("Location"); got != next {
t.Fatalf("post-signup redirect dropped the project hint: got %q want %q", got, next)
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("signup returned 303 with no session cookie")
}
// 3. The join route itself serves the SPA shell, which redeems and then
// lands on /<p>/install. Nothing here 404s or bounces to login.
req = httptest.NewRequest("GET", next, nil)
for _, ck := range cookies {
req.AddCookie(ck)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("join route with ?p= is not the app shell: %d %s", rec.Code, rec.Body)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-D4PhKgpJ.js"></script>
<script type="module" crossorigin src="/assets/index-DC3fmGe9.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
<link rel="stylesheet" crossorigin href="/assets/index-L-I4D1mx.css">
@@ -20,6 +20,13 @@ This is the safe posture for a hub on a public URL. New people get in only
through an expiring invite link an owner mints; the link lets them create an
account — bypassing the gates below — and join, in one step.
An owner who mints the link from a project's **Settings → People** ("Invite a
teammate") gets one scoped to that project: the newcomer lands straight on that
project's install page, with the agent paste prompt already naming this hub and
this project, instead of on a list of projects with nothing saying which one
they were invited for. A link minted from Organization settings still joins the
org and lands on the normal home view.
To allow self-service signup instead, set `"allow_signup": true` **with a gate**.
The server refuses to start an open hub that has none, so a fake email can never
just walk in.