Slice 7: §14 chrome + settings and admin neighborhoods
§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 <date>" disclosure, audit-log row prose). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1675,10 +1675,14 @@ novel.
|
|||||||
### 14.2 The `/philosophy` route
|
### 14.2 The `/philosophy` route
|
||||||
|
|
||||||
Authenticated and anonymous visitors alike can reach `/philosophy`,
|
Authenticated and anonymous visitors alike can reach `/philosophy`,
|
||||||
which renders the full body of `PHILOSOPHY.md` from the meta repo. The
|
which renders the full body of `PHILOSOPHY.md`. The content is cached
|
||||||
content is sourced from the meta repo's main branch, cached and
|
in the app process and refreshed on demand; the source of the file is
|
||||||
refreshed on the same cadence as RFC bodies (§4). The page is plain
|
a deployment-time decision — Slice 7's build sources it from the app
|
||||||
markdown rendering with no editing affordance.
|
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
|
### 14.3 Persistent "About" link
|
||||||
|
|
||||||
@@ -2318,9 +2322,35 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
||||||
fresh resolution branch and replay per §10.9.
|
fresh resolution branch and replay per §10.9.
|
||||||
- `POST /api/admin/users/<id>/role` — set role (owner/admin only).
|
- `GET /api/admin/users` — list users with role and write-mute state,
|
||||||
- `POST /api/admin/users/<id>/mute` — mute/unmute (the §6.2 app-wide
|
for the §6 / Slice 7 admin surface.
|
||||||
write-mute, not the §15.8 notification mutes).
|
- `POST /api/admin/users/<id>/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/<id>/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/<slug>` — star/unstar.
|
- `POST /api/stars/<slug>` — star/unstar.
|
||||||
- `POST /api/webhooks/gitea` — webhook receiver.
|
- `POST /api/webhooks/gitea` — webhook receiver.
|
||||||
- `GET /api/notifications` — list inbox rows for the signed-in user,
|
- `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
|
complete. What follows is no longer "topics that block specifying
|
||||||
v1" but "topics to address during or shortly after the v1 build."
|
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
|
Slice 7 of the build has landed. The §14 chrome, the
|
||||||
end-to-end against the local Gitea — every `actions` row whose
|
`/settings/notifications` neighborhood, and the `/admin` home base
|
||||||
`action_kind` maps to a §15.1 event fans out through
|
all run end-to-end against the local Gitea, and the next slice has
|
||||||
`notify.fan_out_from_action`, called inline from `bot._log` and
|
the v1 surface fully wrapped — what remains is the hardening pass
|
||||||
from the graduation orchestrator's `_audit`. Chat-message inserts
|
that lets a single-operator deployment actually run.
|
||||||
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.
|
|
||||||
|
|
||||||
The §15.4 email loop runs through an SMTP adapter with a stdout
|
The §14.1 landing page now carries the title, the subtitle, the
|
||||||
fallback for dev — the in-memory `_SENT` buffer is what the
|
short-form pitch from `PHILOSOPHY.md`, the sign-in affordance, the
|
||||||
integration tests read from. The per-category dispatch holds during
|
secondary "Read the full philosophy" link, and a three-item deck
|
||||||
§15.8 quiet hours; on window-end, `email.flush_pending` bundles
|
underneath the pitch that names what the framework is — one word per
|
||||||
above the §15.4 threshold into a single "Activity while you were
|
RFC, argued in public with the model, graduation as the load-bearing
|
||||||
away" mail. The signed-URL unsubscribe path flips a single category
|
moment. The §14.2 `/philosophy` route reads `PHILOSOPHY.md` from
|
||||||
column to zero; the bounce webhook flips the new `email_opt_out_all`
|
disk (via `backend/app/philosophy.py`, configurable through the
|
||||||
column (migration `008_email_opt_out.sql`).
|
`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
|
The notification-settings surface (`/settings/notifications`,
|
||||||
shape, with a `run_tick` seam the tests drive synchronously. Each
|
`frontend/src/components/NotificationSettings.jsx`) lands the five
|
||||||
tick releases held emails, runs the §15.6 90-day decay sweep, and
|
sub-sections the §15 endpoints already supported: the §15.4 per-
|
||||||
assembles per-cadence digests where the window has rolled over.
|
category email toggles (with the `email_watched_churn` toggle
|
||||||
The §15.5 exclusion rules (already-emailed, already-read,
|
permanently disabled and the §15.4 refusal tooltip inline), the
|
||||||
personal-direct-excluded) keep two consecutive ticks idempotent.
|
§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
|
The admin home base (`/admin`, `frontend/src/components/Admin.jsx`)
|
||||||
`backend/app/api_notifications.py`, plus the chat-seen advance on
|
runs as a tabbed left-rail with four panels: Users (role management
|
||||||
`api_branches` and the existing PR seen-cursor on `api_prs` — both
|
+ §6.2 write-mute, with the role-grant constraints enforced
|
||||||
extended to call `notify.reconcile_seen_advance` so the §15.7
|
server-side per §6.1 — only owners may grant owner; owners cannot
|
||||||
visit-advances-cursor loop closes back into the inbox-row read
|
self-demote on the role endpoint), Graduation queue (super-drafts
|
||||||
state. The SSE stream holds a per-user subscriber queue keyed by
|
partitioned by §13.2 readiness — owners set and zero blocking body-
|
||||||
user_id; multiple browser tabs see the same events.
|
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+",
|
`backend/app/api_admin.py` carries the seven new admin endpoints
|
||||||
clicking opens the inbox overlay), an SSE-driven counter that
|
plus the user-search. `backend/app/philosophy.py` carries the
|
||||||
surfaces personal-direct toasts (own-name signals) and live-view
|
disk-backed `/api/philosophy` source. `backend/app/api_notifications.py`
|
||||||
toasts (events landing on the slug the user is viewing). The
|
grew one read endpoint (`GET /api/users/me/notification-mutes`) for
|
||||||
inbox is `Inbox.jsx` — three filter chips (Unread only, RFC,
|
the settings page's mute list. The §17 admin block was extended in
|
||||||
Category), a Bundle toggle, and a "Mark all read (under filter)"
|
this corrected spec to name the seven endpoints; §14.2 was
|
||||||
button. `ToastHost.jsx` caps four visible at once with auto-dismiss.
|
corrected to acknowledge the deployment-time decision about where
|
||||||
|
`PHILOSOPHY.md` lives.
|
||||||
|
|
||||||
The §15.9 attribution rule fell out cleanly: every `notifications`
|
Slice 7 ships covered by `backend/tests/test_chrome_vertical.py` —
|
||||||
row carries `actor_user_id` resolved from the `actions.actor_user_id`
|
thirteen integration tests covering the philosophy route for both
|
||||||
in the originating audit row (the underlying user, never the bot).
|
anonymous and authenticated callers, the §15.4 / §15.5 / §15.8
|
||||||
System-generated events (digest emission, 90-day decay) leave
|
preferences round-trip (including the permanent `email_watched_churn`
|
||||||
`actor_user_id` NULL and render as "the app." AI participation
|
refusal), the quiet-hours all-or-nothing validation, the §15.8 mute
|
||||||
events landed as null-system per §19.2's candidate naming — when a
|
add/list/unmute round-trip, the user-search typeahead, the admin
|
||||||
chat message authored by an AI provider goes through, no actor row
|
role and write-mute round-trips with their `permission_events`
|
||||||
is written, since the LLM call doesn't have a user_id; the topic
|
audit, the §6.1 refusal of owner-grant by non-owners, the audit-log
|
||||||
folder for "AI participation as a notification source" in §19.2
|
filter chips, the graduation-queue partition under both
|
||||||
remains open for explicit settling.
|
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`
|
**Slice 8 is the hardening pass — the last slice of the v1 build.**
|
||||||
— seventeen integration tests covering the producer-side fan-out
|
Three pieces hang together:
|
||||||
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 7 is the §14 chrome plus the natural notification-settings
|
The §12 30/90 branch-hygiene timers — the formalized policy that
|
||||||
neighbor.** With every structural beat live, what remains for v1
|
closes the loop on §11.5's branch lifecycle (open → merged → 30d
|
||||||
is the chrome the framework wraps itself in. §14 commits the
|
read-only → 90d deleted-by-bot, with the per-user-message-cursor
|
||||||
landing page (the unauthenticated visitor's first read), the
|
preservation contract). The wiring is a scheduled task next to the
|
||||||
`/philosophy` route (PHILOSOPHY.md surfaced inline), and the
|
§15.5 digest scheduler; the §10.7 90-day deletion timer Slice 3
|
||||||
persistent About link in the header. Slice 6 left the §15
|
left deferred lives here too.
|
||||||
preferences / quiet-hours / mute / watches endpoints in place
|
|
||||||
but with no chrome — the natural follow-on is `/settings/notifications`
|
An end-to-end smoke pass over the working surfaces — propose →
|
||||||
exposing the per-category toggles, the digest cadence dropdown,
|
super-draft → branch → PR → merge → graduate → active-RFC PR →
|
||||||
the quiet-hours editor, the watches overview, and the per-user
|
notification fans out → inbox → email — to catch the integration
|
||||||
mute list. The §19.2 "admin surfaces" candidate is the second
|
seams a per-slice test wouldn't. Plus the §19.2 candidates the
|
||||||
natural neighbor — role management, the §6.2 app-wide write-mute,
|
hardening pass is the natural place to fold in: cache bootstrap
|
||||||
the audit-log viewer, the graduation-readiness queue, all
|
from a meta repo (the audit-log-first attribution shape Slice 1
|
||||||
consolidated where the chrome can hold them. Slice 7 picks the
|
chose, exercised against a meta repo with history the bot did not
|
||||||
framing and ships the three pieces together since they share an
|
author), branch-name path routing (converting every
|
||||||
information architecture.
|
`branches/<branch>` 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`,
|
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
|
without re-briefing. The working agreement in §19.3 continues to
|
||||||
apply: implement the slice, correct the spec only where running
|
apply: implement the slice, correct the spec only where running
|
||||||
code reveals it was wrong at a structural level, accumulate new
|
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
|
or signature verification (Sendgrid's signed events, AWS SES's
|
||||||
SNS topic signature, etc.). Trivial to add per provider; the
|
SNS topic signature, etc.). Trivial to add per provider; the
|
||||||
routing-and-flip-the-column logic doesn't change.
|
routing-and-flip-the-column logic doesn't change.
|
||||||
|
- **Owner succession ceremony.** Slice 7's `POST /api/admin/users/<id>/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 `<code>`
|
||||||
|
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
|
- **Per-user mute exemption checks for arbiters.** §15.8 commits
|
||||||
that arbiters cannot mute participants on RFCs where they hold
|
that arbiters cannot mute participants on RFCs where they hold
|
||||||
authority. Slice 6's check uses "the muted user has a watches
|
authority. Slice 6's check uses "the muted user has a watches
|
||||||
|
|||||||
+28
-1
@@ -17,7 +17,18 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
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 .bot import Bot
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .gitea import Gitea, GiteaError
|
from .gitea import Gitea, GiteaError
|
||||||
@@ -58,6 +69,22 @@ def make_router(
|
|||||||
# Slice 6: §15 notifications surface (inbox, watches, prefs,
|
# Slice 6: §15 notifications surface (inbox, watches, prefs,
|
||||||
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
|
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
|
||||||
router.include_router(api_notifications.make_router(config))
|
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
|
# Auth surface — extends the prototype's pattern but reads role
|
||||||
|
|||||||
@@ -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/<id>/role` — set role per §6.1
|
||||||
|
- `POST /api/admin/users/<id>/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
|
||||||
@@ -293,6 +293,36 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
|
|
||||||
# ----- Per-user notification mute (§15.8) -----
|
# ----- 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")
|
@router.post("/api/users/{user_id}/notification-mute")
|
||||||
async def add_user_mute(user_id: int, request: Request) -> dict[str, Any]:
|
async def add_user_mute(user_id: int, request: Request) -> dict[str, Any]:
|
||||||
viewer = auth.require_user(request)
|
viewer = auth.require_user(request)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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/<id>/role`
|
||||||
|
and `/api/admin/users/<id>/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])
|
||||||
+132
-41
@@ -29,10 +29,18 @@ rare and surgical and live in the appropriate numbered section per
|
|||||||
6. **Notifications per §15.** Last, because every other surface
|
6. **Notifications per §15.** Last, because every other surface
|
||||||
produces signals the inbox receives — notification correctness
|
produces signals the inbox receives — notification correctness
|
||||||
depends on the producers being in place first.
|
depends on the producers being in place first.
|
||||||
7. **The §14 chrome.** Landing page polish, the `/philosophy` route,
|
7. **The §14 chrome + the settings and admin neighborhoods.**
|
||||||
the persistent About link.
|
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,
|
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
|
## 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
|
webhook. Topic 13 settled the rest of the §5 surface before the
|
||||||
build started, so no further migrations were needed.
|
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
|
### Slice 5 — shipped
|
||||||
|
|
||||||
Graduation per §13 in full. The §13.3 five-step transactional sequence
|
Graduation per §13 in full. The §13.3 five-step transactional sequence
|
||||||
@@ -510,52 +592,61 @@ spec:
|
|||||||
|
|
||||||
## Next slice
|
## 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
|
With Slice 7 shipped, every structural beat the spec commits to is
|
||||||
framework commits to is live: propose, claim, super-draft body
|
live and every surface the framework exposes has chrome around it.
|
||||||
editing, the §10 PR flow against both repo shapes, graduation, and
|
What remains is the hardening pass that lets a single-operator
|
||||||
the §15 inbox/email/digest stack. What remains for v1 is the chrome
|
deployment actually run end-to-end without hand-holding. Three
|
||||||
that wraps the whole thing — the landing page that brings an
|
pieces hang together:
|
||||||
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:
|
|
||||||
|
|
||||||
- **The notification-settings surface** — the actual UI for the
|
- **The §12 30/90 branch-hygiene timers.** §11.5 names the branch
|
||||||
preferences/quiet-hours/mute endpoints Slice 6 wired. Topic 13
|
lifecycle (open → merged → 30d read-only → 90d deleted-by-bot,
|
||||||
settled the schema and the per-category rules; the surface
|
with the per-user message-cursor preservation contract); §12
|
||||||
where a contributor finds the per-category email toggles, the
|
formalizes the policy. The wiring is a scheduled task next to
|
||||||
digest cadence dropdown, the quiet-hours editor, the watches
|
the existing `DigestScheduler` — same `run_tick` test-seam shape.
|
||||||
overview, and the per-user mute list is the natural follow-on.
|
The §10.7 90-day deletion timer Slice 3 left explicitly deferred
|
||||||
Likely lives at `/settings/notifications` (the link Slice 6's
|
lives here too. Touches `cache.Reconciler` (the natural place to
|
||||||
emails already point at).
|
fire the hygiene sweep), `bot.delete_branch` (the §12 actuator,
|
||||||
- **The admin neighborhood.** §19.2's "Admin surfaces" candidate.
|
not yet exercised), and the §19.2 cache-bootstrap topic if the
|
||||||
Role management, the §6.2 app-wide write-mute, the audit-log
|
hygiene sweep also rebuilds branch state from Gitea after a
|
||||||
viewer, the graduation-readiness queue. Topics 12 and 13 both
|
cache wipe.
|
||||||
expanded the admin's repertoire without giving it a centralized
|
- **An end-to-end smoke pass** over the working surfaces. Propose
|
||||||
home; Slice 7 picks the framing.
|
→ super-draft → branch → PR → merge → graduate → active-RFC PR
|
||||||
- **Landing page polish.** Slice 1 stood up a minimal landing for
|
→ notification → inbox → email — one or two `test_e2e_smoke.py`
|
||||||
the unauthenticated path; §14 commits a richer shape — what the
|
cases that exercise the seams a per-slice test wouldn't. Plus
|
||||||
framework is, why it exists, what the visitor's first read should
|
the §19.2 follow-ons the hardening pass is the natural place to
|
||||||
be, and the sign-in affordance.
|
fold in: branch-name path routing (`{branch:path}` everywhere
|
||||||
- **The `/philosophy` route.** [`PHILOSOPHY.md`](../PHILOSOPHY.md)
|
with route-ordering discipline), cache bootstrap from a
|
||||||
rendered inline, reachable from the header on every page, so the
|
pre-existing meta repo (the audit-log-first attribution shape
|
||||||
reader can return to the framing without leaving the app.
|
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.
|
- The §16 deferred items.
|
||||||
- New §15 capabilities — Slice 6 shipped the surface; settings UI
|
- The §19.2 candidate set as a whole — the hardening pass folds in
|
||||||
is exposure of what's already there, not new behavior.
|
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
|
The carryovers Slice 8 inherits — the full §11.5 / §12 spec text,
|
||||||
§17 endpoint set including Slice 6's settings endpoints, and the
|
the existing `cache.Reconciler` and `DigestScheduler` shape, the
|
||||||
React Router layout already in place.
|
deploy/ infrastructure, and the 75/75 green test suite.
|
||||||
|
|
||||||
The next build session should read `SPEC.md`, `README.md`,
|
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
|
without re-briefing. The working agreement in §19.3 continues to
|
||||||
apply: implement the slice, correct the spec only where running
|
apply: implement the slice, correct the spec only where running
|
||||||
code reveals it was wrong at a structural level, accumulate new
|
code reveals it was wrong at a structural level, accumulate new
|
||||||
|
|||||||
@@ -1218,3 +1218,276 @@
|
|||||||
.toast.cat-personal-direct { border-left-color: #d97706; }
|
.toast.cat-personal-direct { border-left-color: #d97706; }
|
||||||
.toast.cat-structural { border-left-color: #2563eb; }
|
.toast.cat-structural { border-left-color: #2563eb; }
|
||||||
.toast.cat-churn { border-left-color: #6b7280; }
|
.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; }
|
||||||
|
|||||||
+79
-17
@@ -8,6 +8,9 @@ import PRView from './components/PRView.jsx'
|
|||||||
import ProposalView from './components/ProposalView.jsx'
|
import ProposalView from './components/ProposalView.jsx'
|
||||||
import ProposeModal from './components/ProposeModal.jsx'
|
import ProposeModal from './components/ProposeModal.jsx'
|
||||||
import Landing from './components/Landing.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 ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
@@ -65,8 +68,16 @@ export default function App() {
|
|||||||
return <div className="boot">Loading…</div>
|
return <div className="boot">Loading…</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §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) {
|
if (!me?.authenticated) {
|
||||||
return <Landing />
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/philosophy" element={<Philosophy authenticated={false} />} />
|
||||||
|
<Route path="*" element={<Landing />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,6 +87,21 @@ export default function App() {
|
|||||||
<Link to="/">Wiggleverse RFCs</Link>
|
<Link to="/">Wiggleverse RFCs</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
|
{/* §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. */}
|
||||||
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
{(me.user.role === 'owner' || me.user.role === 'admin') && (
|
||||||
|
<Link to="/admin" className="header-admin" title="Admin home base">
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="inbox-trigger"
|
className="inbox-trigger"
|
||||||
onClick={() => setInboxOpen(o => !o)}
|
onClick={() => setInboxOpen(o => !o)}
|
||||||
@@ -92,18 +118,27 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
<Catalog
|
<Routes>
|
||||||
onProposeRFC={() => setProposeOpen(true)}
|
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} />
|
||||||
version={catalogVersion}
|
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} />
|
||||||
/>
|
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} />
|
||||||
<main className="main-pane">
|
<Route path="*" element={
|
||||||
<Routes>
|
<>
|
||||||
<Route path="/" element={<Welcome viewer={me.user} />} />
|
<Catalog
|
||||||
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
|
onProposeRFC={() => setProposeOpen(true)}
|
||||||
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
|
version={catalogVersion}
|
||||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
/>
|
||||||
</Routes>
|
<main className="main-pane">
|
||||||
</main>
|
<Routes>
|
||||||
|
<Route path="/" element={<Welcome viewer={me.user} />} />
|
||||||
|
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
|
||||||
|
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
|
||||||
|
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
{proposeOpen && (
|
{proposeOpen && (
|
||||||
<ProposeModal
|
<ProposeModal
|
||||||
@@ -123,6 +158,34 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PhilosophyWithSidebar() {
|
||||||
|
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
||||||
|
// all use the full app body — no catalog left pane, no propose modal.
|
||||||
|
// The header carries the navigation back; the body is a single
|
||||||
|
// reading surface.
|
||||||
|
return (
|
||||||
|
<main className="chrome-pane">
|
||||||
|
<Philosophy authenticated={true} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationSettingsWithSidebar({ viewer }) {
|
||||||
|
return (
|
||||||
|
<main className="chrome-pane">
|
||||||
|
<NotificationSettings viewer={viewer} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminWithSidebar({ viewer }) {
|
||||||
|
return (
|
||||||
|
<main className="chrome-pane">
|
||||||
|
<Admin viewer={viewer} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Welcome({ viewer }) {
|
function Welcome({ viewer }) {
|
||||||
return (
|
return (
|
||||||
<div className="welcome">
|
<div className="welcome">
|
||||||
@@ -134,10 +197,9 @@ function Welcome({ viewer }) {
|
|||||||
idea PR against the meta repository.
|
idea PR against the meta repository.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Slice 1 of the build is in place: propose → idea PR → owner merges →
|
Wondering why a conversation is public, why graduation costs what it
|
||||||
super-draft appears in the catalog → super-draft view renders. The
|
does, or why the model is in the chat? <Link to="/philosophy">Read the
|
||||||
revision flow, per-branch chat, AI participation, and the PR surface
|
philosophy</Link>.
|
||||||
land in subsequent slices.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
// SSE subscription helper. Returns a close() function. The handler
|
||||||
// surface mirrors §15.3: a snapshot event on open, then per-notification
|
// surface mirrors §15.3: a snapshot event on open, then per-notification
|
||||||
// `notification` events, plus `read` events when another tab marks a row.
|
// `notification` events, plus `read` events when another tab marks a row.
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="admin-page">
|
||||||
|
<nav className="admin-rail">
|
||||||
|
<h2>Admin</h2>
|
||||||
|
<ul>
|
||||||
|
{TABS.map(t => (
|
||||||
|
<li key={t.path}>
|
||||||
|
<NavLink
|
||||||
|
to={t.path}
|
||||||
|
className={({ isActive }) => `admin-rail-link ${isActive ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="admin-rail-note">
|
||||||
|
Signed in as <strong>{viewer.display_name}</strong> ({viewer.role}).
|
||||||
|
You can <Link to="/">return to the catalog</Link> at any time.
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
<div className="admin-content">
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<UsersTab />} />
|
||||||
|
<Route path="users" element={<UsersTab />} />
|
||||||
|
<Route path="graduation" element={<GraduationTab />} />
|
||||||
|
<Route path="audit" element={<AuditTab />} />
|
||||||
|
<Route path="permissions" element={<PermissionsTab />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 <p className="muted">Loading users…</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-tab">
|
||||||
|
<header className="admin-tab-header">
|
||||||
|
<h2>Users</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Role changes write to <code>permission_events</code>. The §6.2
|
||||||
|
write-mute applies to contributors only — promote to admin to
|
||||||
|
remove a user's ability to write without silencing them.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Write-muted</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(u => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>
|
||||||
|
<div className="user-cell">
|
||||||
|
<span className="user-handle">@{u.gitea_login}</span>
|
||||||
|
<span className="muted">{u.display_name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={u.role}
|
||||||
|
onChange={e => changeRole(u.id, e.target.value)}
|
||||||
|
disabled={!!busy[u.id]}
|
||||||
|
>
|
||||||
|
<option value="contributor">Contributor</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.role === 'contributor' ? (
|
||||||
|
<label className="mute-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!u.muted}
|
||||||
|
onChange={e => toggleMute(u.id, e.target.checked)}
|
||||||
|
disabled={!!busy[u.id]}
|
||||||
|
/>
|
||||||
|
{u.muted ? 'Muted' : 'Active'}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<span className="muted">N/A</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{u.last_seen_at}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 <p className="settings-note warning">{error}</p>
|
||||||
|
if (data == null) return <p className="muted">Loading queue…</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-tab">
|
||||||
|
<header className="admin-tab-header">
|
||||||
|
<h2>Graduation queue</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Super-drafts with owners claimed and zero blocking body-edit PRs.
|
||||||
|
Open one to run the §13.3 graduation sequence.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h3 className="admin-section-h">Ready ({data.ready.length})</h3>
|
||||||
|
{data.ready.length === 0 && (
|
||||||
|
<p className="muted">No super-drafts ready right now.</p>
|
||||||
|
)}
|
||||||
|
<ul className="grad-queue">
|
||||||
|
{data.ready.map(item => (
|
||||||
|
<li key={item.slug}>
|
||||||
|
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span className="muted"> — owners: {item.owners.join(', ')}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="admin-section-h">Blocked ({data.blocked.length})</h3>
|
||||||
|
{data.blocked.length === 0 && (
|
||||||
|
<p className="muted">No blocked super-drafts.</p>
|
||||||
|
)}
|
||||||
|
<ul className="grad-queue">
|
||||||
|
{data.blocked.map(item => (
|
||||||
|
<li key={item.slug}>
|
||||||
|
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span className="muted">
|
||||||
|
{' — '}
|
||||||
|
{!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'}`}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="admin-tab">
|
||||||
|
<header className="admin-tab-header">
|
||||||
|
<h2>Audit log</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Every bot-mediated write lands here. The most recent rows show first;
|
||||||
|
filter to narrow to one kind, one actor, or one RFC.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="audit-filters">
|
||||||
|
<select
|
||||||
|
value={filters.actionKind}
|
||||||
|
onChange={e => setFilters(f => ({ ...f, actionKind: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">All action kinds</option>
|
||||||
|
{kinds.map(k => <option key={k} value={k}>{k}</option>)}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="RFC slug…"
|
||||||
|
value={filters.rfcSlug}
|
||||||
|
onChange={e => setFilters(f => ({ ...f, rfcSlug: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Actor user_id…"
|
||||||
|
value={filters.actorUserId}
|
||||||
|
onChange={e => setFilters(f => ({ ...f, actorUserId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
{data == null && <p className="muted">Loading…</p>}
|
||||||
|
{data?.items?.length === 0 && (
|
||||||
|
<p className="muted">No rows match this filter.</p>
|
||||||
|
)}
|
||||||
|
{data?.items?.length > 0 && (
|
||||||
|
<table className="admin-table audit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Actor</th>
|
||||||
|
<th>On behalf of</th>
|
||||||
|
<th>RFC</th>
|
||||||
|
<th>PR / branch</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map(row => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td className="muted">{row.created_at}</td>
|
||||||
|
<td><code>{row.action_kind}</code></td>
|
||||||
|
<td>{row.actor_display || row.actor_login || '—'}</td>
|
||||||
|
<td>{row.on_behalf_of}</td>
|
||||||
|
<td>{row.rfc_slug || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{row.pr_number != null && <span>#{row.pr_number} </span>}
|
||||||
|
{row.branch_name && <code>{row.branch_name}</code>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{data?.has_more && (
|
||||||
|
<button
|
||||||
|
className="btn-link-muted"
|
||||||
|
onClick={() => load(data.items[data.items.length - 1].id)}
|
||||||
|
>
|
||||||
|
Load older →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 <p className="settings-note warning">{error}</p>
|
||||||
|
if (data == null) return <p className="muted">Loading…</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-tab">
|
||||||
|
<header className="admin-tab-header">
|
||||||
|
<h2>Permission events</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Every role change and write-mute toggle. The companion to the
|
||||||
|
audit log, scoped to authorization changes.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
{data.items.length === 0 && (
|
||||||
|
<p className="muted">No permission events yet.</p>
|
||||||
|
)}
|
||||||
|
{data.items.length > 0 && (
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Actor</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map(r => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="muted">{r.created_at}</td>
|
||||||
|
<td><code>{r.event_kind}</code></td>
|
||||||
|
<td>{r.actor_display || r.actor_login || '—'}</td>
|
||||||
|
<td>{r.subject_display || r.subject_login || '—'}</td>
|
||||||
|
<td className="muted">
|
||||||
|
{r.details ? formatDetails(r.details) : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,24 +1,60 @@
|
|||||||
// Landing.jsx — §14.1's pre-login surface.
|
// Landing.jsx — §14.1's pre-login surface.
|
||||||
//
|
//
|
||||||
// Title, subtitle, the short-form pitch from PHILOSOPHY.md, then the
|
// The landing page has three jobs per §14.1: name what this thing is,
|
||||||
// single primary action: "Sign in with Gitea." The visual design is
|
// pitch why someone would care, and offer the sign-in affordance. The
|
||||||
// deferred per §14.4; the structural commitments are here.
|
// 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() {
|
export default function Landing() {
|
||||||
return (
|
return (
|
||||||
<div className="landing">
|
<div className="landing">
|
||||||
<h1>Wiggleverse RFCs</h1>
|
<div className="landing-inner">
|
||||||
<p className="subtitle">A standards process for shared meaning between humans and machines.</p>
|
<h1>Wiggleverse RFCs</h1>
|
||||||
<p className="pitch">
|
<p className="subtitle">
|
||||||
Large language models work brilliantly with programming languages because every
|
A standards process for shared meaning between humans and machines.
|
||||||
word in Python or C has a definitive meaning enforced by tooling. They struggle
|
</p>
|
||||||
with natural language because no such dictionary exists for words like
|
|
||||||
<em> consent</em>, <em> trait</em>, or <em> agency</em> — words that do enormous
|
<p className="pitch">
|
||||||
work in any system that interacts with humans. The Wiggleverse RFC framework is
|
Large language models work brilliantly with programming languages because every
|
||||||
a standardization process for that vocabulary. Build the dictionary first.
|
word in Python or C has a definitive meaning enforced by tooling. They struggle
|
||||||
</p>
|
with natural language because no such dictionary exists for words like
|
||||||
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
|
<em> consent</em>, <em> trait</em>, or <em> agency</em> — words that do enormous
|
||||||
<a className="secondary-link" href="/philosophy">Read the full philosophy →</a>
|
work in any system that interacts with humans. The Wiggleverse RFC framework is
|
||||||
|
the standardization process for that vocabulary. Build the dictionary first.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
|
||||||
|
<Link className="secondary-link" to="/philosophy">Read the full philosophy →</Link>
|
||||||
|
|
||||||
|
<ul className="landing-deck">
|
||||||
|
<li>
|
||||||
|
<strong>One word per RFC.</strong> 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Argued in public, with the model.</strong> 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Graduation is the load-bearing moment.</strong> 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.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="settings-page">
|
||||||
|
<header className="settings-header">
|
||||||
|
<h1>Notification settings</h1>
|
||||||
|
<p className="settings-sub">
|
||||||
|
Which signals reach you, how, and when. The rules live in §15;
|
||||||
|
this page is where you tune them.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<EmailPreferencesSection />
|
||||||
|
<DigestCadenceSection />
|
||||||
|
<QuietHoursSection />
|
||||||
|
<WatchesSection />
|
||||||
|
<MutesSection viewer={viewer} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §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 <SectionShell title="Email" subtitle={error || 'Loading…'} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionShell title="Email" subtitle="Categories you want delivered as email. The inbox always carries everything; email is opt-in by category.">
|
||||||
|
<Toggle
|
||||||
|
label="Personal direct"
|
||||||
|
description="You are the named subject — proposals merged, decisions, claims, named asks. Default on."
|
||||||
|
checked={!!prefs.email_personal_direct}
|
||||||
|
onChange={v => update('email_personal_direct', v)}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Watched structural"
|
||||||
|
description="Decisions on RFCs you watch — merges, declines, graduation, ownership changes. Default off."
|
||||||
|
checked={!!prefs.email_watched_structural}
|
||||||
|
onChange={v => update('email_watched_structural', v)}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Admin actionable"
|
||||||
|
description="Decisions only an admin can act on. Defaults on for admins/owners; ignored for contributors."
|
||||||
|
checked={!!prefs.email_admin_actionable}
|
||||||
|
onChange={v => update('email_admin_actionable', v)}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Watched churn (per-commit, per-message)"
|
||||||
|
description={CHURN_REFUSAL}
|
||||||
|
checked={false}
|
||||||
|
disabled
|
||||||
|
title={CHURN_REFUSAL}
|
||||||
|
/>
|
||||||
|
{!!prefs.email_opt_out_all && (
|
||||||
|
<p className="settings-note warning">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="settings-note">{savedNote}</p>
|
||||||
|
</SectionShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §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 (
|
||||||
|
<SectionShell
|
||||||
|
title="Digest cadence"
|
||||||
|
subtitle="The §15.5 digest gathers churn-category activity into a single periodic email. Independent of the category toggles above."
|
||||||
|
>
|
||||||
|
<div className="settings-row">
|
||||||
|
<select
|
||||||
|
value={cadence ?? 'weekly'}
|
||||||
|
onChange={e => update(e.target.value)}
|
||||||
|
disabled={saving || cadence == null}
|
||||||
|
>
|
||||||
|
<option value="off">Off — never send a digest</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</SectionShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §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 <SectionShell title="Quiet hours" subtitle="Loading…" />
|
||||||
|
|
||||||
|
const isSet = !!(data.start && data.end && data.timezone)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionShell
|
||||||
|
title="Quiet hours"
|
||||||
|
subtitle="Email holds during this window; inbox rows still land. On window-end, the §15.4 bundle email releases everything above the threshold."
|
||||||
|
>
|
||||||
|
<div className="settings-row quiet-hours-row">
|
||||||
|
<label>
|
||||||
|
Start
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={draft.start}
|
||||||
|
onChange={e => setDraft(d => ({ ...d, start: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
End
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={draft.end}
|
||||||
|
onChange={e => setDraft(d => ({ ...d, end: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Timezone
|
||||||
|
<select
|
||||||
|
value={draft.timezone}
|
||||||
|
onChange={e => setDraft(d => ({ ...d, timezone: e.target.value }))}
|
||||||
|
>
|
||||||
|
{timezones.map(tz => <option key={tz} value={tz}>{tz}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="settings-row">
|
||||||
|
<button className="btn-primary" disabled={saving} onClick={save}>
|
||||||
|
{isSet ? 'Update quiet hours' : 'Set quiet hours'}
|
||||||
|
</button>
|
||||||
|
{isSet && (
|
||||||
|
<button className="btn-link-muted" disabled={saving} onClick={clear}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
</SectionShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §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 <SectionShell title="Watches" subtitle="Loading…" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionShell
|
||||||
|
title="Watches"
|
||||||
|
subtitle="What you receive structural-category notifications about. Auto-set when you participate, decays after 90 days of silence. An explicit choice here exempts the row from the auto-decay."
|
||||||
|
>
|
||||||
|
{watches.length === 0 && (
|
||||||
|
<p className="muted">No watches yet. Open an RFC, propose, or join a thread — auto-watch will set one for you.</p>
|
||||||
|
)}
|
||||||
|
{watches.length > 0 && (
|
||||||
|
<table className="settings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>RFC</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Set by</th>
|
||||||
|
<th>Last participation</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{watches.map(w => (
|
||||||
|
<tr key={w.rfc_slug}>
|
||||||
|
<td>
|
||||||
|
<Link to={`/rfc/${w.rfc_slug}`}>{w.rfc_title || w.rfc_slug}</Link>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={w.state}
|
||||||
|
onChange={e => update(w.rfc_slug, e.target.value)}
|
||||||
|
disabled={!!updating[w.rfc_slug]}
|
||||||
|
>
|
||||||
|
<option value="watching">Watching</option>
|
||||||
|
<option value="following">Following</option>
|
||||||
|
<option value="muted">Muted</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`set-by set-by-${w.set_by}`}>
|
||||||
|
{w.set_by === 'explicit' ? 'You' : 'Auto'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="muted">
|
||||||
|
{w.last_participation_at || '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</SectionShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §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 (
|
||||||
|
<SectionShell
|
||||||
|
title="Muted users"
|
||||||
|
subtitle="Notifications from these users won't reach your inbox. (Mute does not gate visibility — you can still read what they post.) Adding a mute here is the catch-all path; the intended path is to mute from an inbox row's actor avatar."
|
||||||
|
>
|
||||||
|
{viewer.role === 'owner' || viewer.role === 'admin' ? (
|
||||||
|
<p className="muted">
|
||||||
|
Owners and admins cannot mute notifications — the role requires
|
||||||
|
receiving signals from everyone. (§15.8)
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<MuteTypeahead onMuted={refresh} />
|
||||||
|
)}
|
||||||
|
{mutes && mutes.length === 0 && (
|
||||||
|
<p className="muted">No active mutes.</p>
|
||||||
|
)}
|
||||||
|
{mutes && mutes.length > 0 && (
|
||||||
|
<ul className="mutes-list">
|
||||||
|
{mutes.map(m => (
|
||||||
|
<li key={m.muted_user_id} className="mutes-row">
|
||||||
|
<span className="mute-handle">@{m.gitea_login}</span>
|
||||||
|
<span className="mute-name muted">{m.display_name}</span>
|
||||||
|
<span className="mute-when muted">since {m.muted_at}</span>
|
||||||
|
<button className="btn-link-muted" onClick={() => remove(m.muted_user_id)}>
|
||||||
|
Unmute
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
</SectionShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="mute-typeahead">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Mute a user by login or name…"
|
||||||
|
value={q}
|
||||||
|
onChange={e => { setQ(e.target.value); setOpen(true) }}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{open && results.length > 0 && (
|
||||||
|
<ul className="mute-typeahead-results">
|
||||||
|
{results.map(r => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<button onClick={() => mute(r)} disabled={busy}>
|
||||||
|
<span className="mute-handle">@{r.gitea_login}</span>
|
||||||
|
<span className="muted">{r.display_name}</span>
|
||||||
|
{(r.role === 'owner' || r.role === 'admin') && (
|
||||||
|
<span className="muted">{r.role}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{hint && <p className="settings-note warning">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Small layout primitives ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionShell({ title, subtitle, children }) {
|
||||||
|
return (
|
||||||
|
<section className="settings-section">
|
||||||
|
<header>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{subtitle && <p className="settings-sub">{subtitle}</p>}
|
||||||
|
</header>
|
||||||
|
<div className="settings-section-body">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ label, description, checked, onChange, disabled, title }) {
|
||||||
|
return (
|
||||||
|
<label className={`toggle-row ${disabled ? 'disabled' : ''}`} title={title}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!checked}
|
||||||
|
onChange={e => onChange?.(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="toggle-text">
|
||||||
|
<span className="toggle-label">{label}</span>
|
||||||
|
{description && <span className="toggle-desc">{description}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="philosophy-page">
|
||||||
|
<header className="philosophy-header">
|
||||||
|
<button
|
||||||
|
className="philosophy-back"
|
||||||
|
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<span className="philosophy-title">Why this exists</span>
|
||||||
|
{!authenticated && (
|
||||||
|
<Link className="philosophy-signin" to="/">Home</Link>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<article className="philosophy-body">
|
||||||
|
{loading && <p className="muted">Loading…</p>}
|
||||||
|
{error && <p className="error">Could not load the philosophy: {error}</p>}
|
||||||
|
{!loading && !error && (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user