From fc5f7e8ddfca55ba80dfa1632e1125bd503ed124 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Mon, 24 Aug 2026 09:16:54 -0700 Subject: [PATCH] feat(webapp): an invite link can name the project it was sent about (BEA-170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An org owner minted /join/, 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 — //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=, 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/ 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/" 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 /

/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) --- CLAUDE.md | 2 +- internal/webapp/authlocal.go | 6 + internal/webapp/frontend/e2e/admin.spec.ts | 74 +++++++++++ internal/webapp/frontend/src/apps/HubApp.tsx | 24 +++- .../src/components/ProjectSettings.tsx | 34 ++++- internal/webapp/frontend/src/hooks/useHub.ts | 20 ++- internal/webapp/sec_frontend2_test.go | 68 ++++++++++ .../webapp/static/assets/index-D4PhKgpJ.js | 122 ------------------ .../webapp/static/assets/index-DC3fmGe9.js | 122 ++++++++++++++++++ internal/webapp/static/index.html | 2 +- .../docs/self-hosting/authentication.md | 7 + 11 files changed, 352 insertions(+), 129 deletions(-) delete mode 100644 internal/webapp/static/assets/index-D4PhKgpJ.js create mode 100644 internal/webapp/static/assets/index-DC3fmGe9.js diff --git a/CLAUDE.md b/CLAUDE.md index 9f8251a..a50b025 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Package roles (`internal/`): - **`daemon`** — per-mount background loop (detached process, `daemon.pid`/`daemon.log`/`daemon.lock` in the mount's volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `.bdrive/config.json` each tick; if it vanishes (folder moved/renamed/deleted) the daemon **exits cleanly without propagating deletes** — the next bdrive command at the new location resumes it (self-heal on next touch). **Liveness is the flock on `daemon.lock`, never the pidfile**: the pidfile outlives its process (it lives in `$BDRIVE_HOME`, which survives reboots), so a recycled pid used to read as a live daemon — making `status` lie and `Start` a silent no-op, which broke the one documented recovery. The kernel drops the flock when the holder dies, including at reboot and on a crash, and holding the lock also makes two daemons on one mount impossible (two writers of one journal). **The pid `stop` signals is written inside the lock file and cleared with it** — `daemon.pid` is display-only, since nothing binds its contents to the lock holder and a `kill -9`'d daemon leaves it behind for a recycled pid to inherit. A mid-run change to the folder's `remote` is not followed: the daemon exits cleanly and the next bdrive command in the folder starts one for whatever the config then says. - **`autostart`** — the login registration that undoes a reboot: one unit per machine, user-level, running `bdrive resume` — macOS `~/Library/LaunchAgents/ai.beardrive.daemon.plist` (`RunAtLoad`, deliberately no `KeepAlive` since the job exits); Linux `$XDG_CONFIG_HOME/systemd/user/beardrive.service` (`Type=oneshot`, no `Restart=`) plus the `default.target.wants` symlink that `systemctl --user enable` would create, since systemd ignores a unit nothing wants. Linux also requires systemd to be booted (`/run/systemd/system`, i.e. `sd_booted`) — otherwise `Install` returns `ErrUnsupported` rather than writing a file nothing would ever read (Alpine/runit, WSL1, slim containers), which starts a daemon for every enrolled, unpaused mount — so mounts added later need no re-registration and `bdrive stop` still means stay stopped. `init` installs it (`--no-autostart` skips). Writing the file is the whole job: no `launchctl` shell-out, so a test or a packaging script can't register a real login item as a side effect, and launchd loads it at the next login anyway. Windows uses a per-user `HKCU\...\CurrentVersion\Run` value (`golang.org/x/sys/windows/registry`) — no admin, no COM (a Startup `.lnk` would need it), no `schtasks`, and it shows up in Task Manager's Startup tab where a user can disable it. Its tests exist but have **never been executed** (written and compile-checked from macOS); they run the first time the suite runs on Windows. **`GOOS=windows go build ./...` does not pass yet** and this package is not the reason: `internal/store`'s `Lock` uses `syscall.Flock` and `internal/daemon` uses `syscall.Kill`/`Setsid`, all unix-only — a Windows port means `LockFileEx` and a kill story for a platform with no SIGTERM, which is a separate change against the sync invariants. `autostart_other.go` (`!darwin && !linux && !windows`) covers the BSDs with `ErrUnsupported`; shared bits (`writeIfDifferent`, `selfPath`) live in the tag-free `autostart.go`. - **`config`** — global state under `$BDRIVE_HOME` (default `~/.bdrive`): device identity (`device.json`), settings (`settings.json`: default server + device token + signed-in account), and the mount registry (`mounts.json`, keyed by **stable mount id**, holding only each mount's last-known path). The per-folder `.bdrive/` directory (`project.go`) holds `config.json` with the mount id + volume/remote/include; **nothing is keyed by the folder path**, so renames/moves are free — `ResolveMount` self-heals the registry path, and the volume store lives at `~/.bdrive/volumes//`. `.bdrive/` is never synced and holds no credentials. -- **`webapp`** — the `bdrive serve` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `//` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p//store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. That proxy gzips what it answers and inflates what it is sent — the inflate sits ABOVE `spool` (the sha, the op count and the billed size are all properties of the plaintext) and is bounded by `maxInflatedPut`, since a compressed body severs the one-wire-byte-one-disk-byte relationship that made `spool` safe unbounded; `RecordUsage` charges uncompressed, `RecordEgress` compressed. Frontend is a React + TypeScript app (`webapp/frontend/`, Vite + Tailwind v4 + shadcn/ui — Radix-based components copied into `src/components/ui`, themed from the BearDrive tokens in `src/tw.css`; TanStack query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react) whose **built output is committed** at `webapp/static/` — the `go:embed static` target — so plain `go build` needs no Node; after any `frontend/src` change run `npm run build` there and commit the new `static/` (`frontend/check-dist.sh` verifies freshness; e2e suite: `npm run e2e` — Playwright against the seeded harness in `e2e_serve_test.go`, port 8993). It learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`//` in hub mode, `/` in volume mode, `/join/` for invites — no `#`, slashes stay literal). **Every user-facing page owns a URL path**: new surfaces are view routes (`//{dashboard|history|install|settings}[/]`, `VIEW_ROUTES` in `router.ts`; renamed segments live in `LEGACY_VIEWS` and are normalized away on arrival) so deep links, reload, and back/forward always work — never URL-less panel state (the org/hub admin panels are the legacy exceptions; don't add more) implemented by the in-repo synchronous router `frontend/src/nav.ts` + `router.ts` (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed `assets/*` are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution; the shell also carries `nosniff` + `frame-ancestors 'none'`, and `immutable` is set only after an asset is found (a miss under `assets/` must not pin the shell in a shared cache for a year). Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the `dangerouslySetInnerHTML` subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//heat?prefix=&days=` returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response, with one stated exception: `?by=device` reports agent **device** ids, which History already shows every project member. That exception holds only because ingest validates the id — `POST /api/p//reads` records a read as an agent actor only when the reported `X-Bdrive-Device` is shaped like a device id at all and no OTHER account has been seen syncing under it (`devices.go:ownsDevice` → `DeviceRegistry.MayActAs`); otherwise the report is accepted and counted for nobody. That route never registers a device: **only `/store/*` traffic does**, and it registers per `(account, id)`, so naming someone else's id claims nothing and cannot lock its real owner out. Emails and share tokens never leave the server in any shape. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings and a per-project Dashboard quadrant (reads × staleness, route `//dashboard`) — both visible to every project member, since `/heat` is membership-gated and identity-free. **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`, `ReadLedger`) keep their in-memory maps + logic and persist each change as one record through a repo (the `ReadRepo` alone is batch-oriented — one flush, one write). Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend. +- **`webapp`** — the `bdrive serve` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `//` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/`, optionally carrying `?p=` — minted from a project's Settings — to land the joiner on that project's `//install` page instead of a nameless project list; the id is trusted only after it resolves in the joiner's own `/api/projects`, so an unresolvable one falls back to `/` and never builds a URL from raw input), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p//store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. That proxy gzips what it answers and inflates what it is sent — the inflate sits ABOVE `spool` (the sha, the op count and the billed size are all properties of the plaintext) and is bounded by `maxInflatedPut`, since a compressed body severs the one-wire-byte-one-disk-byte relationship that made `spool` safe unbounded; `RecordUsage` charges uncompressed, `RecordEgress` compressed. Frontend is a React + TypeScript app (`webapp/frontend/`, Vite + Tailwind v4 + shadcn/ui — Radix-based components copied into `src/components/ui`, themed from the BearDrive tokens in `src/tw.css`; TanStack query/table/virtual, react-hook-form + zod, cmdk, sonner, lucide-react) whose **built output is committed** at `webapp/static/` — the `go:embed static` target — so plain `go build` needs no Node; after any `frontend/src` change run `npm run build` there and commit the new `static/` (`frontend/check-dist.sh` verifies freshness; e2e suite: `npm run e2e` — Playwright against the seeded harness in `e2e_serve_test.go`, port 8993). It learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`//` in hub mode, `/` in volume mode, `/join/` for invites — no `#`, slashes stay literal). **Every user-facing page owns a URL path**: new surfaces are view routes (`//{dashboard|history|install|settings}[/]`, `VIEW_ROUTES` in `router.ts`; renamed segments live in `LEGACY_VIEWS` and are normalized away on arrival) so deep links, reload, and back/forward always work — never URL-less panel state (the org/hub admin panels are the legacy exceptions; don't add more) implemented by the in-repo synchronous router `frontend/src/nav.ts` + `router.ts` (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed `assets/*` are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution; the shell also carries `nosniff` + `frame-ancestors 'none'`, and `immutable` is set only after an asset is found (a miss under `assets/` must not pin the shell in a shared cache for a year). Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the `dangerouslySetInnerHTML` subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//heat?prefix=&days=` returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response, with one stated exception: `?by=device` reports agent **device** ids, which History already shows every project member. That exception holds only because ingest validates the id — `POST /api/p//reads` records a read as an agent actor only when the reported `X-Bdrive-Device` is shaped like a device id at all and no OTHER account has been seen syncing under it (`devices.go:ownsDevice` → `DeviceRegistry.MayActAs`); otherwise the report is accepted and counted for nobody. That route never registers a device: **only `/store/*` traffic does**, and it registers per `(account, id)`, so naming someone else's id claims nothing and cannot lock its real owner out. Emails and share tokens never leave the server in any shape. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings and a per-project Dashboard quadrant (reads × staleness, route `//dashboard`) — both visible to every project member, since `/heat` is membership-gated and identity-free. **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`, `ReadLedger`) keep their in-memory maps + logic and persist each change as one record through a repo (the `ReadRepo` alone is batch-oriented — one flush, one write). Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend. `cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `scope`, `forget`, `status`, `log`, `share`, `export`, `import`, `url`, `hooks`, `resume`, `autostart`, `read-log`, `serve`, `whoami`, `daemon`, `version` — `mnt`/`umnt`/`remote` are gone; `init` is the front door and `stop` pauses). `export`/`import` (`migrate.go`) move a whole project between hubs with full fidelity: the archive is the remote store layout (all devices' journals + all blobs) in a tar.gz, streamed through the existing `remote.Backend` — no server-side support needed, so it works against any hub in either direction (the anti-lock-in story for cloud-hesitant users). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`; `bdrive logout` revokes this device's token on the hub (`DELETE /api/auth/token`, authenticated by the token itself) and then clears the saved token+account, keeping the remembered server unless `--forget`; a revocation it could not reach the hub for is reported, never swallowed. Switching hubs is `bdrive login ` then re-`init` — `init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs only-some-subfolders) with full flag bypass (`--name/--project/--only/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, registers agent sync hooks in each platform's USER config (`~/.claude/settings.json` and friends — once per machine, never inside a project: platforms read hook config only from the directory a session starts in, so a per-project file covers only sessions that start there and, living in a mount, would sync to the team; `Install` also migrates away hooks older versions wrote into projects), and starts sync via `startSync`; re-running it resumes — including after a folder move. **A mount is always exactly the folder named** — there is no re-rooting flag. Syncing only part of a mount is `--only wiki,docs`, which writes a bdrive-managed block of `.bdriveignore` negation rules (`cmd/bdrive/scopefile.go`; `bdrive scope add/rm` edits the same block) rather than a second scope mechanism: the old `Include` list in `config.json` is legacy — still honored, never written. Because the rules live in the synced `.bdriveignore`, scope is team-wide, which is why `sync --prune` refuses when `!` rules are present (it would strip everything outside the scope from the hub for everyone; `bdrive forget ` is the per-path tool). `init` also refuses a second folder for a project this device already syncs — one device writes one journal per project, so two mounts would overwrite each other's ops. `bdrive serve -c config.json` configures the server from a file, explicit flags winning. diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index e0f9cd9..3fb0e7f 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -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/?p=", 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/" cuts to "/wiki/note.md" and + // still fails the prefix. + next, _, _ = strings.Cut(next, "?") if !strings.HasPrefix(next, marker) { return "" } diff --git a/internal/webapp/frontend/e2e/admin.spec.ts b/internal/webapp/frontend/e2e/admin.spec.ts index 5531a06..4ba8075 100644 --- a/internal/webapp/frontend/e2e/admin.spec.ts +++ b/internal/webapp/frontend/e2e/admin.spec.ts @@ -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=", 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}`); +}); diff --git a/internal/webapp/frontend/src/apps/HubApp.tsx b/internal/webapp/frontend/src/apps/HubApp.tsx index 53969cd..019b139 100644 --- a/internal/webapp/frontend/src/apps/HubApp.tsx +++ b/internal/webapp/frontend/src/apps/HubApp.tsx @@ -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/?p=": 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 }); }} /> ); diff --git a/internal/webapp/frontend/src/components/ProjectSettings.tsx b/internal/webapp/frontend/src/components/ProjectSettings.tsx index 9f1042e..eac0ba0 100644 --- a/internal/webapp/frontend/src/components/ProjectSettings.tsx +++ b/internal/webapp/frontend/src/components/ProjectSettings.tsx @@ -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 }) { + {/* 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" && ( +

+ Not in {org.name} yet? + +

+ )}

Everyone in {org?.name || "this workspace"} can