Locked-account profiles: private = stub + Pending follow flow

Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.

Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
  remain 'public' (backfill on first migration handled them).

Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
  targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
  approveFollowRequest, rejectFollowRequest. Approve/reject are
  owner-bound: only the followed user can act on their own incoming
  requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
  filter to accepted-only relations.

Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
  profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
  copy + Request-to-follow / sign-in CTA. Routes/activities sections
  are not rendered.

UI:
- FollowButton gains a "Request to follow" / "Requested" state for
  private targets via a new isPrivateTarget prop. Cancel-request reuses
  the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
  Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
  pending > 0.

Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).

Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
  (public, private+anon, private+pending, private+accepted) with
  scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
  manual accept, approve/reject endpoints, pending request management,
  Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
  default-private; non-goal "locked-local-accounts as a follow-up" is
  removed since this change ships it.

Tests:
- follow.integration.test.ts: pending-against-private, approve flips
  to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
  flow, plus stub-for-anonymous and /follows/requests auth gate.

Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-25 23:38:26 +02:00
parent ede68712a3
commit 5da7ffa037
17 changed files with 656 additions and 181 deletions

View file

@ -26,6 +26,17 @@ async function registerUser(page: Page, email: string, username: string) {
await expect(page).toHaveURL("/", { timeout: 10000 });
}
async function setProfileVisibility(page: Page, value: "public" | "private") {
await page.goto("/settings");
if (value === "public") {
await page.getByLabel("Public").check();
} else {
await page.getByLabel(/Private/).check();
}
await page.getByRole("button", { name: /^Save$/ }).first().click();
await page.waitForLoadState("networkidle");
}
// WebAuthn + parallel workers + shared local Postgres race; serialize.
test.describe.configure({ mode: "serial" });
@ -35,6 +46,11 @@ test.describe("Social follows + /feed", () => {
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
});
test("/follows/requests redirects anonymous visitors to login", async ({ page }) => {
await page.goto("/follows/requests");
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
});
test("Follow button toggles state on a public profile", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
@ -45,31 +61,17 @@ test.describe("Social follows + /feed", () => {
const bEmail = `social-b-${stamp}@example.com`;
const bUsername = `sb${stamp}`;
// Register A in the main page.
await registerUser(page, aEmail, aUsername);
// Register B in a separate browser context (independent session).
// Register B in a separate browser context so we have two independent
// sessions. New users default to `private` — B flips to public so this
// test exercises the auto-accept path.
const bCtx = await browser.newContext();
const bPage = await bCtx.newPage();
const bCdp = await bPage.context().newCDPSession(bPage);
await setupVirtualAuthenticator(bCdp);
await registerUser(bPage, bEmail, bUsername);
// B's profile alone won't render publicly without any public content;
// that's tested elsewhere. For follow-button transitions, we use the
// user-list page directly (which gates only on profile_visibility).
// Instead, seed a public route from B via the existing routes.new
// flow so B's profile renders.
await bPage.goto("/routes/new");
await bPage.getByLabel("Name").fill("Public ride");
await bPage.getByRole("button", { name: "Create Route" }).click();
await bPage.waitForURL(/\/routes\/[0-9a-f-]+$/, { timeout: 10000 });
const url = bPage.url();
const id = url.split("/").pop()!;
await bPage.goto(`/routes/${id}/edit`);
await bPage.getByLabel("Visibility").selectOption("public");
await bPage.getByRole("button", { name: "Save Changes" }).click();
await bPage.waitForURL(new RegExp(`/routes/${id}$`), { timeout: 10000 });
await setProfileVisibility(bPage, "public");
// A visits B's profile and follows.
await page.goto(`/users/${bUsername}`);
@ -81,57 +83,76 @@ test.describe("Social follows + /feed", () => {
await page.reload();
await expect(page.getByRole("link", { name: /1\s+Followers/i })).toBeVisible();
// Unfollow goes back to Follow.
// Unfollow.
await page.getByRole("button", { name: "Unfollow" }).click();
await expect(page.getByRole("button", { name: "Follow" })).toBeVisible({ timeout: 5000 });
await bCtx.close();
});
test("Profile visibility toggle: private 404s, public restores", async ({ page, browser }) => {
test("Private profile: stub for visitors, Request → Pending → Approve → full view", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const ownerEmail = `vis-${stamp}@example.com`;
const ownerUsername = `vu${stamp}`;
await registerUser(page, ownerEmail, ownerUsername);
const aEmail = `req-a-${stamp}@example.com`;
const aUsername = `ra${stamp}`;
const bEmail = `req-b-${stamp}@example.com`;
const bUsername = `rb${stamp}`;
// Create a public route so the owner's profile would otherwise render.
await page.goto("/routes/new");
await page.getByLabel("Name").fill("Trail run");
await page.getByRole("button", { name: "Create Route" }).click();
await page.waitForURL(/\/routes\/[0-9a-f-]+$/, { timeout: 10000 });
const url = page.url();
const id = url.split("/").pop()!;
await page.goto(`/routes/${id}/edit`);
await page.getByLabel("Visibility").selectOption("public");
await page.getByRole("button", { name: "Save Changes" }).click();
await page.waitForURL(new RegExp(`/routes/${id}$`), { timeout: 10000 });
await registerUser(page, aEmail, aUsername);
const bCtx = await browser.newContext();
const bPage = await bCtx.newPage();
const bCdp = await bPage.context().newCDPSession(bPage);
await setupVirtualAuthenticator(bCdp);
await registerUser(bPage, bEmail, bUsername);
// B stays at the default `private`. Verify by loading the profile
// anonymously and seeing the stub.
// Visitor: profile reachable.
const anonCtx = await browser.newContext();
const anon = await anonCtx.newPage();
const before = await anon.goto(`/users/${ownerUsername}`);
expect(before?.status()).toBe(200);
const anonResp = await anon.goto(`/users/${bUsername}`);
expect(anonResp?.status()).toBe(200);
await expect(anon.getByText(/This profile is private/i)).toBeVisible();
// Owner flips to private.
await page.goto("/settings");
await page.getByLabel("Private").check();
await page.getByRole("button", { name: /^Save$/ }).first().click();
await page.waitForLoadState("networkidle");
// A (signed in) visits B's private profile — sees stub + Request button.
await page.goto(`/users/${bUsername}`);
await expect(page.getByText(/This profile is private/i)).toBeVisible();
await expect(page.getByRole("button", { name: /Request to follow/i })).toBeVisible();
await page.getByRole("button", { name: /Request to follow/i }).click();
await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 });
const after = await anon.goto(`/users/${ownerUsername}`);
expect(after?.status()).toBe(404);
// B sees the request in /follows/requests with badge in nav.
await bPage.goto("/follows/requests");
await expect(bPage.getByText(`@${aUsername}`)).toBeVisible();
await bPage.getByRole("button", { name: "Approve" }).click();
await bPage.waitForLoadState("networkidle");
// Empty state after approval.
await expect(bPage.getByText(/No pending follow requests/i)).toBeVisible();
// Flip back to public.
await page.goto("/settings");
await page.getByLabel("Public").check();
await page.getByRole("button", { name: /^Save$/ }).first().click();
await page.waitForLoadState("networkidle");
// A reloads B's profile — now sees full content (no stub) + Unfollow.
await page.goto(`/users/${bUsername}`);
await expect(page.getByText(/This profile is private/i)).not.toBeVisible();
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible();
const restored = await anon.goto(`/users/${ownerUsername}`);
expect(restored?.status()).toBe(200);
await bCtx.close();
await anonCtx.close();
});
test("Private profile: visitor sees stub layout (200, not 404)", async ({ page, browser }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const username = `priv${stamp}`;
await registerUser(page, `priv-${stamp}@example.com`, username);
// User stays at default `private`.
const anonCtx = await browser.newContext();
const anon = await anonCtx.newPage();
const resp = await anon.goto(`/users/${username}`);
expect(resp?.status()).toBe(200);
await expect(anon.getByText(/This profile is private/i)).toBeVisible();
await anonCtx.close();
});