mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
82e23f2382
commit
fc5f7e8ddf
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user