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

@ -229,14 +229,30 @@ export default {
},
profile: {
ownNote: "Das ist dein Profil — Besucher:innen sehen nur Inhalte, die du als öffentlich markiert hast.",
privateNote: "Dein Profil ist auf Privat gestellt. Besucher:innen sehen 404; öffentliche Inhalte sind weiterhin per direkter URL erreichbar, aber du kannst nicht gefolgt werden.",
privateNote: "Dein Profil ist auf Privat (gesperrt) gestellt. Besucher:innen sehen eine Hinweisseite mit einem Anfrage-Button; nur akzeptierte Follower sehen deine öffentlichen Inhalte.",
goToSettings: "Zu den Einstellungen",
noPublicRoutes: "Noch keine öffentlichen Routen.",
noPublicActivities: "Noch keine öffentlichen Aktivitäten.",
lockedBadge: "Privat",
lockedTitle: "Dieses Profil ist privat. Folge-Anfragen müssen genehmigt werden.",
privateStub: {
heading: "Dieses Profil ist privat",
bodyAuth: "Sende eine Folge-Anfrage, um die öffentlichen Routen und Aktivitäten zu sehen. Die Person muss sie freigeben.",
bodyAnon: "Melde dich an und sende eine Folge-Anfrage, um die öffentlichen Routen und Aktivitäten zu sehen.",
},
},
social: {
follow: "Folgen",
unfollow: "Entfolgen",
requestToFollow: "Folgen anfragen",
pendingCancel: "Angefragt",
requests: {
title: "Folge-Anfragen",
empty: "Keine offenen Folge-Anfragen.",
approve: "Annehmen",
reject: "Ablehnen",
receivedAt: "Angefragt am {{date}}",
},
followers: {
label: "Follower",
heading: "Follower von {{user}}",
@ -290,9 +306,9 @@ export default {
visibility: {
label: "Profil-Sichtbarkeit",
public: "Öffentlich",
publicHelp: "Deine Profilseite ist für alle sichtbar (sobald du öffentliche Inhalte hast). Du kannst gefolgt werden.",
private: "Privat",
privateHelp: "Deine Profilseite gibt 404 zurück. Öffentliche Inhalte sind weiterhin per direkter URL erreichbar, aber du kannst nicht gefolgt werden.",
publicHelp: "Alle sehen dein Profil und deine öffentlichen Inhalte. Folge-Anfragen werden automatisch akzeptiert.",
private: "Privat (gesperrt)",
privateHelp: "Besucher:innen sehen nur eine Hinweisseite mit Anfrage-Button. Nur akzeptierte Follower sehen deine öffentlichen Inhalte. Anfragen verwaltest du unter /follows/requests.",
},
},
security: {

View file

@ -229,14 +229,30 @@ export default {
},
profile: {
ownNote: "This is your profile — visitors see only what you've marked public.",
privateNote: "Your profile is set to private. Visitors see a 404; public posts are still reachable by direct URL but you can't be followed.",
privateNote: "Your profile is set to private (locked). Visitors see a stub with a Request-to-follow button; only accepted followers see your public content.",
goToSettings: "Go to settings",
noPublicRoutes: "No public routes yet.",
noPublicActivities: "No public activities yet.",
lockedBadge: "Private",
lockedTitle: "This profile is private. Follow requests need approval.",
privateStub: {
heading: "This profile is private",
bodyAuth: "Send a follow request to see their public routes and activities. They'll need to approve it.",
bodyAnon: "Sign in and request to follow them to see their public routes and activities.",
},
},
social: {
follow: "Follow",
unfollow: "Unfollow",
requestToFollow: "Request to follow",
pendingCancel: "Requested",
requests: {
title: "Follow requests",
empty: "No pending follow requests.",
approve: "Approve",
reject: "Reject",
receivedAt: "Requested {{date}}",
},
followers: {
label: "Followers",
heading: "Followers of {{user}}",
@ -290,9 +306,9 @@ export default {
visibility: {
label: "Profile visibility",
public: "Public",
publicHelp: "Your profile page is visible to anyone (when you have any public content). You can be followed.",
private: "Private",
privateHelp: "Your profile page returns 404. Public posts are still reachable by direct URL but you can't be followed.",
publicHelp: "Anyone can view your profile and your public content. Follows auto-accept.",
private: "Private (locked)",
privateHelp: "Visitors see a stub with a Request-to-follow button. Only accepted followers see your public content. You manage requests at /follows/requests.",
},
},
security: {