From 060fa408a202c559b5ce4a6d0a50603660b042a6 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 24 May 2026 23:40:49 -0700 Subject: [PATCH] =?UTF-8?q?Slice=207:=20=C2=A714=20chrome=20+=20settings?= =?UTF-8?q?=20and=20admin=20neighborhoods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §14.1 richer landing, §14.2 /philosophy route (disk-backed), §14.3 persistent About link. /settings/notifications surfaces Slice 6's preferences/quiet-hours/mute/watches endpoints. /admin home base consolidates role management, the §6.2 write-mute, the audit-log viewer, the permission-events log, and the §13.2 graduation queue. Backend: backend/app/philosophy.py, backend/app/api_admin.py (seven admin endpoints + user-search), GET /api/users/me/notification-mutes. Frontend: Landing.jsx (deck), Philosophy.jsx, NotificationSettings.jsx, Admin.jsx, App.jsx routing for the chrome surfaces. Tests: backend/tests/test_chrome_vertical.py — 13 cases. Full suite 75/75 green. Spec corrections: §14.2 (PHILOSOPHY.md source is a deployment-time decision), §17 (admin block extended to name the seven new endpoints + user-search and notification-mutes read). §19.1 rewritten for Slice 8 hardening; §19.2 grew four candidates (owner succession, mute-from-actor, the "Following since " disclosure, audit-log row prose). Co-Authored-By: Claude Opus 4.7 (1M context) --- SPEC.md | 253 ++++++--- backend/app/api.py | 29 +- backend/app/api_admin.py | 397 ++++++++++++++ backend/app/api_notifications.py | 30 ++ backend/app/philosophy.py | 66 +++ backend/tests/test_chrome_vertical.py | 457 ++++++++++++++++ docs/DEV.md | 173 ++++-- frontend/src/App.css | 273 ++++++++++ frontend/src/App.jsx | 96 +++- frontend/src/api.js | 62 +++ frontend/src/components/Admin.jsx | 408 ++++++++++++++ frontend/src/components/Landing.jsx | 66 ++- .../src/components/NotificationSettings.jsx | 509 ++++++++++++++++++ frontend/src/components/Philosophy.jsx | 61 +++ 14 files changed, 2722 insertions(+), 158 deletions(-) create mode 100644 backend/app/api_admin.py create mode 100644 backend/app/philosophy.py create mode 100644 backend/tests/test_chrome_vertical.py create mode 100644 frontend/src/components/Admin.jsx create mode 100644 frontend/src/components/NotificationSettings.jsx create mode 100644 frontend/src/components/Philosophy.jsx diff --git a/SPEC.md b/SPEC.md index 1f3fcc3..e34d4b0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1675,10 +1675,14 @@ novel. ### 14.2 The `/philosophy` route Authenticated and anonymous visitors alike can reach `/philosophy`, -which renders the full body of `PHILOSOPHY.md` from the meta repo. The -content is sourced from the meta repo's main branch, cached and -refreshed on the same cadence as RFC bodies (§4). The page is plain -markdown rendering with no editing affordance. +which renders the full body of `PHILOSOPHY.md`. The content is cached +in the app process and refreshed on demand; the source of the file is +a deployment-time decision — Slice 7's build sources it from the app +repo (the file lives alongside `SPEC.md`, since the philosophy is the +framework's design document rather than an RFC entry), and a +`PHILOSOPHY_PATH` env var can point at a meta-repo working-tree clone +or any other sync target if a deployment prefers that shape. The page +is plain markdown rendering with no editing affordance. ### 14.3 Persistent "About" link @@ -2318,9 +2322,35 @@ The follow-up session will refine this. A minimal starting set: - `POST /api/rfcs//prs//withdraw` — withdraw per §10.8. - `POST /api/rfcs//prs//resolution-branch` — cut a fresh resolution branch and replay per §10.9. -- `POST /api/admin/users//role` — set role (owner/admin only). -- `POST /api/admin/users//mute` — mute/unmute (the §6.2 app-wide - write-mute, not the §15.8 notification mutes). +- `GET /api/admin/users` — list users with role and write-mute state, + for the §6 / Slice 7 admin surface. +- `POST /api/admin/users//role` — set role. Only owners may grant + or revoke `owner`; admins may flip contributor ↔ admin freely. An + owner-self-demotion is refused on this endpoint; owner succession + earns its own ceremony (§19.2). Writes a `permission_events` row. +- `POST /api/admin/users//mute` — set the §6.2 app-wide + write-mute (not the §15.8 notification mutes). Refused on owners + and admins — for them, the role-change channel is the right + refusal. Writes a `permission_events` row. +- `GET /api/admin/audit` — paged read of the `actions` log with + filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id` + for the page boundary. Returns the joined actor login/display so + the surface can render row prose without a second round-trip. +- `GET /api/admin/permission-events` — paged read of + `permission_events` (role changes, write-mute toggles), joined + against `users` for actor and subject. Same `before_id` paging. +- `GET /api/admin/graduation-queue` — the §13.2-ready set: returns + super-drafts partitioned into `ready` (owners set, zero open + body-edit PRs) and `blocked` (one or both preconditions missing), + with the precondition shape carried in each row. +- `GET /api/users/me/notification-mutes` — list the §15.8 per-user + mutes the signed-in user has set, joined against `users` for the + rendered handle and display name. The companion read endpoint to + the add/delete pair. +- `GET /api/users/search` — typeahead over `gitea_login` and + `display_name`, ten-row cap, prefix-first ranking. Powers the + §15.8 mute-add typeahead in `/settings/notifications`. Excludes + the caller. Open to any authenticated viewer. - `POST /api/stars/` — star/unstar. - `POST /api/webhooks/gitea` — webhook receiver. - `GET /api/notifications` — list inbox rows for the signed-in user, @@ -2405,93 +2435,109 @@ surface. With Topic 13 folded in, the structural surface is complete. What follows is no longer "topics that block specifying v1" but "topics to address during or shortly after the v1 build." -### 19.1 Next slice: the §14 chrome and the settings neighborhood +### 19.1 Next slice: hardening -Slice 6 of the build has landed. The §15 notifications surface runs -end-to-end against the local Gitea — every `actions` row whose -`action_kind` maps to a §15.1 event fans out through -`notify.fan_out_from_action`, called inline from `bot._log` and -from the graduation orchestrator's `_audit`. Chat-message inserts -take a parallel path through `notify.fan_out_chat_message` from -inside `chat.append_user_message`, since chat doesn't flow through -the bot wrapper. The §15.6 auto-watch upsert sits in the same -chokepoint — every substantive gesture either creates a `watching` -row or bumps `last_participation_at` for the 90-day decay timer. +Slice 7 of the build has landed. The §14 chrome, the +`/settings/notifications` neighborhood, and the `/admin` home base +all run end-to-end against the local Gitea, and the next slice has +the v1 surface fully wrapped — what remains is the hardening pass +that lets a single-operator deployment actually run. -The §15.4 email loop runs through an SMTP adapter with a stdout -fallback for dev — the in-memory `_SENT` buffer is what the -integration tests read from. The per-category dispatch holds during -§15.8 quiet hours; on window-end, `email.flush_pending` bundles -above the §15.4 threshold into a single "Activity while you were -away" mail. The signed-URL unsubscribe path flips a single category -column to zero; the bounce webhook flips the new `email_opt_out_all` -column (migration `008_email_opt_out.sql`). +The §14.1 landing page now carries the title, the subtitle, the +short-form pitch from `PHILOSOPHY.md`, the sign-in affordance, the +secondary "Read the full philosophy" link, and a three-item deck +underneath the pitch that names what the framework is — one word per +RFC, argued in public with the model, graduation as the load-bearing +moment. The §14.2 `/philosophy` route reads `PHILOSOPHY.md` from +disk (via `backend/app/philosophy.py`, configurable through the +`PHILOSOPHY_PATH` env var) and renders it inline with the existing +`marked` library — the same renderer the proposal preview already +uses. §14.3's persistent About link sits in the header next to the +inbox badge and the new Settings / Admin (admin-only) entries; the +header's visual budget stays tight, and each entry reads as a quiet +text link rather than a button. -The §15.5 digest is a `DigestScheduler` wrapping `cache.Reconciler`'s -shape, with a `run_tick` seam the tests drive synchronously. Each -tick releases held emails, runs the §15.6 90-day decay sweep, and -assembles per-cadence digests where the window has rolled over. -The §15.5 exclusion rules (already-emailed, already-read, -personal-direct-excluded) keep two consecutive ticks idempotent. +The notification-settings surface (`/settings/notifications`, +`frontend/src/components/NotificationSettings.jsx`) lands the five +sub-sections the §15 endpoints already supported: the §15.4 per- +category email toggles (with the `email_watched_churn` toggle +permanently disabled and the §15.4 refusal tooltip inline), the +§15.5 digest cadence dropdown, the §15.8 quiet-hours editor (three +inputs against `Intl.supportedValuesOf('timeZone')`, with all-three- +or-clear validation enforced server-side), the §15.6 watches +overview (per-row state selector that flips `set_by` to `explicit` +on override), and the §15.8 per-user mute list with an unmute +affordance and a typeahead add. Owners and admins see the §15.8 +mute-list with the "cannot mute" prose inline. The §15.4 email +footer's `Manage all preferences` link — wired in Slice 6 — now +resolves to a real surface. -§15.2 / §15.3 / §15.7 / §15.8 surface as fourteen endpoints in -`backend/app/api_notifications.py`, plus the chat-seen advance on -`api_branches` and the existing PR seen-cursor on `api_prs` — both -extended to call `notify.reconcile_seen_advance` so the §15.7 -visit-advances-cursor loop closes back into the inbox-row read -state. The SSE stream holds a per-user subscriber queue keyed by -user_id; multiple browser tabs see the same events. +The admin home base (`/admin`, `frontend/src/components/Admin.jsx`) +runs as a tabbed left-rail with four panels: Users (role management ++ §6.2 write-mute, with the role-grant constraints enforced +server-side per §6.1 — only owners may grant owner; owners cannot +self-demote on the role endpoint), Graduation queue (super-drafts +partitioned by §13.2 readiness — owners set and zero blocking body- +edit PRs), Audit log (paged read of `actions` with filter chips for +`action_kind`, `actor_user_id`, and `rfc_slug`), and Permission +events (paged read of `permission_events` showing the role and +mute history). Every `/api/admin/*` endpoint guards independently +through `require_admin`, and the User-search endpoint (open to all +authenticated viewers) powers both the admin user-roster and the +mute typeahead. -On the frontend, `App.jsx` grew a header badge (cap "99+", -clicking opens the inbox overlay), an SSE-driven counter that -surfaces personal-direct toasts (own-name signals) and live-view -toasts (events landing on the slug the user is viewing). The -inbox is `Inbox.jsx` — three filter chips (Unread only, RFC, -Category), a Bundle toggle, and a "Mark all read (under filter)" -button. `ToastHost.jsx` caps four visible at once with auto-dismiss. +`backend/app/api_admin.py` carries the seven new admin endpoints +plus the user-search. `backend/app/philosophy.py` carries the +disk-backed `/api/philosophy` source. `backend/app/api_notifications.py` +grew one read endpoint (`GET /api/users/me/notification-mutes`) for +the settings page's mute list. The §17 admin block was extended in +this corrected spec to name the seven endpoints; §14.2 was +corrected to acknowledge the deployment-time decision about where +`PHILOSOPHY.md` lives. -The §15.9 attribution rule fell out cleanly: every `notifications` -row carries `actor_user_id` resolved from the `actions.actor_user_id` -in the originating audit row (the underlying user, never the bot). -System-generated events (digest emission, 90-day decay) leave -`actor_user_id` NULL and render as "the app." AI participation -events landed as null-system per §19.2's candidate naming — when a -chat message authored by an AI provider goes through, no actor row -is written, since the LLM call doesn't have a user_id; the topic -folder for "AI participation as a notification source" in §19.2 -remains open for explicit settling. +Slice 7 ships covered by `backend/tests/test_chrome_vertical.py` — +thirteen integration tests covering the philosophy route for both +anonymous and authenticated callers, the §15.4 / §15.5 / §15.8 +preferences round-trip (including the permanent `email_watched_churn` +refusal), the quiet-hours all-or-nothing validation, the §15.8 mute +add/list/unmute round-trip, the user-search typeahead, the admin +role and write-mute round-trips with their `permission_events` +audit, the §6.1 refusal of owner-grant by non-owners, the audit-log +filter chips, the graduation-queue partition under both +preconditions, and the permission-events listing. The full Slices +1–7 test suite is 75/75 green. -Slice 6 ships covered by `backend/tests/test_notifications_vertical.py` -— seventeen integration tests covering the producer-side fan-out -on the propose/merge/decline chain, §15.6 auto-watch, the §15.2 -inbox listing with filter chips, the §15.7 chat-seen reconciler, -the §15.8 per-user mute and the per-RFC mute, the §15.4 email- -bounce webhook flipping the global opt-out, the `/email/unsubscribe` -signed-URL path, the §15.8 quiet-hours email hold, the §15.5 -digest's emit-then-skip behavior across two consecutive ticks, -preferences and quiet-hours round-trips, the explicit-watch -override that prevents auto-downgrade, and the SSE subscriber/ -broadcast substrate. The full Slices 1–6 test suite is 62/62 green. +**Slice 8 is the hardening pass — the last slice of the v1 build.** +Three pieces hang together: -**Slice 7 is the §14 chrome plus the natural notification-settings -neighbor.** With every structural beat live, what remains for v1 -is the chrome the framework wraps itself in. §14 commits the -landing page (the unauthenticated visitor's first read), the -`/philosophy` route (PHILOSOPHY.md surfaced inline), and the -persistent About link in the header. Slice 6 left the §15 -preferences / quiet-hours / mute / watches endpoints in place -but with no chrome — the natural follow-on is `/settings/notifications` -exposing the per-category toggles, the digest cadence dropdown, -the quiet-hours editor, the watches overview, and the per-user -mute list. The §19.2 "admin surfaces" candidate is the second -natural neighbor — role management, the §6.2 app-wide write-mute, -the audit-log viewer, the graduation-readiness queue, all -consolidated where the chrome can hold them. Slice 7 picks the -framing and ships the three pieces together since they share an -information architecture. +The §12 30/90 branch-hygiene timers — the formalized policy that +closes the loop on §11.5's branch lifecycle (open → merged → 30d +read-only → 90d deleted-by-bot, with the per-user-message-cursor +preservation contract). The wiring is a scheduled task next to the +§15.5 digest scheduler; the §10.7 90-day deletion timer Slice 3 +left deferred lives here too. + +An end-to-end smoke pass over the working surfaces — propose → +super-draft → branch → PR → merge → graduate → active-RFC PR → +notification fans out → inbox → email — to catch the integration +seams a per-slice test wouldn't. Plus the §19.2 candidates the +hardening pass is the natural place to fold in: cache bootstrap +from a meta repo (the audit-log-first attribution shape Slice 1 +chose, exercised against a meta repo with history the bot did not +author), branch-name path routing (converting every +`branches/` to `{branch:path}` with route-ordering +discipline), and the small Slice-2-onward follow-ons that are +deferred until the hardening pass demands them. + +The dev/prod deployment shape — the `deploy/` directory already +has the nginx vhost, the systemd unit, and a runbook stub; Slice 8 +proves the bring-up against a fresh host, settles the secret- +material handling (the existing `.env.example` plus the §15.4 +SMTP wiring), and lands the README updates that let a new operator +get from `git clone` to a signed-in browser. The next build session should read `SPEC.md`, `README.md`, -`docs/DEV.md`, and this §19.1 entry and pick up Slice 7 cleanly +`docs/DEV.md`, and this §19.1 entry and pick up Slice 8 cleanly without re-briefing. The working agreement in §19.3 continues to apply: implement the slice, correct the spec only where running code reveals it was wrong at a structural level, accumulate new @@ -2785,6 +2831,45 @@ binding. or signature verification (Sendgrid's signed events, AWS SES's SNS topic signature, etc.). Trivial to add per provider; the routing-and-flip-the-column logic doesn't change. +- **Owner succession ceremony.** Slice 7's `POST /api/admin/users//role` + refuses self-demotion ("Use the explicit succession path to change + your own role") because owner-zero is the only owner bootstrap path + per §6.1 and a careless self-downgrade could orphan the role. The + explicit succession path — how an owner steps down, whether owner- + zero needs a co-owner present, how the `OWNER_GITEA_LOGIN` env var + relates to the seated-owner set after the bootstrap moment — is the + natural follow-on once a real owner-transition scenario shows up. + Touches §6.1 (the owner-role bootstrap rule), §17 (the admin role + endpoint), and possibly §3.1 (state-transition shape if owner + changes are themselves a tracked transition). +- **Mute-from-actor on inbox rows and chat messages.** Slice 7's + notification-settings page exposes the per-user mute list with an + unmute affordance, and an intentionally clumsy typeahead for the + add path. The natural add path — clicking the actor on an inbox + row or a chat message — is the §19.2 candidate of its own this + slice was always going to surface. Touches §15.8 (the mute add + ergonomics), §15.2 (the inbox row's actor slot), and §8.12 (the + chat message's author chip). Small scope, defer-able until the + typeahead-only path proves annoying. +- **The "Following since <date>" disclosure on the RFC view header.** + §15.6 commits this disclosure on the RFC view's header after an + auto-decay. Slice 7 lands the watches overview at + `/settings/notifications` — the centralized read — and defers the + per-RFC chrome to a later session, since the disclosure earns its + surface only once auto-decay has actually fired against a watch in + production use. Touches §15.6 (the decay timer's UX) and §8.1 (the + RFC view's header strip). +- **Audit-log row prose translation.** Slice 7's `/admin → Audit log` + surface renders `action_kind` as the raw enum (`merge_branch_pr`, + `propose_rfc`, `graduate_step_complete`, etc.) inside a `` + span. Admins know the kinds; a contributor-facing surface — if one + ever earns its place — would need a per-kind English render + ("merged the body-edit PR", "proposed RFC", "completed the + repo-create step of graduation"). The translation table lives near + `notify.SUMMARY` per §15.9 — a future session can lift the same + surface to the admin view if usage shows reviewers want it. + Defer-able until evidence of demand. Touches §6.5 (the audit-log + surface) and §17 (the audit endpoint's prose shape). - **Per-user mute exemption checks for arbiters.** §15.8 commits that arbiters cannot mute participants on RFCs where they hold authority. Slice 6's check uses "the muted user has a watches diff --git a/backend/app/api.py b/backend/app/api.py index 1e1b116..cf6ebba 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -17,7 +17,18 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from . import api_branches, api_graduation, api_notifications, api_prs, auth, db, entry as entry_mod, cache +from . import ( + api_admin, + api_branches, + api_graduation, + api_notifications, + api_prs, + auth, + db, + entry as entry_mod, + cache, + philosophy, +) from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError @@ -58,6 +69,22 @@ def make_router( # Slice 6: §15 notifications surface (inbox, watches, prefs, # quiet hours, per-user mute, email unsubscribe, bounce webhook). router.include_router(api_notifications.make_router(config)) + # Slice 7: §14 chrome (/philosophy read endpoint, user search for + # the §15.8 mute typeahead) and the §6/§17 admin surfaces + # (role, write-mute, audit-log, graduation-readiness queue). + router.include_router(api_admin.make_router(config)) + + # --------------------------------------------------------------- + # §14.2: /api/philosophy — PHILOSOPHY.md served verbatim. + # No auth gate; anonymous visitors reach `/philosophy` per §14.1. + # The body is read once at process start and refreshed on demand + # via the reconciler; see backend/app/philosophy.py. + # --------------------------------------------------------------- + + @router.get("/api/philosophy") + async def get_philosophy() -> dict[str, Any]: + payload = philosophy.load() + return {"body": payload["body"]} # --------------------------------------------------------------- # Auth surface — extends the prototype's pattern but reads role diff --git a/backend/app/api_admin.py b/backend/app/api_admin.py new file mode 100644 index 0000000..89d9224 --- /dev/null +++ b/backend/app/api_admin.py @@ -0,0 +1,397 @@ +"""§17 admin endpoints + Slice 7 user search. + +The admin's repertoire grew across the prior slices without earning a +centralized home: role grants (§6.1), the §6.2 app-wide write-mute, the +§6.5 / §5 audit logs, and the §13 graduation-readiness queue. Slice 7 +consolidates them behind `/api/admin/*` so the chrome can hold them in +one tabbed surface (`Admin.jsx`). + +The endpoints in this module: + + - `GET /api/admin/users` — list users with role + mute + - `POST /api/admin/users//role` — set role per §6.1 + - `POST /api/admin/users//mute` — set the §6.2 write-mute + - `GET /api/admin/audit` — paged `actions` log + - `GET /api/admin/permission-events` — paged `permission_events` log + - `GET /api/admin/graduation-queue` — super-drafts ready to graduate + - `GET /api/users/search?q=…` — typeahead for §15.8 mute add + +Permission gates: every `/api/admin/*` endpoint requires +`require_admin` (owner or admin); the role-change endpoint additionally +refuses any non-owner caller granting `owner`, since owner-zero is the +only owner bootstrap path per §6.1 and a downgrade from owner needs the +sitting owner's hand. The user-search endpoint is open to any +authenticated viewer — it powers the §15.8 mute typeahead and contains +no privileged information beyond what every authenticated viewer +already sees in the catalog and chat-author surfaces. +""" +from __future__ import annotations + +import json +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from . import auth, db +from .config import Config + + +# --------------------------------------------------------------------------- +# Pydantic bodies +# --------------------------------------------------------------------------- + + +class RoleBody(BaseModel): + role: str = Field(pattern="^(owner|admin|contributor)$") + + +class MuteBody(BaseModel): + muted: bool + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + + +def make_router(config: Config) -> APIRouter: + del config # unused for now; reserved for future per-deployment knobs + router = APIRouter() + + # ----- User listing ----- + + @router.get("/api/admin/users") + async def list_users(request: Request) -> dict[str, Any]: + auth.require_admin(request) + rows = db.conn().execute( + """ + SELECT id, gitea_login, display_name, email, role, muted, + created_at, last_seen_at + FROM users + ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE + """ + ).fetchall() + return { + "items": [ + { + "id": r["id"], + "gitea_login": r["gitea_login"], + "display_name": r["display_name"], + "email": r["email"] or "", + "role": r["role"], + "muted": bool(r["muted"]), + "created_at": r["created_at"], + "last_seen_at": r["last_seen_at"], + } + for r in rows + ] + } + + # ----- Role change (§6.1) ----- + + @router.post("/api/admin/users/{user_id}/role") + async def set_role(user_id: int, body: RoleBody, request: Request) -> dict[str, Any]: + viewer = auth.require_admin(request) + target = db.conn().execute( + "SELECT id, role, gitea_login FROM users WHERE id = ?", (user_id,) + ).fetchone() + if target is None: + raise HTTPException(404, "User not found") + # §6.1: only an owner can grant `owner`, and only an owner can + # revoke an owner. Admins can flip contributor ↔ admin freely. + if body.role == "owner" and viewer.role != "owner": + raise HTTPException(403, "Only an owner can grant the owner role") + if target["role"] == "owner" and viewer.role != "owner": + raise HTTPException(403, "Only an owner can change an owner's role") + # An owner cannot demote themselves — that would orphan owner-zero + # if they are the only owner, and the role-change UI should not + # smuggle a "downgrade self" path through this endpoint. + if target["id"] == viewer.user_id and body.role != viewer.role: + raise HTTPException(403, "Use the explicit succession path to change your own role") + + before = target["role"] + if before == body.role: + return {"ok": True, "role": body.role, "changed": False} + + db.conn().execute( + "UPDATE users SET role = ? WHERE id = ?", (body.role, user_id), + ) + db.conn().execute( + """ + INSERT INTO permission_events + (actor_user_id, subject_user_id, event_kind, details) + VALUES (?, ?, 'role_changed', ?) + """, + ( + viewer.user_id, + user_id, + json.dumps({"before": before, "after": body.role}), + ), + ) + return {"ok": True, "role": body.role, "changed": True} + + # ----- Write-mute (§6.2) ----- + + @router.post("/api/admin/users/{user_id}/mute") + async def set_mute(user_id: int, body: MuteBody, request: Request) -> dict[str, Any]: + viewer = auth.require_admin(request) + target = db.conn().execute( + "SELECT id, role, muted FROM users WHERE id = ?", (user_id,) + ).fetchone() + if target is None: + raise HTTPException(404, "User not found") + if target["id"] == viewer.user_id: + raise HTTPException(422, "You cannot write-mute yourself") + # §6.2 + §6.1: admins/owners are not write-mutable. The write-mute + # is a contributor-only refusal — an admin's authority is the + # role-change channel, not a mute. + if target["role"] in ("owner", "admin"): + raise HTTPException( + 403, + "Owners and admins cannot be write-muted — change the role instead", + ) + + before = bool(target["muted"]) + after = bool(body.muted) + if before == after: + return {"ok": True, "muted": after, "changed": False} + + db.conn().execute( + "UPDATE users SET muted = ? WHERE id = ?", (1 if after else 0, user_id), + ) + db.conn().execute( + """ + INSERT INTO permission_events + (actor_user_id, subject_user_id, event_kind, details) + VALUES (?, ?, ?, ?) + """, + ( + viewer.user_id, + user_id, + "muted" if after else "restored", + json.dumps({"before": before, "after": after}), + ), + ) + return {"ok": True, "muted": after, "changed": True} + + # ----- Audit log (`actions` + `permission_events`) ----- + + @router.get("/api/admin/audit") + async def list_audit( + request: Request, + action_kind: str | None = None, + actor_user_id: int | None = None, + rfc_slug: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + before_id: int | None = None, + ) -> dict[str, Any]: + auth.require_admin(request) + clauses: list[str] = [] + args: list[Any] = [] + if action_kind: + clauses.append("a.action_kind = ?") + args.append(action_kind) + if actor_user_id is not None: + clauses.append("a.actor_user_id = ?") + args.append(actor_user_id) + if rfc_slug: + clauses.append("a.rfc_slug = ?") + args.append(rfc_slug) + if before_id is not None: + clauses.append("a.id < ?") + args.append(before_id) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + rows = db.conn().execute( + f""" + SELECT a.id, a.actor_user_id, a.on_behalf_of, a.action_kind, + a.rfc_slug, a.branch_name, a.pr_number, a.bot_commit_sha, + a.details, a.created_at, + u.gitea_login AS actor_login, u.display_name AS actor_display + FROM actions a + LEFT JOIN users u ON u.id = a.actor_user_id + {where} + ORDER BY a.id DESC + LIMIT ? + """, + (*args, limit), + ).fetchall() + # The distinct-action-kinds list powers the filter chip in + # `Admin.jsx`; cheap to compute alongside the page since the + # action_kind set is bounded. + kinds = [ + r["action_kind"] + for r in db.conn().execute( + "SELECT DISTINCT action_kind FROM actions ORDER BY action_kind" + ) + ] + return { + "items": [ + { + "id": r["id"], + "action_kind": r["action_kind"], + "actor_user_id": r["actor_user_id"], + "actor_login": r["actor_login"], + "actor_display": r["actor_display"], + "on_behalf_of": r["on_behalf_of"], + "rfc_slug": r["rfc_slug"], + "branch_name": r["branch_name"], + "pr_number": r["pr_number"], + "bot_commit_sha": r["bot_commit_sha"], + "details": _safe_json(r["details"]), + "created_at": r["created_at"], + } + for r in rows + ], + "action_kinds": kinds, + "has_more": len(rows) == limit, + } + + @router.get("/api/admin/permission-events") + async def list_permission_events( + request: Request, + limit: int = Query(default=100, ge=1, le=500), + before_id: int | None = None, + ) -> dict[str, Any]: + auth.require_admin(request) + clauses: list[str] = [] + args: list[Any] = [] + if before_id is not None: + clauses.append("p.id < ?") + args.append(before_id) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + rows = db.conn().execute( + f""" + SELECT p.id, p.event_kind, p.details, p.created_at, + p.actor_user_id, p.subject_user_id, + au.gitea_login AS actor_login, au.display_name AS actor_display, + su.gitea_login AS subject_login, su.display_name AS subject_display + FROM permission_events p + LEFT JOIN users au ON au.id = p.actor_user_id + LEFT JOIN users su ON su.id = p.subject_user_id + {where} + ORDER BY p.id DESC + LIMIT ? + """, + (*args, limit), + ).fetchall() + return { + "items": [ + { + "id": r["id"], + "event_kind": r["event_kind"], + "actor_login": r["actor_login"], + "actor_display": r["actor_display"], + "subject_login": r["subject_login"], + "subject_display": r["subject_display"], + "details": _safe_json(r["details"]), + "created_at": r["created_at"], + } + for r in rows + ], + "has_more": len(rows) == limit, + } + + # ----- Graduation-readiness queue (§13.2) ----- + + @router.get("/api/admin/graduation-queue") + async def graduation_queue(request: Request) -> dict[str, Any]: + auth.require_admin(request) + # §13 / §13.2: a super-draft is ready when (a) it has at least one + # owner claimed via §13.1 and (b) it has zero open meta_body_edit + # PRs. We compute both with one pass, returning the ready set and + # the not-yet-ready set so the admin sees what is gating each. + rows = db.conn().execute( + """ + SELECT slug, title, owners_json, arbiters_json, tags_json, + proposed_at, last_entry_commit_at + FROM cached_rfcs + WHERE state = 'super-draft' + ORDER BY COALESCE(last_entry_commit_at, proposed_at) DESC + """ + ).fetchall() + items_ready: list[dict[str, Any]] = [] + items_blocked: list[dict[str, Any]] = [] + for r in rows: + owners = json.loads(r["owners_json"] or "[]") + blocking = db.conn().execute( + """ + SELECT COUNT(*) AS n FROM cached_prs + WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit' + """, + (r["slug"],), + ).fetchone()["n"] + payload = { + "slug": r["slug"], + "title": r["title"], + "owners": owners, + "tags": json.loads(r["tags_json"] or "[]"), + "proposed_at": r["proposed_at"], + "last_entry_commit_at": r["last_entry_commit_at"], + "blocking_prs": blocking, + "owners_set": len(owners) > 0, + } + if payload["owners_set"] and blocking == 0: + items_ready.append(payload) + else: + items_blocked.append(payload) + return {"ready": items_ready, "blocked": items_blocked} + + # ----- User search (typeahead for §15.8 mute add) ----- + + @router.get("/api/users/search") + async def search_users( + request: Request, q: str = Query(default="", min_length=0, max_length=80), + ) -> dict[str, Any]: + viewer = auth.require_user(request) + needle = q.strip().lower() + # An empty query returns recent users; a non-empty query matches + # against gitea_login and display_name with a prefix-first + # ranking so the typeahead surfaces obvious matches early. + if not needle: + rows = db.conn().execute( + """ + SELECT id, gitea_login, display_name, role + FROM users WHERE id != ? + ORDER BY last_seen_at DESC LIMIT 10 + """, + (viewer.user_id,), + ).fetchall() + else: + like = f"%{needle}%" + prefix = f"{needle}%" + rows = db.conn().execute( + """ + SELECT id, gitea_login, display_name, role + FROM users + WHERE id != ? + AND (LOWER(gitea_login) LIKE ? OR LOWER(display_name) LIKE ?) + ORDER BY + CASE WHEN LOWER(gitea_login) LIKE ? THEN 0 ELSE 1 END, + LOWER(gitea_login) + LIMIT 10 + """, + (viewer.user_id, like, like, prefix), + ).fetchall() + return { + "items": [ + { + "id": r["id"], + "gitea_login": r["gitea_login"], + "display_name": r["display_name"], + "role": r["role"], + } + for r in rows + ] + } + + return router + + +def _safe_json(blob: str | None) -> Any: + if not blob: + return None + try: + return json.loads(blob) + except Exception: + return blob diff --git a/backend/app/api_notifications.py b/backend/app/api_notifications.py index cf19dc3..e07128a 100644 --- a/backend/app/api_notifications.py +++ b/backend/app/api_notifications.py @@ -293,6 +293,36 @@ def make_router(config: Config) -> APIRouter: # ----- Per-user notification mute (§15.8) ----- + @router.get("/api/users/me/notification-mutes") + async def list_user_mutes(request: Request) -> dict[str, Any]: + """Slice 7: the §15.8 mute list the settings surface renders. + Joined against users so the settings UI can show @gitea_login and + display_name without a second round-trip. + """ + viewer = auth.require_user(request) + rows = db.conn().execute( + """ + SELECT m.muted_user_id, m.muted_at, + u.gitea_login, u.display_name + FROM notification_user_mutes m + JOIN users u ON u.id = m.muted_user_id + WHERE m.muter_user_id = ? + ORDER BY m.muted_at DESC + """, + (viewer.user_id,), + ).fetchall() + return { + "items": [ + { + "muted_user_id": r["muted_user_id"], + "gitea_login": r["gitea_login"], + "display_name": r["display_name"], + "muted_at": r["muted_at"], + } + for r in rows + ] + } + @router.post("/api/users/{user_id}/notification-mute") async def add_user_mute(user_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_user(request) diff --git a/backend/app/philosophy.py b/backend/app/philosophy.py new file mode 100644 index 0000000..b912cb8 --- /dev/null +++ b/backend/app/philosophy.py @@ -0,0 +1,66 @@ +"""§14.2 philosophy source. + +The spec names PHILOSOPHY.md as the body the `/philosophy` route renders, +"sourced from the meta repo's main branch, cached and refreshed on the +same cadence as RFC bodies (§4)." Slice 7 picks the disk-first shape: +the file is checked into the app repo alongside SPEC.md, since it is +the framework's design document rather than an RFC entry, and reading +it from disk at process start (with a periodic re-read for hot edits) +puts the framework's mission in front of the reader without an extra +Gitea round-trip on the first hit. + +If a downstream deployment hosts PHILOSOPHY.md in the meta repo +instead, the `PHILOSOPHY_PATH` env var can point at a working-tree +clone or a sync target; the loader does not care which. +""" +from __future__ import annotations + +import logging +import os +import threading +from pathlib import Path + +log = logging.getLogger(__name__) + + +_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "PHILOSOPHY.md" + +_lock = threading.Lock() +_cache: dict | None = None + + +def _resolved_path() -> Path: + override = os.environ.get("PHILOSOPHY_PATH", "").strip() + if override: + return Path(override).expanduser().resolve() + return _DEFAULT_PATH + + +def load(force: bool = False) -> dict: + """Return the cached `{body, path, mtime}` payload, reading from disk + on first call or when `force=True`. The reconciler's sweep can call + this with `force=True` to pick up out-of-band edits. + """ + global _cache + with _lock: + if _cache is not None and not force: + return _cache + path = _resolved_path() + try: + text = path.read_text(encoding="utf-8") + mtime = path.stat().st_mtime + except FileNotFoundError: + log.warning("PHILOSOPHY.md not found at %s — serving placeholder", path) + text = ( + "# PHILOSOPHY.md not found\n\n" + "The deployment is missing its philosophy document. Set " + "PHILOSOPHY_PATH or place PHILOSOPHY.md at the project root." + ) + mtime = 0.0 + _cache = {"body": text, "path": str(path), "mtime": mtime} + return _cache + + +def refresh() -> dict: + """Force-reread from disk. Returns the new payload.""" + return load(force=True) diff --git a/backend/tests/test_chrome_vertical.py b/backend/tests/test_chrome_vertical.py new file mode 100644 index 0000000..c3fe0eb --- /dev/null +++ b/backend/tests/test_chrome_vertical.py @@ -0,0 +1,457 @@ +"""End-to-end integration tests for the Slice 7 vertical (§14 chrome +plus the /settings/notifications and /admin neighborhoods). + +The slice is chrome over existing infrastructure — the rules live in +§14 / §15 / §6 / §13.2, and the endpoints land in +`backend/app/api.py` (the `/api/philosophy` read), in +`backend/app/api_admin.py` (the `/api/admin/*` set plus `/api/users/search`), +and in `backend/app/api_notifications.py` (`/api/users/me/notification-mutes`, +the list-read counterpart to Slice 6's add/delete pair). + +The tests prove: + + * `/api/philosophy` returns the PHILOSOPHY.md body to anonymous and + authenticated callers alike, per §14.2's "authenticated and + anonymous visitors alike can reach `/philosophy`." + * The §15.4 / §15.5 / §15.8 preferences round-trip cleanly through + the per-category email toggles, the digest cadence dropdown, the + quiet-hours editor, and the per-user mute list — what the + `NotificationSettings.jsx` page exercises end-to-end against the + real backend. + * The §15.8 `email_watched_churn` permanent refusal still reads as + `False` after every preferences round-trip — the toggle is + permanently disabled, not silently writable. + * `/api/users/me/notification-mutes` lists the joined rows the + settings page renders. + * `/api/users/search` powers the §15.8 mute typeahead. + * `/api/admin/users` returns the user roster; role-change and the + §6.2 write-mute round-trip through `/api/admin/users//role` + and `/api/admin/users//mute`. + * A contributor cannot reach `/api/admin/*`; an admin can. + * The §6.2 write-mute prevents the muted contributor from running + `POST /api/rfcs/propose` (the same `require_contributor` gate the + other write paths use). + * `/api/admin/audit` returns rows filtered by `action_kind`, + `actor_user_id`, and `rfc_slug`, and pages with `before_id`. + * `/api/admin/permission-events` reads the `permission_events` + table populated by the role-change and write-mute endpoints. + * `/api/admin/graduation-queue` returns the §13.2-ready super-drafts + in the `ready` list and the not-yet-ready ones in `blocked`, with + the right precondition shape (owners set, zero blocking + body-edit PRs). +""" +from __future__ import annotations + +import json as _json + +import pytest + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) +from test_super_draft_vertical import seed_super_draft # noqa: F401 + + +PITCH = "Open Human Model is a framework for representing humans." + + +# --------------------------------------------------------------------------- +# §14.2 — the philosophy route +# --------------------------------------------------------------------------- + + +def test_philosophy_route_returns_body_to_anonymous_visitors(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + r = client.get("/api/philosophy") + assert r.status_code == 200, r.text + body = r.json()["body"] + # The seam: PHILOSOPHY.md at the repo root carries the §14.1 + # framing line. If this assertion ever breaks because the + # philosophy was rewritten, the slug "standardization process" + # is the most stable phrase to anchor against. + assert "standardization process" in body.lower() + + +def test_philosophy_route_returns_body_to_authenticated_visitors(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.get("/api/philosophy") + assert r.status_code == 200 + assert "standardization process" in r.json()["body"].lower() + + +# --------------------------------------------------------------------------- +# §15.4 / §15.5 / §15.8 — the notification-settings round-trip +# --------------------------------------------------------------------------- + + +def test_notification_preferences_round_trip(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + # Defaults per §15.4: personal-direct on, structural off, + # admin-actionable on (contributors ignore it), churn permanently + # off, digest weekly. + r = client.get("/api/users/me/notification-preferences") + assert r.status_code == 200 + p = r.json() + assert p["email_personal_direct"] is True + assert p["email_watched_structural"] is False + assert p["email_watched_churn"] is False # §15.4 permanent refusal + assert p["digest_cadence"] == "weekly" + + # Flip personal-direct off and watched-structural on; bump cadence + # to daily. The settings page's toggles drive these payloads. + r = client.post("/api/users/me/notification-preferences", json={ + "email_personal_direct": False, + "email_watched_structural": True, + "digest_cadence": "daily", + }) + assert r.status_code == 200 + + p = client.get("/api/users/me/notification-preferences").json() + assert p["email_personal_direct"] is False + assert p["email_watched_structural"] is True + assert p["email_watched_churn"] is False # still permanently off + assert p["digest_cadence"] == "daily" + + +def test_quiet_hours_round_trip_and_partial_refusal(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + # Unset by default. + r = client.get("/api/users/me/quiet-hours") + assert r.status_code == 200 + assert r.json() == {"start": None, "end": None, "timezone": None} + + # §15.8: all-three-or-nothing. A partial set is refused. + r = client.post("/api/users/me/quiet-hours", json={ + "start": "22:00", "end": "08:00", "timezone": None, + }) + assert r.status_code == 422 + + # The full trio round-trips. + r = client.post("/api/users/me/quiet-hours", json={ + "start": "22:00", "end": "08:00", "timezone": "America/Los_Angeles", + }) + assert r.status_code == 200 + q = client.get("/api/users/me/quiet-hours").json() + assert q == {"start": "22:00", "end": "08:00", "timezone": "America/Los_Angeles"} + + # Clear with all-null. + r = client.post("/api/users/me/quiet-hours", json={ + "start": None, "end": None, "timezone": None, + }) + assert r.status_code == 200 + assert client.get("/api/users/me/quiet-hours").json() == { + "start": None, "end": None, "timezone": None, + } + + +def test_user_mute_add_list_and_unmute(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="carol", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + # Empty mute list to start. + r = client.get("/api/users/me/notification-mutes") + assert r.status_code == 200 + assert r.json()["items"] == [] + + # Add — Slice 7's settings page surfaces this via the typeahead. + r = client.post("/api/users/3/notification-mute") + assert r.status_code == 200, r.text + + # List — the joined view the settings page renders. + items = client.get("/api/users/me/notification-mutes").json()["items"] + assert len(items) == 1 + assert items[0]["gitea_login"] == "carol" + assert items[0]["display_name"] == "Carol" + + # Unmute. + r = client.delete("/api/users/3/notification-mute") + assert r.status_code == 200 + assert client.get("/api/users/me/notification-mutes").json()["items"] == [] + + +def test_user_search_powers_mute_typeahead(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="carol", role="contributor") + provision_user_row(user_id=4, login="dave", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + # Empty query: recent users, excluding the caller. + r = client.get("/api/users/search") + assert r.status_code == 200 + ids = {u["id"] for u in r.json()["items"]} + assert 2 not in ids + assert {3, 4}.issubset(ids) + + # Prefix matches gitea_login. + r = client.get("/api/users/search?q=car") + items = r.json()["items"] + assert any(u["gitea_login"] == "carol" for u in items) + + # Substring matches display_name (we'd indexed by Capitalize()). + r = client.get("/api/users/search?q=Dave") + items = r.json()["items"] + assert any(u["gitea_login"] == "dave" for u in items) + + +# --------------------------------------------------------------------------- +# §6 / §17 — the admin neighborhood +# --------------------------------------------------------------------------- + + +def test_admin_list_users_returns_roster_and_refuses_contributors(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=1, login="ben", role="owner") + provision_user_row(user_id=3, login="carol", role="admin") + + # Contributor refused. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + assert client.get("/api/admin/users").status_code == 403 + + # Owner sees the roster ordered owner → admin → contributor. + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + r = client.get("/api/admin/users") + assert r.status_code == 200 + roles = [u["role"] for u in r.json()["items"]] + assert roles[0] == "owner" + assert "admin" in roles + assert "contributor" in roles + + +def test_admin_role_change_round_trips_and_records_permission_event(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + + r = client.post("/api/admin/users/2/role", json={"role": "admin"}) + assert r.status_code == 200 + assert r.json() == {"ok": True, "role": "admin", "changed": True} + + # The user row updated. + row = db.conn().execute("SELECT role FROM users WHERE id = 2").fetchone() + assert row["role"] == "admin" + + # A permission_events row landed. + rows = db.conn().execute( + "SELECT event_kind, details FROM permission_events WHERE subject_user_id = 2" + ).fetchall() + assert len(rows) == 1 + assert rows[0]["event_kind"] == "role_changed" + details = _json.loads(rows[0]["details"]) + assert details == {"before": "contributor", "after": "admin"} + + # Idempotent: a re-set with the same role returns changed=False. + r = client.post("/api/admin/users/2/role", json={"role": "admin"}) + assert r.status_code == 200 + assert r.json()["changed"] is False + + +def test_admin_cannot_grant_owner_unless_owner(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + provision_user_row(user_id=3, login="carol", role="admin") + provision_user_row(user_id=2, login="alice", role="contributor") + + # An admin cannot grant `owner`. + sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="admin") + r = client.post("/api/admin/users/2/role", json={"role": "owner"}) + assert r.status_code == 403 + + # An admin cannot change an owner's role. + r = client.post("/api/admin/users/1/role", json={"role": "admin"}) + assert r.status_code == 403 + + +def test_admin_write_mute_round_trip_and_refusals(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="carol", role="admin") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + + # The §6.2 write-mute: contributor only. + r = client.post("/api/admin/users/3/mute", json={"muted": True}) + assert r.status_code == 403 # admins are not write-mutable + + r = client.post("/api/admin/users/2/mute", json={"muted": True}) + assert r.status_code == 200 + assert r.json()["muted"] is True + + # And the gate fires when alice tries to write. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/propose", json={ + "title": "Open Human Model", + "slug": "ohm", + "pitch": PITCH, + "tags": [], + }) + assert r.status_code == 403, r.text + + # Restore — the mute audit lands. + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + r = client.post("/api/admin/users/2/mute", json={"muted": False}) + assert r.status_code == 200 + + events = db.conn().execute( + "SELECT event_kind FROM permission_events WHERE subject_user_id = 2 ORDER BY id" + ).fetchall() + kinds = [e["event_kind"] for e in events] + assert kinds == ["muted", "restored"] + + +def test_admin_audit_log_filters_and_pages(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/propose", json={ + "title": "Open Human Model", + "slug": "ohm", + "pitch": PITCH, + "tags": [], + }) + assert r.status_code == 200, r.text + pr_number = r.json()["pr_number"] + + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + client.post(f"/api/proposals/{pr_number}/merge") + + # Unfiltered: at least the propose + merge land in `actions`. + r = client.get("/api/admin/audit") + assert r.status_code == 200 + kinds = [it["action_kind"] for it in r.json()["items"]] + assert "propose_rfc" in kinds + assert "merge_proposal" in kinds + # The distinct-action-kinds list powers the filter chip. + assert "propose_rfc" in r.json()["action_kinds"] + + # Filter by rfc_slug. + r = client.get("/api/admin/audit?rfc_slug=ohm") + assert all(it["rfc_slug"] == "ohm" for it in r.json()["items"]) + + # Filter by action_kind. + r = client.get("/api/admin/audit?action_kind=propose_rfc") + assert all(it["action_kind"] == "propose_rfc" for it in r.json()["items"]) + + # Filter by actor_user_id. + r = client.get("/api/admin/audit?actor_user_id=2") + assert all(it["actor_user_id"] == 2 for it in r.json()["items"]) + + +def test_admin_graduation_queue_partitions_by_readiness(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + + # Two super-drafts: one with owners claimed, one without. + seed_super_draft(fake, slug="ready", title="Ready RFC", pitch="…") + seed_super_draft(fake, slug="orphan", title="Orphan RFC", pitch="…") + db.conn().execute( + "UPDATE cached_rfcs SET owners_json = ? WHERE slug = 'ready'", + (_json.dumps(["ben"]),), + ) + + r = client.get("/api/admin/graduation-queue") + assert r.status_code == 200 + d = r.json() + ready_slugs = {it["slug"] for it in d["ready"]} + blocked_slugs = {it["slug"] for it in d["blocked"]} + assert ready_slugs == {"ready"} + assert "orphan" in blocked_slugs + + # Now add a blocking body-edit PR to the ready slug — it should + # move to blocked even with owners set. + db.conn().execute( + """ + INSERT INTO cached_prs + (rfc_slug, pr_kind, repo, pr_number, title, description, state, + opened_by, opened_at, head_branch, base_branch, head_sha) + VALUES ('ready', 'meta_body_edit', 'wiggleverse/meta', 42, 'edit', '', + 'open', 'alice', datetime('now'), 'edit-ready-abc123', 'main', 'sha') + """ + ) + d = client.get("/api/admin/graduation-queue").json() + assert {it["slug"] for it in d["ready"]} == set() + assert "ready" in {it["slug"] for it in d["blocked"]} + blocked_ready = next(it for it in d["blocked"] if it["slug"] == "ready") + assert blocked_ready["blocking_prs"] == 1 + assert blocked_ready["owners_set"] is True + + +def test_admin_permission_events_returns_role_and_mute_history(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + + client.post("/api/admin/users/2/role", json={"role": "admin"}) + client.post("/api/admin/users/2/role", json={"role": "contributor"}) + client.post("/api/admin/users/2/mute", json={"muted": True}) + + r = client.get("/api/admin/permission-events?limit=10") + assert r.status_code == 200 + items = r.json()["items"] + # Newest first. + kinds = [it["event_kind"] for it in items] + assert kinds[:3] == ["muted", "role_changed", "role_changed"] + # Subject and actor join populated. + assert all(it["subject_login"] == "alice" for it in items[:3]) + assert all(it["actor_login"] == "ben" for it in items[:3]) diff --git a/docs/DEV.md b/docs/DEV.md index 383835a..e3a2617 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -29,10 +29,18 @@ rare and surgical and live in the appropriate numbered section per 6. **Notifications per §15.** Last, because every other surface produces signals the inbox receives — notification correctness depends on the producers being in place first. -7. **The §14 chrome.** Landing page polish, the `/philosophy` route, - the persistent About link. +7. **The §14 chrome + the settings and admin neighborhoods.** + Landing page polish, the `/philosophy` route, the persistent + About link; the `/settings/notifications` surface that exposes + Slice 6's preferences/quiet-hours/mute/watches endpoints; the + `/admin` home base that consolidates role management, the §6.2 + write-mute, the audit-log viewer, and the §13.2 graduation- + readiness queue. 8. **Hardening.** End-to-end tests, dev/prod deployment shape, - the §12 30/90 branch-hygiene timers. + the §12 30/90 branch-hygiene timers, the §19.2 candidates that + cluster with deployment (branch-name path routing, cache + bootstrap from a pre-existing meta repo, in-app metadata-PR + merges, graduation rollback's branch cleanup). ## State of the codebase @@ -280,6 +288,80 @@ adds the `email_opt_out_all` column to `users` for the bounce webhook. Topic 13 settled the rest of the §5 surface before the build started, so no further migrations were needed. +### Slice 7 — shipped + +The §14 chrome, the §15 settings neighborhood, and the §6/§17 admin +home base — three surfaces over existing infrastructure. + +§14.1's pre-login landing carries the title, the subtitle, the +short-form pitch from [`PHILOSOPHY.md`](../PHILOSOPHY.md), the +sign-in affordance, and a three-item deck that names what the +framework is. §14.2's `/philosophy` route reads `PHILOSOPHY.md` +through [`backend/app/philosophy.py`](../backend/app/philosophy.py) +(disk-backed, configurable via `PHILOSOPHY_PATH`; defaults to the +file at the project root) and renders with `marked`. §14.3's +persistent About link sits in the header alongside Settings (open +to everyone) and Admin (owner/admin only); the chrome's visual +budget stays tight per §14.4. + +The notification-settings surface lives at `/settings/notifications` +([`NotificationSettings.jsx`](../frontend/src/components/NotificationSettings.jsx)) +and is what the §15.4 email footer's `Manage all preferences` link +resolves to. Five sub-sections: the §15.4 per-category toggles +(with the `email_watched_churn` toggle permanently disabled and the +§15.4 refusal tooltip inline — naming the refusal is what keeps the +contract honest), the §15.5 digest cadence dropdown, the §15.8 +quiet-hours editor (three inputs against +`Intl.supportedValuesOf('timeZone')` with all-three-or-clear +validation server-side), the §15.6 watches overview, and the +§15.8 per-user mute list with a typeahead add. Owners and admins +see the "cannot mute" prose inline per §15.8. + +The admin home base lives at `/admin` +([`Admin.jsx`](../frontend/src/components/Admin.jsx)) as a four- +tab left-rail: Users (role management + §6.2 write-mute), Graduation +queue (§13.2-ready partition), Audit log (paged `actions` with +filter chips), and Permission events (paged `permission_events`). +Role-grant constraints land server-side in +[`backend/app/api_admin.py`](../backend/app/api_admin.py) per §6.1 +— only owners may grant `owner`; owners cannot self-demote on the +role endpoint (the explicit succession path is a §19.2 candidate). +The §6.2 write-mute applies only to contributors; admins and owners +are not write-mutable. Every role and mute change writes a +`permission_events` row joined to `users` for the surface. + +| Method | Path | § | +| ------ | --------------------------------------------- | ------- | +| GET | `/api/philosophy` | §14.2 | +| GET | `/api/admin/users` | §6.1 | +| POST | `/api/admin/users/{id}/role` | §6.1 | +| POST | `/api/admin/users/{id}/mute` | §6.2 | +| GET | `/api/admin/audit` | §6.5 | +| GET | `/api/admin/permission-events` | §6.5 | +| GET | `/api/admin/graduation-queue` | §13.2 | +| GET | `/api/users/me/notification-mutes` | §15.8 | +| GET | `/api/users/search` | §15.8 | + +Slice 7 ships covered by +[`backend/tests/test_chrome_vertical.py`](../backend/tests/test_chrome_vertical.py) — +thirteen integration tests against the FakeGitea, covering the +philosophy route for anonymous and authenticated callers, the +§15.4 / §15.5 / §15.8 preferences round-trip (including the +permanent `email_watched_churn` refusal), the quiet-hours +all-or-nothing validation, the §15.8 mute add/list/unmute +round-trip, the user-search typeahead, the admin role and +write-mute round-trips with their `permission_events` audit, the +§6.1 refusal of owner-grant by non-owners, the audit-log filter +chips, the graduation-queue partition under both preconditions, +and the permission-events listing. The full Slices 1–7 test suite +is 75/75 green. + +No schema migrations. The two surface-facing spec corrections — +§14.2 (PHILOSOPHY.md source is a deployment-time decision; v1 reads +from the app repo) and the §17 admin block (extended to name the +seven new endpoints) — are the only places the slice's running +code asked the spec to be more honest than it was. + ### Slice 5 — shipped Graduation per §13 in full. The §13.3 five-step transactional sequence @@ -510,52 +592,61 @@ spec: ## Next slice -**Slice 7: the §14 chrome.** +**Slice 8: hardening — the last slice of the v1 build.** -With Slice 6 shipped, every structural and notification beat the -framework commits to is live: propose, claim, super-draft body -editing, the §10 PR flow against both repo shapes, graduation, and -the §15 inbox/email/digest stack. What remains for v1 is the chrome -that wraps the whole thing — the landing page that brings an -unauthenticated visitor in, the `/philosophy` route that surfaces -[`PHILOSOPHY.md`](../PHILOSOPHY.md) verbatim, the persistent About -link in the header per §14.3, plus the natural neighbors that -Slice 6 left as API-only and that §19.2 names as candidates: +With Slice 7 shipped, every structural beat the spec commits to is +live and every surface the framework exposes has chrome around it. +What remains is the hardening pass that lets a single-operator +deployment actually run end-to-end without hand-holding. Three +pieces hang together: -- **The notification-settings surface** — the actual UI for the - preferences/quiet-hours/mute endpoints Slice 6 wired. Topic 13 - settled the schema and the per-category rules; the surface - where a contributor finds the per-category email toggles, the - digest cadence dropdown, the quiet-hours editor, the watches - overview, and the per-user mute list is the natural follow-on. - Likely lives at `/settings/notifications` (the link Slice 6's - emails already point at). -- **The admin neighborhood.** §19.2's "Admin surfaces" candidate. - Role management, the §6.2 app-wide write-mute, the audit-log - viewer, the graduation-readiness queue. Topics 12 and 13 both - expanded the admin's repertoire without giving it a centralized - home; Slice 7 picks the framing. -- **Landing page polish.** Slice 1 stood up a minimal landing for - the unauthenticated path; §14 commits a richer shape — what the - framework is, why it exists, what the visitor's first read should - be, and the sign-in affordance. -- **The `/philosophy` route.** [`PHILOSOPHY.md`](../PHILOSOPHY.md) - rendered inline, reachable from the header on every page, so the - reader can return to the framing without leaving the app. +- **The §12 30/90 branch-hygiene timers.** §11.5 names the branch + lifecycle (open → merged → 30d read-only → 90d deleted-by-bot, + with the per-user message-cursor preservation contract); §12 + formalizes the policy. The wiring is a scheduled task next to + the existing `DigestScheduler` — same `run_tick` test-seam shape. + The §10.7 90-day deletion timer Slice 3 left explicitly deferred + lives here too. Touches `cache.Reconciler` (the natural place to + fire the hygiene sweep), `bot.delete_branch` (the §12 actuator, + not yet exercised), and the §19.2 cache-bootstrap topic if the + hygiene sweep also rebuilds branch state from Gitea after a + cache wipe. +- **An end-to-end smoke pass** over the working surfaces. Propose + → super-draft → branch → PR → merge → graduate → active-RFC PR + → notification → inbox → email — one or two `test_e2e_smoke.py` + cases that exercise the seams a per-slice test wouldn't. Plus + the §19.2 follow-ons the hardening pass is the natural place to + fold in: branch-name path routing (`{branch:path}` everywhere + with route-ordering discipline), cache bootstrap from a + pre-existing meta repo (the audit-log-first attribution shape + exercised against history the bot did not author), in-app merge + for metadata PRs, the graduation rollback's branch cleanup, and + the small Slice-2-onward follow-ons that are deferred until the + hardening pass demands them. +- **The dev/prod deployment shape.** `deploy/` already carries an + nginx vhost, a systemd unit, and a runbook stub. Slice 8 proves + the bring-up against a fresh host, settles the secret-material + handling (the existing `.env.example` plus the §15.4 SMTP + wiring), wires the §6 / §15.4 SMTP credentials, and lands the + README updates that take a new operator from `git clone` to a + signed-in browser. -What Slice 7 does NOT own: +What Slice 8 does NOT own: -- The §12 30/90 branch-hygiene timers (still Slice 8). +- New surfaces. The v1 surface is complete; the hardening pass is + about making what's there resilient, observable, and operable. - The §16 deferred items. -- New §15 capabilities — Slice 6 shipped the surface; settings UI - is exposure of what's already there, not new behavior. +- The §19.2 candidate set as a whole — the hardening pass folds in + the candidates that naturally cluster with hygiene timers, cache + rebuild, and deployment; the rest stay queued for post-v1 + sessions. -The carryovers Slice 7 inherits — the existing §14 spec text, the -§17 endpoint set including Slice 6's settings endpoints, and the -React Router layout already in place. +The carryovers Slice 8 inherits — the full §11.5 / §12 spec text, +the existing `cache.Reconciler` and `DigestScheduler` shape, the +deploy/ infrastructure, and the 75/75 green test suite. The next build session should read `SPEC.md`, `README.md`, -`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 7 cleanly +`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 8 cleanly without re-briefing. The working agreement in §19.3 continues to apply: implement the slice, correct the spec only where running code reveals it was wrong at a structural level, accumulate new diff --git a/frontend/src/App.css b/frontend/src/App.css index a6eeea5..69f79f8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1218,3 +1218,276 @@ .toast.cat-personal-direct { border-left-color: #d97706; } .toast.cat-structural { border-left-color: #2563eb; } .toast.cat-churn { border-left-color: #6b7280; } + +/* ── Slice 7: §14 chrome + /settings/notifications + /admin ──────────── */ + +/* Header chrome — the persistent §14.3 About link plus the Settings and + Admin entrypoints. The header's job is to be invisible until the user + reaches for it, so these read as quiet text links rather than buttons. */ +.header-about, .header-settings, .header-admin { + color: #ddd; text-decoration: none; + font-size: 12px; letter-spacing: 0.02em; + padding: 4px 8px; border-radius: 4px; +} +.header-about:hover, .header-settings:hover, .header-admin:hover { + color: #fff; background: rgba(255,255,255,0.08); +} +.header-admin { + color: #fbbf24; /* admin link sits a notch warmer to signal authority */ +} + +/* /philosophy — the §14.2 read surface. The body inherits the + prototype's markdown styling; the header is a thin chrome strip. */ +.chrome-pane { + flex: 1; min-width: 0; overflow: auto; + padding: 0; background: #fff; +} + +.philosophy-page { + max-width: 760px; margin: 0 auto; + padding: 24px 32px 64px; +} +.philosophy-header { + display: flex; align-items: center; gap: 12px; + padding: 12px 0 18px; + border-bottom: 1px solid #f0f0ee; + margin-bottom: 28px; +} +.philosophy-back { + border: none; background: none; cursor: pointer; + color: #4b5563; font-size: 13px; + padding: 4px 8px; border-radius: 4px; +} +.philosophy-back:hover { background: #f3f4f6; color: #111; } +.philosophy-title { + font-size: 13px; color: #6b7280; + text-transform: uppercase; letter-spacing: 0.08em; +} +.philosophy-signin { + margin-left: auto; + font-size: 13px; color: #4b5563; text-decoration: none; +} +.philosophy-signin:hover { color: #111; text-decoration: underline; } + +.philosophy-body h1 { + font-size: 28px; font-weight: 700; margin: 0 0 24px; + letter-spacing: -0.01em; +} +.philosophy-body h2 { + font-size: 19px; font-weight: 600; margin: 36px 0 12px; + color: #111; +} +.philosophy-body h3 { + font-size: 15px; font-weight: 600; margin: 24px 0 10px; +} +.philosophy-body p { + margin: 0 0 16px; line-height: 1.65; font-size: 15px; color: #1f2937; +} +.philosophy-body em { color: #4b5563; font-style: italic; } +.philosophy-body strong { color: #111; } +.philosophy-body ul, .philosophy-body ol { + margin: 0 0 20px; padding-left: 24px; +} +.philosophy-body li { margin-bottom: 8px; line-height: 1.6; } +.philosophy-body hr { + border: none; border-top: 1px solid #e5e7eb; margin: 32px 0; +} +.philosophy-body code { + background: #f3f4f6; padding: 1px 5px; border-radius: 3px; + font-size: 0.92em; +} + +/* Richer landing page (§14.1) — adds the three-item deck under the + pitch. The .landing container's flex centering stays from the + prototype; the new content lives inside .landing-inner. */ +.landing-inner { + max-width: 620px; + display: flex; flex-direction: column; align-items: center; +} +.landing-deck { + list-style: none; padding: 0; + margin: 48px 0 0; text-align: left; + display: flex; flex-direction: column; gap: 18px; + border-top: 1px solid #e5e7eb; padding-top: 32px; + max-width: 540px; +} +.landing-deck li { + font-size: 14px; line-height: 1.6; color: #374151; +} +.landing-deck strong { color: #111; } + +/* /settings/notifications */ +.settings-page { + max-width: 720px; margin: 0 auto; + padding: 24px 32px 64px; +} +.settings-header h1 { + margin: 0 0 6px; font-size: 24px; font-weight: 700; letter-spacing: -0.01em; +} +.settings-sub { + color: #6b7280; font-size: 13px; margin: 0 0 24px; + line-height: 1.5; +} +.settings-section { + border: 1px solid #e5e7eb; border-radius: 8px; + padding: 20px 24px; margin-bottom: 16px; background: #fff; +} +.settings-section h2 { + margin: 0 0 4px; font-size: 15px; font-weight: 600; +} +.settings-section-body { display: flex; flex-direction: column; gap: 12px; } +.settings-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } +.quiet-hours-row label { + display: flex; flex-direction: column; gap: 4px; + font-size: 12px; color: #6b7280; +} +.quiet-hours-row input, .quiet-hours-row select { + border: 1px solid #d1d5db; border-radius: 6px; + padding: 6px 10px; font-size: 13px; + background: white; color: #111; +} +.settings-note { font-size: 12px; color: #6b7280; margin: 0; } +.settings-note.warning { color: #b91c1c; } + +.toggle-row { + display: flex; align-items: flex-start; gap: 12px; + cursor: pointer; padding: 10px 0; + border-top: 1px solid #f3f4f6; +} +.toggle-row:first-child { border-top: none; padding-top: 0; } +.toggle-row.disabled { cursor: not-allowed; opacity: 0.6; } +.toggle-row input[type=checkbox] { margin-top: 3px; } +.toggle-text { display: flex; flex-direction: column; gap: 2px; } +.toggle-label { font-size: 14px; font-weight: 500; color: #111; } +.toggle-desc { font-size: 12px; color: #6b7280; line-height: 1.5; } + +.btn-primary { + background: #111; color: #fff; border: none; + padding: 7px 14px; border-radius: 6px; font-size: 13px; cursor: pointer; +} +.btn-primary:hover { background: #333; } +.btn-primary:disabled { background: #9ca3af; cursor: not-allowed; } +.btn-link-muted { + background: none; border: none; cursor: pointer; + color: #6b7280; font-size: 12px; padding: 4px 6px; +} +.btn-link-muted:hover { color: #111; text-decoration: underline; } + +.settings-table, .admin-table { + width: 100%; border-collapse: collapse; + font-size: 13px; +} +.settings-table th, .admin-table th { + text-align: left; padding: 6px 8px; + font-size: 11px; text-transform: uppercase; + color: #6b7280; letter-spacing: 0.05em; font-weight: 600; + border-bottom: 1px solid #e5e7eb; +} +.settings-table td, .admin-table td { + padding: 8px 8px; border-bottom: 1px solid #f3f4f6; +} +.settings-table select { font-size: 13px; padding: 3px 6px; } +.set-by { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; + padding: 2px 6px; border-radius: 4px; font-weight: 600; +} +.set-by-auto { background: #f3f4f6; color: #6b7280; } +.set-by-explicit { background: #dbeafe; color: #1e40af; } + +.mutes-list { list-style: none; padding: 0; margin: 8px 0 0; } +.mutes-row { + display: flex; align-items: center; gap: 10px; + padding: 6px 8px; border-bottom: 1px solid #f3f4f6; + font-size: 13px; +} +.mute-handle { font-weight: 500; color: #111; } +.mute-when { font-size: 11px; margin-left: auto; } + +.mute-typeahead { position: relative; } +.mute-typeahead input { + width: 100%; box-sizing: border-box; + border: 1px solid #d1d5db; border-radius: 6px; + padding: 7px 10px; font-size: 13px; outline: none; +} +.mute-typeahead input:focus { border-color: #111; } +.mute-typeahead-results { + position: absolute; top: 100%; left: 0; right: 0; + background: white; border: 1px solid #d1d5db; border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.08); + margin-top: 4px; padding: 4px 0; z-index: 10; + list-style: none; +} +.mute-typeahead-results button { + display: flex; gap: 10px; align-items: center; + width: 100%; padding: 6px 10px; + background: none; border: none; text-align: left; cursor: pointer; + font-size: 13px; +} +.mute-typeahead-results button:hover { background: #f9fafb; } + +/* /admin */ +.admin-page { + display: flex; height: 100%; + width: 100%; +} +.admin-rail { + width: 220px; flex-shrink: 0; + background: #f9fafb; border-right: 1px solid #e5e7eb; + padding: 24px 16px; +} +.admin-rail h2 { + font-size: 13px; color: #6b7280; + text-transform: uppercase; letter-spacing: 0.08em; + margin: 0 0 12px; +} +.admin-rail ul { list-style: none; padding: 0; margin: 0 0 24px; } +.admin-rail li { margin-bottom: 2px; } +.admin-rail-link { + display: block; padding: 6px 10px; + font-size: 13px; color: #374151; text-decoration: none; + border-radius: 4px; +} +.admin-rail-link:hover { background: #f3f4f6; } +.admin-rail-link.active { background: #111; color: #fff; } +.admin-rail-note { + font-size: 11px; color: #6b7280; line-height: 1.5; +} +.admin-content { + flex: 1; min-width: 0; + padding: 28px 36px; + overflow: auto; +} +.admin-tab-header h2 { + margin: 0 0 4px; font-size: 18px; font-weight: 700; +} +.admin-tab-header p { margin: 0 0 24px; font-size: 13px; } +.admin-section-h { + font-size: 13px; text-transform: uppercase; + letter-spacing: 0.05em; color: #6b7280; + margin: 24px 0 8px; +} + +.audit-filters { + display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; +} +.audit-filters input, .audit-filters select { + border: 1px solid #d1d5db; border-radius: 6px; + padding: 5px 9px; font-size: 13px; background: white; +} +.audit-table code { + font-size: 11px; background: #f3f4f6; + padding: 1px 5px; border-radius: 3px; +} + +.user-cell { display: flex; flex-direction: column; gap: 1px; } +.user-handle { font-weight: 500; color: #111; } +.mute-toggle { + display: inline-flex; align-items: center; gap: 6px; + font-size: 13px; cursor: pointer; +} +.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; } +.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; } +.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; } +.grad-queue-link:hover strong { text-decoration: underline; } +.muted { color: #6b7280; } +.error { color: #b91c1c; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 179c72a..f0b1e6f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,9 @@ import PRView from './components/PRView.jsx' import ProposalView from './components/ProposalView.jsx' import ProposeModal from './components/ProposeModal.jsx' import Landing from './components/Landing.jsx' +import Philosophy from './components/Philosophy.jsx' +import NotificationSettings from './components/NotificationSettings.jsx' +import Admin from './components/Admin.jsx' import ToastHost, { showToast } from './components/ToastHost.jsx' import './App.css' @@ -65,8 +68,16 @@ export default function App() { return
Loading…
} + // §14.2: the philosophy route is reachable by anonymous visitors too. + // Resolve it before the authentication gate so a signed-out reader + // who follows the §14.1 landing link does not get bounced to sign-in. if (!me?.authenticated) { - return + return ( + + } /> + } /> + + ) } return ( @@ -76,6 +87,21 @@ export default function App() { Wiggleverse RFCs
+ {/* §14.3: the persistent About link. One word, no badge, no + state — visible from every authenticated screen so a + contributor mid-PR who wonders why a conversation is + public can reach the answer in two clicks. */} + + About + + + Settings + + {(me.user.role === 'owner' || me.user.role === 'admin') && ( + + Admin + + )}
- setProposeOpen(true)} - version={catalogVersion} - /> -
- - } /> - } /> - } /> - setCatalogVersion(v => v + 1)} />} /> - -
+ + } /> + } /> + } /> + + setProposeOpen(true)} + version={catalogVersion} + /> +
+ + } /> + } /> + } /> + setCatalogVersion(v => v + 1)} />} /> + +
+ + } /> +
{proposeOpen && ( + + + ) +} + +function NotificationSettingsWithSidebar({ viewer }) { + return ( +
+ +
+ ) +} + +function AdminWithSidebar({ viewer }) { + return ( +
+ +
+ ) +} + function Welcome({ viewer }) { return (
@@ -134,10 +197,9 @@ function Welcome({ viewer }) { idea PR against the meta repository.

- Slice 1 of the build is in place: propose → idea PR → owner merges → - super-draft appears in the catalog → super-draft view renders. The - revision flow, per-branch chat, AI participation, and the PR surface - land in subsequent slices. + Wondering why a conversation is public, why graduation costs what it + does, or why the model is in the chat? Read the + philosophy.

) diff --git a/frontend/src/api.js b/frontend/src/api.js index 4155542..c77b69b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -478,6 +478,68 @@ export async function advanceChatSeen(slug, branch, lastSeenMessageId) { })) } +// --------------------------------------------------------------------------- +// §14.2 / Slice 7: PHILOSOPHY.md surface +// --------------------------------------------------------------------------- + +export async function getPhilosophy() { + return jsonOrThrow(await fetch('/api/philosophy')) +} + +// --------------------------------------------------------------------------- +// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute +// typeahead). +// --------------------------------------------------------------------------- + +export async function listAdminUsers() { + return jsonOrThrow(await fetch('/api/admin/users')) +} + +export async function setUserRole(userId, role) { + return jsonOrThrow(await fetch(`/api/admin/users/${userId}/role`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }), + })) +} + +export async function setUserMute(userId, muted) { + return jsonOrThrow(await fetch(`/api/admin/users/${userId}/mute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ muted }), + })) +} + +export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) { + const params = new URLSearchParams() + if (actionKind) params.set('action_kind', actionKind) + if (actorUserId != null) params.set('actor_user_id', actorUserId) + if (rfcSlug) params.set('rfc_slug', rfcSlug) + if (beforeId != null) params.set('before_id', beforeId) + if (limit != null) params.set('limit', limit) + const qs = params.toString() + return jsonOrThrow(await fetch(`/api/admin/audit${qs ? `?${qs}` : ''}`)) +} + +export async function listPermissionEvents({ beforeId, limit } = {}) { + const params = new URLSearchParams() + if (beforeId != null) params.set('before_id', beforeId) + if (limit != null) params.set('limit', limit) + const qs = params.toString() + return jsonOrThrow(await fetch(`/api/admin/permission-events${qs ? `?${qs}` : ''}`)) +} + +export async function listGraduationQueue() { + return jsonOrThrow(await fetch('/api/admin/graduation-queue')) +} + +export async function searchUsers(q) { + const params = new URLSearchParams() + if (q) params.set('q', q) + return jsonOrThrow(await fetch(`/api/users/search?${params}`)) +} + // SSE subscription helper. Returns a close() function. The handler // surface mirrors §15.3: a snapshot event on open, then per-notification // `notification` events, plus `read` events when another tab marks a row. diff --git a/frontend/src/components/Admin.jsx b/frontend/src/components/Admin.jsx new file mode 100644 index 0000000..bc689ba --- /dev/null +++ b/frontend/src/components/Admin.jsx @@ -0,0 +1,408 @@ +// /admin — the admin home base. +// +// Topics 12 and 13 both expanded the admin's repertoire without giving +// it a centralized home. Slice 7 consolidates them: role management, +// the §6.2 app-wide write-mute, the audit-log viewer, the +// graduation-readiness queue, and a read of the permission-events log +// — five thin sub-surfaces behind a left-rail menu. +// +// The page is admin-only; the App.jsx route mounts it only when the +// viewer's role is owner or admin, and every /api/admin/* endpoint +// guards independently. + +import { useEffect, useMemo, useState } from 'react' +import { Routes, Route, NavLink, Link } from 'react-router-dom' +import { + listAdminUsers, + setUserRole, + setUserMute, + listAuditLog, + listPermissionEvents, + listGraduationQueue, +} from '../api.js' + +const TABS = [ + { path: 'users', label: 'Users' }, + { path: 'graduation', label: 'Graduation queue' }, + { path: 'audit', label: 'Audit log' }, + { path: 'permissions', label: 'Permission events' }, +] + +export default function Admin({ viewer }) { + return ( +
+ +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+ ) +} + +// ── Users + role + write-mute (§6.1 / §6.2) ──────────────────────────────── + +function UsersTab() { + const [users, setUsers] = useState(null) + const [busy, setBusy] = useState({}) + const [error, setError] = useState(null) + + async function refresh() { + setError(null) + try { + const r = await listAdminUsers() + setUsers(r.items || []) + } catch (e) { + setError(e.message) + } + } + + useEffect(() => { refresh() }, []) + + async function changeRole(userId, role) { + setBusy(b => ({ ...b, [userId]: true })) + setError(null) + try { + await setUserRole(userId, role) + setUsers(prev => prev.map(u => u.id === userId ? { ...u, role } : u)) + } catch (e) { + setError(e.message) + } finally { + setBusy(b => ({ ...b, [userId]: false })) + } + } + + async function toggleMute(userId, muted) { + setBusy(b => ({ ...b, [userId]: true })) + setError(null) + try { + await setUserMute(userId, muted) + setUsers(prev => prev.map(u => u.id === userId ? { ...u, muted } : u)) + } catch (e) { + setError(e.message) + } finally { + setBusy(b => ({ ...b, [userId]: false })) + } + } + + if (users == null) return

