quranic-universal-audio / docs /reference /admin-dashboard.md
hetchyy's picture
deploy: inspector prod @ 5b5b4fe
773d586 verified
|
Raw
History Blame Contribute Delete
24.7 kB
# Admin dashboard
Owner/maintainer control surface launched from the Dashboard activity rail. A wide modal with a top tab strip; **Users**, **Requests**, **Reviews**, **Releases**, **Jobs**, **Announcements**, and **Permissions** ship. The modal reopens to the **last active tab** (localStorage `insp_admin_active_tab`, validated against the live tab set; `openModal()` defaults to it). The **Permissions** tab is **owner-only** (`ownerOnly` β†’ `$isOwner`); the **Announcements** tab is **capability-gated** on `announcements.send` (owner-only by default, but owner-toggleable to maintainers β€” so it filters on `$canAnnounce`, not the tier). Both vanish from the tab strip when ineligible, and an active-tab guard snaps back to Users on a live demotion.
## Permissions compartment (owner-only)
`PermissionsCompartment.svelte` renders the capability matrix (`GET /api/admin/permissions`) grouped by category; each capability row is an On/Off `Toggle` (`lib/components/Toggle.svelte`) per tier (anonymous / contributor / maintainer), with a static always-on **Owner** indicator. Anonymous cells render a locked **N/A** marker when the capability isn't `anon_eligible`; the `manage_permissions` row is fully locked (owner-only fixed). Toggling is optimistic + revert-on-error (mirrors `UsersCompartment.changeRole`); a modified cell shows a reset (β†Ί) affordance β†’ `POST …/<cap>/<tier>` with `{reset:true}`. API client `lib/api/admin-permissions.ts`; the resolved capability model + endpoints live in [auth-permissions.md](auth-permissions.md) Β§ Capabilities.
Gated on `$isAdmin` (maintainer **or** owner). Read-only data for maintainers; the role picker (Users), request resolution (Requests), and reviewer overrides (Reviews β†’ General-drawer popover) are owner-only or use existing owner-gated routes.
The entry button (`AdminDashboardButton`) is badge-free β€” it just opens the modal. "New request" awareness moved to the per-user My Notifications rail (the owner-facing `request.received` alert, see [notifications.md](notifications.md) Β§ Owner review alerts); the Requests tab no longer carries an unviewed dot/count, and the marked-ready review notification was retired with the Releases-tab restructure.
## Frontend
`tabs/dashboard/components/admin/`
| File | Role |
|---|---|
| `AdminDashboardButton.svelte` | Entry button above the activity rail; rendered only when `$isAdmin`. Opens the modal via the store. No badge. |
| `AdminDashboardModal.svelte` | `Modal size="wide"` + tab strip (`adminDashboard.activeTab`, persisted to localStorage). Renders `UsersCompartment` / `RequestsCompartment` / `reviews/ReviewsCompartment` / `releases/ReleasesCompartment` / `jobs/JobsCompartment` / `AnnouncementsCompartment` / `PermissionsCompartment`. |
| `AnnouncementsCompartment.svelte` | Compose a global announcement (title + optional body) + a management list of past announcements (active + revoked) with per-row Revoke. Gated on `announcements.send`. API client `lib/api/announcements.ts`; see [notifications.md](notifications.md) Β§ Announcements. |
| `RequestsCompartment.svelte` | Status facets (open / accepted / sent back / discarded + counts) over a review queue. Clicking a pending row expands an inline review (proposed-changes diff over `ProposedEdits` fields, requester note, conflict notice). Expanding a pending request marks it viewed (clears the unviewed dot/pill, decrements the badge). **Owner-only** inline reason + Send back / Discard; maintainers are read-only. Archived rows show a read-only resolution footer. Lazy-fetched on first activation + light `visiblePoll` while open. |
| `reviews/ReviewsCompartment.svelte` | Slimmed read-only oversight surface β€” two collapsible buckets (**Under review** [claimed, not yet marked ready] / **Available for review**) split FE-side from one `/api/admin/reviews/list` fetch. Available collapsed by default (localStorage-persisted). Filter bar (Arabic + Latin search Β· riwayah/style/channel facets Β· `stalled`/`name` sort). Marked-ready / published / staleness moved to the Releases tab. |
| `reviews/ReviewsRow.svelte` | One recitation row β€” Latin primary + Arabic muted trailing + reviewer chip; age (stale ⚠ when a claim is > 7d old) + a `Segments` deep-link (`selectedReciter` + `setActiveTab('segments')` + `adminDashboard.close()`). Row body click opens the General drawer. No Generate-TS / unread dot (retired with the restructure). |
| `reviews/ReviewsGeneralDrawer.svelte` | Current-claim chip is the popover trigger for owners (subtle hover ring + caret); maintainers see static text. **Mark-ready submission** card (between Current reviewer and Reviewer history, visible only when `current_claim.mark_ready_submission` is set) renders the reviewer's checklist as a 6-row βœ“ list plus the two optional comment boxes as quoted blocks β€” labels come from the same `tabs/segments/copy/mark-ready` module the reviewer saw. **Bypass submissions** (`submission.bypass_used === true`) replace the checklist with an italic bypass line + an "owner bypass" pill. Also holds the reviewer-history table (closed claims), expanded vertical timeline, and a **Flagged issues** count (shown only when `flagged_issues_count > 0`). |
| `reviews/ReviewerActionsPopover.svelte` | **Owner-only** mode-driven popover anchored to the current-claim chip β€” three views: `menu` (Change reviewer / Remove reviewer), `change` (debounced 300 ms HF login lookup β†’ resolved user card + reason β†’ Reassign; disables when picked login equals current reviewer), `remove` (reason β†’ Force-release). RolePicker-style click-outside + Escape dismissal; success fires `onaction` so the drawer + parent list refetch. |
| `reviews/ExpandedTimeline.svelte` | Vertical chronological timeline with tone variants (transition / admin / job dot styles). Newest-first sort. Reused by the Releases row's Timeline expand. |
| `UsersCompartment.svelte` | Fetches the list once + visitor stats; people/traffic ribbon, search + role-filter toolbar, master table, detail drawer. Owns inline role-change orchestration (optimistic + revert + error banner). |
| `UsersTable.svelte` | Sortable master table. Role cell is a `RolePicker` (`editable={canEditRoles}`); the cell stops click-propagation only when editable so non-owners still open the drawer. |
| `UserDetailDrawer.svelte` | Lazy per-user detail (stats, role/claims/requests/activity timelines, HF profile link). Header pill is a `RolePicker`; reflects the live row role via `roleOverride`. |
### Releases compartment (FE)
`tabs/dashboard/components/admin/releases/` β€” the single operator lifecycle home. Sections are organized **by action needed** (mutually exclusive, priority order): In progress Β· Failed to publish Β· **Ready to generate** (marked-ready, no ts) Β· **Behind edits** (ts stale) Β· **Republish to HF** (hf stale) Β· **Publish to HF** (released, first publish) Β· Published & current. `bucketOf()` mirrors the route's `_is_bucketable`.
| File | Role |
|---|---|
| `ReleasesCompartment.svelte` | Bulk `/api/admin/releases/status` fetch + FE bucketing + filter bar; owns the single-open in-row expansion state (`expanded = {slug, mode}` β€” one open across the whole list), the batch-publish selection + action bar, the cut modal, and send-back on Ready-to-generate rows. Also fetches `/api/admin/release-preview` for the header what's-next. |
| `ReleasesRow.svelte` | TS/HF/GH chips + reviewer chip on Ready-to-generate rows. Action cluster: Generate/Regenerate (opens the `ts` expand) Β· Timeline Β· Reviewers Β· Past jobs Β· Segments redirect; Send back on Ready-to-generate; in-flight controls on running rows. Renders `ReleasesRowExpansion` below when this row is the open one. |
| `ReleasesRowExpansion.svelte` | The one shared, button-switched panel β€” renders `ReleasesTsSettings` (`ts`), `ExpandedTimeline` (`timeline`), `ReviewerHistoryView` (`reviewers`), or `ReleasesPastJobs` (`jobs`). Timeline + Reviewers share one lazy `fetchAdminReviewDetail`. |
| `ReleasesTsSettings.svelte` | Unified generate/regenerate form β€” beam 50 + probe 2, collapsible Advanced, inline Full-vs-Affected scope chooser. Launches via `generateTimestamps`. |
| `ReleasesPastJobs.svelte` | Job history + live `visiblePoll` log tail; on terminal success triggers a status refetch. |
| `ReviewerHistoryView.svelte` | Read-only current-reviewer + mark-ready submission + closed-claim history + flagged count (no claim mutation). |
| `ReleasesSummaryCard.svelte` | Current GH release + **what's-next** preview ("Adds N, refreshes M (K carried) over vX.Y.Z") + Cut / Refresh-catalog buttons + in-flight strip. |
| `CutReleaseModal.svelte` Β· `ReleasesActionBar.svelte` | Cut dry-run document; sticky batch-publish bar. |
Full release model: [dataset-and-releases.md](dataset-and-releases.md). TS gen/regen subsystem: [timestamps-job.md](timestamps-job.md).
### Jobs compartment (FE)
`tabs/dashboard/components/admin/jobs/` β€” the **one** place to see every job kind (`timestamps` Β· `hf_publish` Β· `hf_publish_batch` Β· `cut_release` Β· `refresh_catalog`), running **and** historical, across all reciters. Replaces the scatter where TS history hid in a per-reciter Past-jobs expand and release/cut/publish jobs only flashed in the Releases in-flight strip.
| File | Role |
|---|---|
| `JobsCompartment.svelte` | `visiblePoll` (~10 s) over `GET /api/admin/jobs`; filter chips (kind / status / reciter+id search); running pinned on top; row click opens the drawer. Gated `reviews.view`. |
| `JobDetailDrawer.svelte` | `GET /api/admin/jobs/detail` β†’ summary + per-kind extras (TS settings + log tail; batch/cut `members` table; `version`/`external_uri`); **Open on HF β†—** (the job page `url`) + **Cancel** when running (reuses `cancelReleaseJob`). |
API client `lib/api/admin-jobs.ts` (`fetchJobs`, `fetchJobDetail`; re-exports `cancelReleaseJob`).
### Shared job-record store (backend) β€” the single source the Jobs tab reads
`services/admin/jobs/records.py` is the **one writer/reader** of job records. Every kind's server-side `launch()` writes a `running` `JobRecord`; every `complete()` and every failure path (webhook non-success branches, the generic cancel route, the TS backstop) writes the terminal outcome via read-merge-write (launch fields survive). Paths reuse `base.job_record_path` β€” `reciters/<slug>/jobs/<kind>/<id>.json` per-slug, `jobs/_global/<kind>/<id>.json` global. The DB stays source-of-truth for *releases*; these records are the uniform observability trail (the `JobRecord` schema is a superset of the legacy `TsJobRecord`, which the TS HF-Job container still self-writes unchanged).
`services/admin/jobs/registry.py` aggregates: `records.list_all()` βˆͺ live `base.list_in_flight_jobs(ALL_KINDS)`, deduped by `job_id` (live `running` wins), newest-first, TTL-cached (`cache.{get,set,invalidate}_all_jobs_cache`). `job_detail()` enriches a running TS job with its live log tail. Endpoints (gate `reviews.view`): `GET /api/admin/jobs`, `GET /api/admin/jobs/detail?kind=&job_id=&slug=`. Cancel reuses `POST /api/admin/release-jobs/<job_id>/cancel`.
Pre-store history (cut/hf_publish that predate the store) is one-shot synthesized into records by `scripts/backfills/backfill_job_records.py` (from `gh_releases` + `per_recitation_releases(track='hf')`), so the tab reads complete history from one store.
State:
- `tabs/dashboard/stores/admin-dashboard.svelte.ts` (`adminDashboard`: `open`, `activeTab` [localStorage-persisted], `openModal/close/setTab`).
- `lib/stores/reviews.svelte.ts` (`reviewsStore`: `selectedSlug`, `openDrawer` [`'general'`], `filters`, `sortBy`, `refreshSeq`; `open(slug)`).
- `lib/stores/releases.svelte.ts` (`releasesStore`: `filters`, `sortBy`, `refreshSeq` + `requestRefresh()`).
Cross-tab building blocks in `lib/components/`: `RolePill` (presentational badge), `RolePicker` (owner-editable wrapper β€” bare pill when `editable=false`, dropdown of role pills + click-outside/Escape when `true`), `Avatar`, `Timeline`. API clients `lib/api/admin-users.ts`, `lib/api/admin-requests.ts`, `lib/api/admin-reviews.ts`; formatters `lib/utils/admin-format.ts`.
## Backend
Blueprint `admin_users_bp` at `/api/admin` (`routes/admin/users.py`), all `@require_role(Role.MAINTAINER, Role.OWNER)`:
| Route | Purpose |
|---|---|
| `GET /users` | Master list. Cached on `db_seq` (`services/admin/users.py::list_users`, cache in `services/storage/cache.py`). |
| `GET /users/<hf_user_id>` | Per-user detail (lazy, uncached) β€” `repo_admin_users` aggregations. |
| `GET /visitor-stats` | Today's traffic rollup. Gated on env `INSPECTOR_VISITOR_ANALYTICS`; the compartment swallows failure. |
| `POST /users/<hf_user_id>/role` | **Owner-only** role change (`@require_role(Role.OWNER)` + `@require_same_origin`). |
Aggregation/reads: `services/db/repo_admin_users.py`, `repo_visitors.py`; visitor counting in `services/admin/visitors.py`. Schemas: `qua_shared/schemas/wire/admin_users.py` (codegen'd to FE types).
### Requests compartment
Routes live in `requests_bp` (`routes/claims/requests.py`); reads in `services/admin/requests.py`; schema `qua_shared/schemas/wire/admin_requests.py` (codegen'd).
| Route | Purpose |
|---|---|
| `GET /admin/requests?status=open\|accepted\|returned\|discarded` | Review-queue payload for one facet β€” catalog-joined (name/riwayah/style), proposed-changes diff over `ProposedEdits` fields, conflict flag, facet counts. Includes **slugless intake rows** (new-combo / new-reciter) alongside slug-based edit requests; intake rows carry `source` + `probe`. Maintainer+; **tier-redacted** (owners get `@login`+hf_id, maintainers role only). Base list cached on `db_seq` in `cache.py` (`get/set_admin_requests_cache`); per-caller overlay applied live. "New request" awareness is the rail's `request.received` alert β€” no unviewed badge / view-mark route. |
Resolution of **slug-based edit requests** stays on the existing per-slug routes, now **owner-only**: `POST /admin/request/<slug>/reject-{soft,hard}` (`@require_role(Role.OWNER)`; the `reciter.request_rejected_{soft,hard}` transition handlers use `_require_owner`). Acceptance is implicit (the alignment pipeline β†’ `reciter.alignment_completed` applies the proposed edits + resolves the request to `accepted`).
#### Intake requests (new combination / new reciter)
The Submit-recitation wizard's two **slugless** types β€” `existing_reciter_new_combo` and `new_reciter` β€” carry an audio **source** (direct links / playlist; a dropped CSV/JSON file is normalised into `links[]` client-side). They land in the unified `requests` table with `slug = NULL`, everything parked in `payload` (`reciter_id`, `proposed_edits` (ProposedEdits-shaped), `source`, three required `attestations` (distribution/links-verified/storage rights β€” recorded for audit), cached `probe`). Service: `services/admin/intake.py`; validation `intake_validation.py`; probe `intake_probe.py`; schemas `qua_shared/schemas/wire/intake_requests.py` (codegen'd).
| Route | Purpose |
|---|---|
| `POST /requests/intake` | Submit (any signed-in user; `@require_same_origin`). Structural validation only β€” URL format (errors list malformed **chapter indices**), required combination + all three attestations, with 1–114 coverage / duplicates / playlist host as **warnings**. 400 carries `{errors, warnings}`. The wizard mirrors this client-side: live invalid/missing-chapter feedback in step 2, a step-4 confirm of the attestations. |
| `POST /admin/requests/<id>/accept` | **Owner-only.** Approval only β€” **does not write the catalog.** Records the owner-confirmed canonical `reciter_id` (new_reciter only) onto the payload and flips the request to `accepted` (slug stays NULL). Source/channel/bitrate/slug are probed from the audio and can't be validly classified by a human at accept time, so the **offline ingest** reads accepted slugless requests, fetches + probes the audio, and creates the reciter + delivery (correct source/channel/slug) + state row, honoring `auto_claim` β€” all offline. |
| `POST /admin/requests/<id>/probe` | **Owner-only.** Reachability probe of the source (ThreadPool HEAD/ranged-GET; playlist = single check). Caches `payload.probe`. Never on the submit path. |
| `POST /admin/requests/<id>/{return,discard}` | **Owner-only.** Id-based resolution for slugless rows β€” pure request-status mutation, **no** state-machine transition (no delivery/state row exists). β‰₯10-char reason. |
The Accept-confirm dialog (`AcceptIntakeDialog.svelte`) is deliberately thin: for a **new reciter** it confirms only the canonical `reciter_id` (the lone human decision); for a **new combination** it's a plain confirm. No source/channel/slug pickers β€” those are ingest's job. `request.intake_*` are audit-only events (slugless, not in `_HANDLERS`) β€” silently dropped from both activity rails, no classification entry needed.
Request review is **no longer a notification**: `reciter.request_rejected_{soft,hard}` are `HIDDEN_EVENTS` and `reciter.requested` is public-prose-only (not `ADMIN_ONLY_EVENTS`) in `activity_classification.py`.
### Reviews compartment
Routes live in `admin_reviews_bp` (`routes/admin/reviews.py`); reads + per-admin view marks in `services/admin/reviews.py`; schema `qua_shared/schemas/wire/admin_reviews.py` (codegen'd). All endpoints `@require_role(Role.MAINTAINER, Role.OWNER)`.
| Route | Purpose |
|---|---|
| `GET /admin/reviews/list` | One whole-table JOIN over `delivery_states` + `deliveries` + `reciters` + open `claims`, filtered to the two states the slimmed tab covers (`awaiting_review`, `under_review`). Canonical rows (state + open-claim shape); the FE renders Under review [not marked-ready] + Available. Not cached β€” sub-second JOIN, refreshed on every admin action. |
| `GET /admin/reviews/<slug>` | General-drawer payload β€” base + current claim + claim history + transition timeline + `timestamps_job_ids` + `flagged_issues_count` (segments carrying a `flag`, from one cached `load_detailed`). Bounded queries + one detailed read; cheap enough to fetch eagerly on every drawer open. Also fed (lazily) by the Releases row's Timeline + Reviewers expands. 404 on unknown slug. |
> The marked-ready unviewed-count / per-admin view-mark surface (`/reviews/unviewed-count`, `/reviews/<slug>/view`, the `review_views` table + `repo_review_views`, the `unread` / `unviewed_marked_ready` schema fields) was **removed** with the Releases restructure β€” marked-ready work moved to Releases without a notification.
**Timestamps-generation** endpoints (`@require_capability("reviews.generate_timestamps")`, maintainer+) β€” drive the Releases-tab in-row `ReleasesTsSettings` / `ReleasesPastJobs` expands. Full subsystem: [timestamps-job.md](timestamps-job.md).
- `POST /api/admin/generate-timestamps/<slug>` β€” launch the in-container MFA (alignment-only) job. Body β†’ `_parse_ts_settings` β†’ `TsJobSettings` (`beam` + `probe_beams` β†’ `beams`, optional `chapters` for affected-only regen, Advanced workers/flavor/timeout β€” no audio/peaks toggles). Valid from `under_review`+marked-ready (first publish β†’ auto-release on success) or `released` (regen). 202 `{job_id, url}`; 409 if a job is already running for the slug; 400 invalid; 404 unknown. `@require_same_origin`. Does **not** transition the reciter at launch.
- `GET /api/admin/jobs/<job_id>` β€” live status + bounded log tail (HF authoritative).
- `GET /api/admin/jobs/<job_id>/record` β€” persisted record (settings + status + full logs) from `jobs/ts/<job_id>.json`; 404 if none.
- `GET /api/admin/reciters/<slug>/ts-jobs` β€” persisted records for the slug (newest first), for the Past-jobs expand's history list.
Reviewer-management mutations reuse existing per-slug routes:
- `POST /api/admin/claim/force-release/<slug>` β€” **owner-only** (`_require_owner` at both route + state-machine handler level). Used by the General-drawer popover's Remove path.
- `POST /api/admin/claim/reassign/<slug>` β€” **owner-only**. Used by the popover's Change path; resolves `to_login` server-side via `hf_users.lookup` before persisting `claim.reassigned`.
- `POST /api/admin/users/lookup` β€” maintainer+. Lookup-only (no mutation); the popover hits it on debounced input to render the resolved-user card.
- `POST /api/admin/send-back/<slug>` β€” maintainer+ (quality gate, not a claim mutation). Rejects marked-ready work for more review. Surfaced on the Releases **Ready-to-generate** row (the new home of marked-ready work); sending back returns the reciter to `under_review`, where it reappears in the Reviews tab.
- Publish / unpublish / unlock-for-revision are listed but **disabled** (see `docs/planning/reviews-tab-deferred.md`).
The owner-only tightening on force-release + reassign was a deliberate split: **owners manage who reviews; maintainers gate what ships**. Maintainers retain Send-back-to-UR but cannot eject or transfer claims.
### Jobs compartment (unified job view)
The **Jobs** tab is one place for every HF-Job kind β€” `timestamps`, `hf_publish`, `hf_publish_batch`, `cut_release`, `refresh_catalog` β€” running *and* historical, across all reciters. Clicking any row opens an in-app drawer (record + settings/logs, batch/cut members, "Open on HF", Cancel-if-running).
**Shared job-record store** (`services/admin/jobs/records.py`) β€” the single source the tab reads. Every kind's **server-side** `launch()` writes a `running` `JobRecord` and every terminal point (`complete()`, the webhook non-success branches, the cancel route) writes the `succeeded`/`failed` outcome via `record_terminal` (read-merge-write, so launch fields survive). The DB stays the source-of-truth for *releases*; these records are the uniform observability trail. Paths reuse `base.job_record_path` β€” `reciters/<slug>/jobs/<kind>/` (per-slug) and `jobs/_global/<kind>/` (global). `JobRecord` (`qua_shared/schemas/bucket/jobs.py`) is a superset of the legacy `TsJobRecord` the timestamps HF-Job container still self-writes, so old TS records parse uniformly (`records.read` injects `kind` from the path + normalizes status).
**Aggregator** (`services/admin/jobs/registry.py`) β€” `list_all_jobs()` unions `records.list_all()` with the live HF list (`base.list_in_flight_jobs`), dedups by `job_id` (live `running` wins), sorts newest-first, TTL-cached (`cache.{get,set,invalidate}_all_jobs_cache`, invalidated at every launch/complete/cancel). `job_detail()` enriches a running TS job with its live log tail.
| Endpoint | What |
|---|---|
| `GET /api/admin/jobs` | `JobsListResponse` β€” unified list + `running_count` (`reviews.view`). |
| `GET /api/admin/jobs/detail?kind=&job_id=&slug=` | One job's full `JobRecord` for the drawer (`reviews.view`). 404 if absent. |
| `POST /api/admin/release-jobs/<job_id>/cancel` | Cancel β€” reused (kind-generic, per-kind cap gates). |
> History caveat: `hf_publish` rows that predate the store, plus `cut_release`, are synthesized from the DB by the one-shot `scripts/backfills/backfill_job_records.py` (idempotent); going forward every kind records natively, including failures.
### Role change (`services/admin/users.py::set_role`)
Roles are tri-state with contributor **implicit** (no `role_assignments` row). `set_role` reads the current role and dispatches to the existing `services/auth/access.py` mutations so authz + audit + `durable_transaction` stay centralized:
| From β†’ To | Action |
|---|---|
| contributor β†’ maintainer/owner | `access.grant` |
| maintainer ↔ owner | `access.update` (in-place tier change) |
| maintainer/owner β†’ contributor | `access.revoke(cascade_release=False)` β€” **preserves the user's open claim** (a contributor is a valid claim holder; demote β‰  offboard) |
Guard: refuse to remove the **last active owner** (`repo_access.active_owner_count`) β†’ `LastOwnerError` β†’ HTTP 409. Other mappings: `NotAuthorized`β†’403, `MemberNotFound`β†’404, bad input / `AccessError`β†’400.
> The legacy `/api/admin/access/{grant,revoke,update}` endpoints (`routes/admin/access.py`) remain β€” maintainer-capable, and `revoke` there keeps `cascade_release=True` (offboarding: auto-releases open claims). The picker is the owner-only path; offboarding is the access endpoints.
### Sync
No special wiring. `durable_transaction` bumps `db_seq` β†’ the admin-users list cache self-invalidates on next read; `current_user()` resolves role fresh per request (no TTL), so the affected user becomes their new role on their next request. A self-demoting owner's own FE refreshes via `loadCurrentUser()` immediately (and on any reload at minimum).
See [auth-permissions.md](auth-permissions.md) for roles/predicates/edit-lock and [frontend.md](frontend.md) for the dashboard tab.