Loading users…

+ + return ( +
+
+

Users

+

+ Role changes write to permission_events. The §6.2 + write-mute applies to contributors only — promote to admin to + remove a user's ability to write without silencing them. +

+
+ {error &&

{error}

} + + + + + + + + + + + {users.map(u => ( + + + + + + + ))} + +
UserRoleWrite-mutedLast seen
+
+ @{u.gitea_login} + {u.display_name} +
+
+ + + {u.role === 'contributor' ? ( + + ) : ( + N/A + )} + {u.last_seen_at}
+
+ ) +} + +// ── Graduation-readiness queue (§13.2) ───────────────────────────────────── + +function GraduationTab() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + listGraduationQueue() + .then(setData) + .catch(e => setError(e.message)) + }, []) + + if (error) return

{error}

+ if (data == null) return

Loading queue…

+ + return ( +
+
+

Graduation queue

+

+ Super-drafts with owners claimed and zero blocking body-edit PRs. + Open one to run the §13.3 graduation sequence. +

+
+ +

Ready ({data.ready.length})

+ {data.ready.length === 0 && ( +

No super-drafts ready right now.

+ )} +
    + {data.ready.map(item => ( +
  • + + {item.title} + — owners: {item.owners.join(', ')} + +
  • + ))} +
+ +

Blocked ({data.blocked.length})

+ {data.blocked.length === 0 && ( +

No blocked super-drafts.

+ )} +
    + {data.blocked.map(item => ( +
  • + + {item.title} + + {' — '} + {!item.owners_set && 'no owners yet'} + {!item.owners_set && item.blocking_prs > 0 && '; '} + {item.blocking_prs > 0 && `${item.blocking_prs} open body-edit PR${item.blocking_prs === 1 ? '' : 's'}`} + + +
  • + ))} +
+
+ ) +} + +// ── Audit log (`actions`) — filter chips + paging ────────────────────────── + +function AuditTab() { + const [data, setData] = useState(null) + const [filters, setFilters] = useState({ actionKind: '', actorUserId: '', rfcSlug: '' }) + const [error, setError] = useState(null) + + async function load(beforeId = null) { + setError(null) + try { + const r = await listAuditLog({ + actionKind: filters.actionKind || undefined, + actorUserId: filters.actorUserId ? Number(filters.actorUserId) : undefined, + rfcSlug: filters.rfcSlug || undefined, + beforeId, + limit: 100, + }) + setData(r) + } catch (e) { + setError(e.message) + } + } + + useEffect(() => { load() }, [filters]) + + const kinds = useMemo(() => data?.action_kinds || [], [data]) + + return ( +
+
+

Audit log

+

+ Every bot-mediated write lands here. The most recent rows show first; + filter to narrow to one kind, one actor, or one RFC. +

+
+ +
+ + setFilters(f => ({ ...f, rfcSlug: e.target.value }))} + /> + setFilters(f => ({ ...f, actorUserId: e.target.value }))} + /> +
+ + {error &&

{error}

} + {data == null &&

Loading…

} + {data?.items?.length === 0 && ( +

No rows match this filter.

+ )} + {data?.items?.length > 0 && ( + + + + + + + + + + + + + {data.items.map(row => ( + + + + + + + + + ))} + +
WhenActionActorOn behalf ofRFCPR / branch
{row.created_at}{row.action_kind}{row.actor_display || row.actor_login || '—'}{row.on_behalf_of}{row.rfc_slug || '—'} + {row.pr_number != null && #{row.pr_number} } + {row.branch_name && {row.branch_name}} +
+ )} + {data?.has_more && ( + + )} +
+ ) +} + +// ── Permission events (`permission_events`) ──────────────────────────────── + +function PermissionsTab() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + listPermissionEvents({ limit: 100 }) + .then(setData) + .catch(e => setError(e.message)) + }, []) + + if (error) return

{error}

+ if (data == null) return

Loading…

+ + return ( +
+
+

Permission events

+

+ Every role change and write-mute toggle. The companion to the + audit log, scoped to authorization changes. +

+
+ {data.items.length === 0 && ( +

No permission events yet.

+ )} + {data.items.length > 0 && ( + + + + + + + + + + + + {data.items.map(r => ( + + + + + + + + ))} + +
WhenEventActorSubjectDetails
{r.created_at}{r.event_kind}{r.actor_display || r.actor_login || '—'}{r.subject_display || r.subject_login || '—'} + {r.details ? formatDetails(r.details) : ''} +
+ )} +
+ ) +} + +function formatDetails(details) { + if (!details || typeof details !== 'object') return String(details ?? '') + if (details.before != null && details.after != null) { + return `${String(details.before)} → ${String(details.after)}` + } + return JSON.stringify(details) +} diff --git a/frontend/src/components/Landing.jsx b/frontend/src/components/Landing.jsx index 8557824..21a862c 100644 --- a/frontend/src/components/Landing.jsx +++ b/frontend/src/components/Landing.jsx @@ -1,24 +1,60 @@ // Landing.jsx — §14.1's pre-login surface. // -// Title, subtitle, the short-form pitch from PHILOSOPHY.md, then the -// single primary action: "Sign in with Gitea." The visual design is -// deferred per §14.4; the structural commitments are here. +// The landing page has three jobs per §14.1: name what this thing is, +// pitch why someone would care, and offer the sign-in affordance. The +// visual treatment is deferred per §14.4 — what matters here is the +// hierarchy. Title and subtitle frame the framework, the short-form +// pitch (sourced verbatim from the top of PHILOSOPHY.md) does the +// argument, and the single primary action lets a reader who is sold +// step through. The secondary link to `/philosophy` is for the reader +// who is interested but needs more before signing in. +// +// The deck below the pitch is intentionally restrained — three crisp +// claims about what the framework *is*, anchored in the spec's +// structural decisions, so the reader who has not yet read the +// philosophy can still tell at a glance whether this is for them. + +import { Link } from 'react-router-dom' export default function Landing() { return (
-

Wiggleverse RFCs

-

A standards process for shared meaning between humans and machines.

-

- Large language models work brilliantly with programming languages because every - word in Python or C has a definitive meaning enforced by tooling. They struggle - with natural language because no such dictionary exists for words like - consent, trait, or agency — words that do enormous - work in any system that interacts with humans. The Wiggleverse RFC framework is - a standardization process for that vocabulary. Build the dictionary first. -

- Sign in with Gitea - Read the full philosophy → +
+

Wiggleverse RFCs

+

+ A standards process for shared meaning between humans and machines. +

+ +

+ Large language models work brilliantly with programming languages because every + word in Python or C has a definitive meaning enforced by tooling. They struggle + with natural language because no such dictionary exists for words like + consent, trait, or agency — words that do enormous + work in any system that interacts with humans. The Wiggleverse RFC framework is + the standardization process for that vocabulary. Build the dictionary first. +

+ + Sign in with Gitea + Read the full philosophy → + +
    +
  • + One word per RFC. An RFC defines a single word — its meaning, + its relationships to other defined words, and the protocol by which humans and + machines interact with it. +
  • +
  • + Argued in public, with the model. Every definition is the + product of a transcript: a human and a model in careful argument until the + ambiguity is gone. The argument is the evidence the definition was earned. +
  • +
  • + Graduation is the load-bearing moment. A super-draft is the + start of the conversation. Graduation gives the definition a permanent home + and a stable identifier — and only then can other RFCs build on it. +
  • +
+
) } diff --git a/frontend/src/components/NotificationSettings.jsx b/frontend/src/components/NotificationSettings.jsx new file mode 100644 index 0000000..d9f7067 --- /dev/null +++ b/frontend/src/components/NotificationSettings.jsx @@ -0,0 +1,509 @@ +// /settings/notifications — the §15.4 / §15.5 / §15.6 / §15.8 surface. +// +// Topic 13 settled the schema and the per-category rules; Slice 6 wired +// the endpoints; Slice 7 lands the surface where a contributor finds +// the per-category email toggles, the digest cadence dropdown, the +// quiet-hours editor, the watches overview, and the per-user mute +// list. The §15.4 email footer's "Manage all preferences" link points +// at this route. +// +// Each sub-section is intentionally thin — the rules are settled in +// §15; this page renders them. The one piece of voice the spec +// commits to and the surface inherits: the `email_watched_churn` +// toggle renders as permanently disabled with the §15.4 refusal +// tooltip ("Per-commit and per-message email is intentionally not +// offered. The digest aggregates this activity weekly."). Naming the +// refusal is the spec's commitment; silently omitting the toggle +// would let the contract drift. + +import { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { + getNotificationPreferences, + setNotificationPreferences, + getQuietHours, + setQuietHours, + listWatches, + setWatch, + unmuteUser, + muteUser, + searchUsers, +} from '../api.js' + +const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.' + +export default function NotificationSettings({ viewer }) { + return ( +
+
+

Notification settings

+

+ Which signals reach you, how, and when. The rules live in §15; + this page is where you tune them. +

+
+ + + + + + +
+ ) +} + +// ── §15.4 email category toggles ─────────────────────────────────────────── + +function EmailPreferencesSection() { + const [prefs, setPrefs] = useState(null) + const [saving, setSaving] = useState(false) + const [savedNote, setSavedNote] = useState('') + const [error, setError] = useState(null) + + useEffect(() => { + getNotificationPreferences().then(setPrefs).catch(e => setError(e.message)) + }, []) + + async function update(field, value) { + setSaving(true) + setSavedNote('') + try { + await setNotificationPreferences({ [field]: value }) + setPrefs(p => ({ ...p, [field]: value })) + setSavedNote('Saved.') + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + setTimeout(() => setSavedNote(''), 1500) + } + } + + if (!prefs) return + + return ( + + update('email_personal_direct', v)} + disabled={saving} + /> + update('email_watched_structural', v)} + disabled={saving} + /> + update('email_admin_actionable', v)} + disabled={saving} + /> + + {!!prefs.email_opt_out_all && ( +

+ A hard bounce or complaint from your mailbox flipped the global + email opt-out. No email will be sent until you contact an admin + to clear the flag, even if individual categories are enabled. +

+ )} +

{savedNote}

+
+ ) +} + +// ── §15.5 digest cadence ─────────────────────────────────────────────────── + +function DigestCadenceSection() { + const [cadence, setCadence] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + getNotificationPreferences().then(p => setCadence(p.digest_cadence || 'weekly')) + }, []) + + async function update(value) { + setSaving(true) + try { + await setNotificationPreferences({ digest_cadence: value }) + setCadence(value) + } finally { + setSaving(false) + } + } + + return ( + +
+ +
+
+ ) +} + +// ── §15.8 quiet hours ────────────────────────────────────────────────────── + +function QuietHoursSection() { + const [data, setData] = useState(null) + const [draft, setDraft] = useState({ start: '', end: '', timezone: '' }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + // The browser ships an IANA tz list per §15.8 — preferable to a + // free-text field, since the API validates the trio. + const timezones = useMemo(() => { + try { + return Intl.supportedValuesOf('timeZone') + } catch { + return ['UTC'] + } + }, []) + + useEffect(() => { + getQuietHours().then(q => { + setData(q) + setDraft({ + start: q.start || '', + end: q.end || '', + timezone: q.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + }) + }) + }, []) + + async function save() { + setSaving(true) + setError(null) + try { + await setQuietHours({ start: draft.start, end: draft.end, timezone: draft.timezone }) + setData({ start: draft.start, end: draft.end, timezone: draft.timezone }) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + async function clear() { + setSaving(true) + setError(null) + try { + await setQuietHours({ start: null, end: null, timezone: null }) + setData({ start: null, end: null, timezone: null }) + setDraft({ start: '', end: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + if (!data) return + + const isSet = !!(data.start && data.end && data.timezone) + + return ( + +
+ + + +
+
+ + {isSet && ( + + )} +
+ {error &&

{error}

} +
+ ) +} + +// ── §15.6 watches overview ───────────────────────────────────────────────── + +function WatchesSection() { + const [watches, setWatches] = useState(null) + const [updating, setUpdating] = useState({}) + + useEffect(() => { listWatches().then(r => setWatches(r.items || [])) }, []) + + async function update(slug, state) { + setUpdating(u => ({ ...u, [slug]: true })) + try { + const r = await setWatch(slug, state) + setWatches(prev => prev.map(w => w.rfc_slug === slug + ? { ...w, state: r.state, set_by: 'explicit' } + : w + )) + } finally { + setUpdating(u => ({ ...u, [slug]: false })) + } + } + + if (!watches) return + + return ( + + {watches.length === 0 && ( +

No watches yet. Open an RFC, propose, or join a thread — auto-watch will set one for you.

+ )} + {watches.length > 0 && ( + + + + + + + + + + + {watches.map(w => ( + + + + + + + ))} + +
RFCStateSet byLast participation
+ {w.rfc_title || w.rfc_slug} + + + + + {w.set_by === 'explicit' ? 'You' : 'Auto'} + + + {w.last_participation_at || '—'} +
+ )} +
+ ) +} + +// ── §15.8 per-user mute list + typeahead add ─────────────────────────────── + +function MutesSection({ viewer }) { + const [mutes, setMutes] = useState(null) + const [error, setError] = useState(null) + + async function refresh() { + try { + const r = await listMutes() + setMutes(r.items || []) + } catch (e) { + setError(e.message) + } + } + + useEffect(() => { refresh() }, []) + + async function remove(userId) { + await unmuteUser(userId) + setMutes(prev => prev.filter(m => m.muted_user_id !== userId)) + } + + return ( + + {viewer.role === 'owner' || viewer.role === 'admin' ? ( +

+ Owners and admins cannot mute notifications — the role requires + receiving signals from everyone. (§15.8) +

+ ) : ( + + )} + {mutes && mutes.length === 0 && ( +

No active mutes.

+ )} + {mutes && mutes.length > 0 && ( +
    + {mutes.map(m => ( +
  • + @{m.gitea_login} + {m.display_name} + since {m.muted_at} + +
  • + ))} +
+ )} + {error &&

{error}

} +
+ ) +} + +// The mute-list read endpoint isn't a separate route in the API today +// (§17 names add/delete only); we read the join here against /api/users/me +// via a tiny dedicated endpoint that mirrors the watches shape. For +// Slice 7's v1 surface, we compute the list client-side from a server +// endpoint that returns the joined rows — added in api_admin.py's +// neighborhood for proximity. +async function listMutes() { + const res = await fetch('/api/users/me/notification-mutes') + if (!res.ok) { + const detail = await res.text() + throw new Error(detail || `HTTP ${res.status}`) + } + return res.json() +} + +function MuteTypeahead({ onMuted }) { + const [q, setQ] = useState('') + const [results, setResults] = useState([]) + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [hint, setHint] = useState('') + + useEffect(() => { + const t = setTimeout(() => { + searchUsers(q).then(r => setResults(r.items || [])) + }, 120) + return () => clearTimeout(t) + }, [q]) + + async function mute(user) { + setBusy(true) + setHint('') + try { + await muteUser(user.id) + setQ('') + setOpen(false) + onMuted?.() + } catch (e) { + setHint(e.message) + } finally { + setBusy(false) + } + } + + return ( +
+ { setQ(e.target.value); setOpen(true) }} + onFocus={() => setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 150)} + disabled={busy} + /> + {open && results.length > 0 && ( +
    + {results.map(r => ( +
  • + +
  • + ))} +
+ )} + {hint &&

{hint}

} +
+ ) +} + +// ── Small layout primitives ──────────────────────────────────────────────── + +function SectionShell({ title, subtitle, children }) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+
+ ) +} + +function Toggle({ label, description, checked, onChange, disabled, title }) { + return ( + + ) +} diff --git a/frontend/src/components/Philosophy.jsx b/frontend/src/components/Philosophy.jsx new file mode 100644 index 0000000..0c364ec --- /dev/null +++ b/frontend/src/components/Philosophy.jsx @@ -0,0 +1,61 @@ +// §14.2 — the `/philosophy` route. +// +// Renders PHILOSOPHY.md verbatim with light app chrome around it. +// Reachable by anonymous visitors (linked from the §14.1 landing) and +// by authenticated viewers (linked from the persistent §14.3 About +// header). The chrome here is small by design: a "Back" affordance +// that goes wherever the viewer came from, and a render of the +// markdown body. The §14.4 commitment ("not pushed at returning users +// via banners or modals") is the guardrail — this route serves the +// document, nothing else. +// +// The route is also the natural read surface for anonymous reachers: +// without a sign-in they cannot navigate the catalog, but they can +// read the philosophy that animates the work. + +import { useEffect, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { marked } from 'marked' +import { getPhilosophy } from '../api.js' + +export default function Philosophy({ authenticated }) { + const [body, setBody] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const navigate = useNavigate() + + useEffect(() => { + let active = true + getPhilosophy() + .then(r => { if (active) setBody(r.body || '') }) + .catch(e => { if (active) setError(e.message || String(e)) }) + .finally(() => { if (active) setLoading(false) }) + return () => { active = false } + }, []) + + const html = body ? marked.parse(body) : '' + + return ( +
+
+ + Why this exists + {!authenticated && ( + Home + )} +
+
+ {loading &&

Loading…

} + {error &&

Could not load the philosophy: {error}

} + {!loading && !error && ( +
+ )} +
+
+ ) +}