diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 2d44a88b76..c6d6dbc949 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -14,13 +14,15 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m ## Stable namespace map -- `app.rpc.commands.*`: RPC command implementations exposed under `/api/rpc/command/`. +- `app.rpc.commands.*`: RPC command implementations, all reachable under `/api/main/methods/`. `/api/rpc/command/` is the deprecated alias of `/api/main/methods`; it still exists because the exporter calls it. +- `app.rpc.admin.*`: superuser-guarded admin commands under `/api/admin/methods/` (same wrapper as `main`, short names, no doc routes). Binary file transfer (`file-export`, `file-import`) lives in `app.http.admin` as plain HTTP routes next to the methods, not as commands. - `app.rpc.permissions`: permission predicate/check helper factories. - `app.http.*`: HTTP routes and middleware. - `app.auth.*`: provider-specific authentication helpers such as LDAP/OIDC. - `app.loggers.*`: audit, webhook, database, and external log integrations. - `app.db.*` / `app.db`: next.jdbc wrapper and SQL helpers. - `app.tasks.*`: background task handlers. +- `app.features.object-cascade`: the single description of the deletion cascade, walked in both directions by the `:delete-object` and `:restore-object` tasks. - `app.worker`: task execution/cron plumbing. - `app.main`: Integrant system map and component wiring. - `app.config`: `PENPOT_*` env config and feature flags. @@ -110,6 +112,7 @@ EOF ``` Default port is 6064. Use `-p ` for a different port. Use `-t ` to override the 120s timeout. Do not start the nREPL server — assume it is already running. +Reloading a namespace with `:reload` only affects the REPL session: the running server keeps serving old code until you evaluate `(restart)` in nREPL, which reloads the whole system. ### Interactive REPL diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index a3cf848ace..9d7a2d35c1 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -10,6 +10,12 @@ - Params schemas are open by default, so undeclared client keys reach the handler unless the map is `:closed true`. Creation commands (`create-file`, `create-project`, `create-team`, `create-team-with-invitations`, `upload-file-media-object`, `create-file-media-object-from-url`, `assemble-file-media-object`) use closed schemas: a client-provided `:id` fails with `:params-validation`. Their internal creation functions still accept an optional explicit `:id` for imports, duplicates and deterministic test fixtures. - Nil RPC bodies become HTTP 204 unless explicit status metadata is present. Stream bodies default to `application/octet-stream` when no content type is set. - The upload schema (`media.v/schema:upload`) takes a `:path` from the caller, and a Transit body can tag any string as a `path`, so a command that reads such an upload reads a file the client named. `create-import-binfile-job` avoids that boundary by accepting only a chunked upload (`:upload-id`); the commands that still take a plain file (the legacy `import-binfile`, the media and font uploads) trust it, and the guard belongs where uploads are accepted, not in each command. +- Three RPC modules live under `/api`, each with its own method map in `app/rpc.clj`: `main` (product, session or access token), `management` (machine callers, shared key), and `admin` (superuser-guarded product operations for the `/admin` panel, short command names, no doc routes). The admin file export/import transfer routes live next to the admin methods as plain HTTP handlers, not commands, behind the same superuser rule. +- The superuser check is a branch of `wrap-authentication`, not something each command repeats, so a new admin command cannot forget it. It runs after authentication and before params validation, so a non-superuser gets 403 `:superuser-required` without the rejection leaking which params the command expects. The rule itself is factored as `auth/superuser-allowed?`, which the admin HTTP transfer routes call to enforce exactly the same check outside the wrapper. +- `::perms` entries other than `"superuser"` keep their token-only meaning: a session calling them gets `token-auth-required`, exactly as before. `"superuser"` is the only permission a session can satisfy, by id membership in the `:app.auth/superusers` registry. A token can also satisfy it by carrying `"superuser"` as an operator-granted scope, for profiles the registry does not list; scopes are granted outside the public API, so this opens nothing by itself. The profile flags (`get-profile`, login) stay registry-based: the flag says who you are, the guard says what the call carries. +- The registry resolves the configured `:superusers` emails to profile ids once, at startup, in dependency order after migrations. What does not exist then is simply not in the set: a profile registered afterwards is recognized after the next restart. The check itself is a plain `contains?` on that set, so guarded calls cost no query. On a `devenv` host the check passes for any authenticated profile, for both sessions and tokens. `get-profile` and the login response carry the same answer as `:is-superuser`, read from the same set. +- `:is-admin` means team membership (owner/admin/editor/viewer roles); `:is-superuser` is the global instance flag. Do not reuse one for the other. +- `profile` stores emails lowercased but the `:superusers` config schema does not normalize, so an upper-case entry in the config never matches. ## Concurrency limiter (CLIMIT) @@ -99,5 +105,5 @@ - Tell a snapshot apart by `label IS NOT NULL` (with `created_by` / `deleted_at` as needed), never by `file_change.data IS NOT NULL`: the label holds with every file data backend, and the payload column stopped meaning "this is a snapshot" when the payload moved out. Queries that identify versions must use the label. - The version quota counts of `app.rpc.quotes` (`sql:get-snapshots-per-file`, `sql:get-snapshots-per-team`) are served by the partial index `file_change__user_snapshots__idx`, whose predicate mirrors their where clause; changing one without the other drops the index only scan and the count walks the whole table again. - Revisions live only in `file_change` and have no `file_data` counterpart. They are born already scheduled for deletion one hour later (`app.rpc.commands.files-update`) and the snapshot paths cannot reach one, because they resolve their row through a `label IS NOT NULL` filter. -- A deletion date is assigned once and never moved: `restore-files` (`app.rpc.commands.files`) unmarks only durable history, the `:file` method of `app.tasks.delete-object` dates only rows that have no date yet, and `objects-gc` collects only rows that carry one. Unmarking a revision leaves it invisible to `objects-gc` for ever, because nothing sets that date again. +- A deletion date is assigned once and never moved: `restore-files` (`app.rpc.commands.files`) unmarks only durable history, the file walk of `app.features.object-cascade` (used by `app.tasks.delete-object` and the admin restore path) dates only rows that have no date yet and restores only durable history, and `objects-gc` collects only rows that carry one. Unmarking a revision leaves it invisible to `objects-gc` for ever, because nothing sets that date again. - Binfile v1/v3 import and the v3 export `get-file` step run inside one long transaction. They raise `idle_in_transaction_session_timeout` to 30 min (`app.binfile.common/transaction-idle-timeout-ms`) instead of disabling it, so a connection left idle while the ZIP is decoded or the file data is realized is bounded; `statement_timeout` stays at 300 s. diff --git a/backend/resources/admin/css/admin.css b/backend/resources/admin/css/admin.css new file mode 100644 index 0000000000..c3843da457 --- /dev/null +++ b/backend/resources/admin/css/admin.css @@ -0,0 +1,536 @@ +:root { + --admin-bg: #1e1e1e; + --admin-surface: #2b2b2b; + --admin-text: #e8e8e8; + --admin-muted: #9a9a9a; + --admin-accent: #f7932e; + --admin-danger: #e65252; + --admin-space-xs: 4px; + --admin-space-sm: 8px; + --admin-space-md: 16px; + --admin-space-lg: 24px; + --admin-font: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; + --admin-mono: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--admin-bg); + color: var(--admin-text); + font-family: var(--admin-font); +} + +#app { + min-height: 100vh; + padding: var(--admin-space-lg); +} + +/* Links keep readable tones on the dark background in both +states: unvisited light blue (7.6:1 on the background), visited +light lavender (8.1:1), so the two are told apart without going +dark. Both clear WCAG AAA for normal text. */ +a { + color: #7fb3e8; +} +a:visited { + color: #c4a8ee; +} +a:focus-visible { + outline: 2px solid var(--admin-accent); + outline-offset: 1px; +} + +.admin-layout { + display: flex; + gap: var(--admin-space-lg); + align-items: flex-start; +} + +.admin-content { + flex: 1; + min-width: 0; +} + +.admin-sidebar { + display: flex; + flex-direction: column; + gap: var(--admin-space-xs); + min-width: 180px; + padding-top: 52px; +} + +.admin-sidebar-link { + color: var(--admin-muted); + text-decoration: none; + padding: var(--admin-space-sm) var(--admin-space-md); + border-radius: 6px; +} + +.admin-sidebar-link:hover { + color: var(--admin-text); + background: var(--admin-surface); +} + +.admin-sidebar-link-active { + color: var(--admin-text); + background: var(--admin-surface); + border-left: 3px solid var(--admin-accent); +} + +.admin-header h1 { + font-size: 1.5rem; + margin: 0 0 var(--admin-space-md); +} + +/* Title crumb: clickable back to the team or project page, but +wearing plain title text instead of link styling. */ +.admin-crumb { + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; +} +.admin-crumb:focus-visible { + outline: 2px solid var(--admin-accent); + outline-offset: 1px; +} + +.admin-controls { + display: flex; + gap: var(--admin-space-sm); + margin-bottom: var(--admin-space-sm); + flex-wrap: wrap; +} + +/* `hidden` must win over the flex above: an empty filter bar +or confirm row takes no space at all. */ +.admin-controls[hidden], +.admin-confirm[hidden] { + display: none; +} + +.admin-input, +.admin-select { + background: var(--admin-surface); + color: var(--admin-text); + border: 1px solid #3d3d3d; + border-radius: 6px; + padding: var(--admin-space-sm) var(--admin-space-md); + font: inherit; +} + +.admin-input:focus-visible, +.admin-select:focus-visible, +.admin-button:focus-visible { + outline: 2px solid var(--admin-accent); + outline-offset: 1px; +} + +.admin-input { + min-width: 240px; +} + +/* The native dropdown arrow hugs the right border in every browser + and ignores padding, so it is replaced with a painted chevron: + no native shape, room for the glyph on the right, the glyph drawn + as a background pinned to the inner edge. */ +.admin-select { + appearance: none; + padding-right: calc(var(--admin-space-md) * 2 + 12px); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' fill='none' stroke='%239a9a9a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--admin-space-md) center; + background-size: 12px 12px; +} + +.admin-textarea { + display: block; + background: var(--admin-surface); + color: var(--admin-text); + border: 1px solid #3d3d3d; + border-radius: 6px; + padding: var(--admin-space-sm) var(--admin-space-md); + font: inherit; + min-width: min(560px, 100%); + min-height: 140px; + margin-bottom: var(--admin-space-sm); +} + +.admin-textarea:focus-visible { + outline: 2px solid var(--admin-accent); + outline-offset: 1px; +} + +.admin-button { + background: var(--admin-accent); + color: #1e1e1e; + border: none; + border-radius: 6px; + padding: var(--admin-space-sm) var(--admin-space-md); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.admin-button:hover:not(:disabled) { + filter: brightness(1.1); +} + +.admin-button:disabled { + opacity: 0.5; + cursor: default; +} + +/* Labels styled as buttons (the import picker) cannot use +:disabled, so loading dims them through this class instead. */ +.admin-button-disabled { + opacity: 0.5; + pointer-events: none; +} + +/* Stacked checkbox labels (export options, repair snapshot). */ +.admin-check { + display: flex; + gap: var(--admin-space-xs); + margin-top: var(--admin-space-sm); + align-items: center; +} + +/* Result area below an action button. */ +.admin-result { + margin-top: var(--admin-space-md); +} + +.admin-button-ghost { + background: transparent; + color: var(--admin-text); + border: 1px solid #3d3d3d; + font-weight: 400; +} + +.admin-button-danger { + background: #5c2b2b; + color: #ee9a9a; +} + +.admin-actions { + display: flex; + gap: var(--admin-space-sm); + margin-top: var(--admin-space-lg); + flex-wrap: wrap; +} + +.admin-confirm { + display: flex; + gap: var(--admin-space-sm); + margin-top: var(--admin-space-lg); + flex-wrap: wrap; + align-items: center; +} + +.admin-confirm p { + flex-basis: 100%; + margin: 0; +} + +.admin-list { + margin: 0; + padding-left: var(--admin-space-lg); +} + +.admin-count { + color: var(--admin-muted); + font-size: 0.875rem; + margin: var(--admin-space-sm) 0 var(--admin-space-md); +} + +/* Count line under a filter bar: extra air above it so it does +not stick to the controls. */ +.admin-count-spaced { + margin-top: var(--admin-space-lg); +} + +.admin-table { + border-collapse: collapse; + width: 100%; + table-layout: fixed; + background: var(--admin-surface); + border-radius: 8px; + overflow: hidden; +} + +.admin-table thead th { + position: sticky; + top: 0; + background: #333; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--admin-muted); +} + +.admin-table th, +.admin-table td { + padding: var(--admin-space-sm) var(--admin-space-md); + text-align: left; + border-bottom: 1px solid #3d3d3d; +} + +.admin-table tbody tr { + cursor: pointer; +} + +.admin-table tbody tr:hover { + background: #353535; +} + +.admin-cell-date { + width: 215px; + white-space: nowrap; + font-size: 0.8125rem; + color: var(--admin-muted); +} + +.admin-cell-source { + width: 115px; + white-space: nowrap; +} + +.admin-cell-kind { + width: 195px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--admin-muted); +} + +.admin-cell-hint { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-cell-email { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-cell-name { + width: 220px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--admin-muted); +} + +.admin-cell-status { + width: 110px; + white-space: nowrap; +} + +.admin-table .admin-cell-count { + width: 64px; + white-space: nowrap; + text-align: right; +} + +.admin-table .admin-cell-right { + text-align: right; +} + +.admin-table .admin-cell-left { + text-align: left; +} + +.admin-table .admin-cell-project, +.admin-table .admin-cell-team { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +/* Auto-layout tables: columns shrink to their content (capped +with ellipsis) instead of spreading across the table. The column +carrying `.admin-cell-fill` eats the leftover space. */ +.admin-table-auto { + table-layout: auto; +} +.admin-table-auto .admin-cell-name { + min-width: 220px; + max-width: 280px; +} +.admin-table-auto .admin-cell-project, +.admin-table-auto .admin-cell-team { + max-width: 260px; +} +.admin-table-auto .admin-cell-date { + width: auto; +} +.admin-table-auto .admin-cell-fill { + width: 100%; + max-width: none; +} + +.admin-table .admin-cell-center { + text-align: center; +} + +.admin-badge { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + white-space: nowrap; +} + +.admin-badge-source-logging { + background: #1d3a5f; + color: #7fb3e8; +} + +.admin-badge-source-audit { + background: #3d2b5c; + color: #c4a8ee; +} + +.admin-badge-source-rlimit { + background: #5c2b2b; + color: #ee9a9a; +} + +.admin-badge-source-legacy { + background: #3a3a3a; + color: var(--admin-muted); +} + +.admin-badge-status-blocked { + background: #5c2b2b; + color: #ee9a9a; +} + +.admin-badge-status-inactive { + background: #3a3a3a; + color: var(--admin-muted); +} + +.admin-badge-status-demo { + background: #1d3a5f; + color: #7fb3e8; +} + +.admin-badge-status-active { + background: #274d2d; + color: #9bda9f; +} + +.admin-badge-status-deleted { + background: #402b3d; + color: #d896d8; +} + +.admin-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--admin-space-md); + margin-top: var(--admin-space-md); +} + +.admin-card { + display: flex; + flex-direction: column; + gap: var(--admin-space-xs); + background: var(--admin-surface); + border: 1px solid #3d3d3d; + border-left: 3px solid var(--admin-accent); + border-radius: 0 8px 8px 0; + padding: var(--admin-space-md) var(--admin-space-lg); + color: var(--admin-text); + text-decoration: none; +} + +.admin-card:hover { + border-color: var(--admin-accent); +} + +.admin-card strong { + font-size: 1rem; +} + +.admin-card span { + color: var(--admin-muted); + font-size: 0.875rem; +} + +.admin-pager { + display: flex; + justify-content: flex-end; + margin-top: var(--admin-space-md); +} + +.admin-detail { + display: grid; + grid-template-columns: 140px 1fr; + gap: var(--admin-space-xs) var(--admin-space-md); + background: var(--admin-surface); + border-radius: 8px; + padding: var(--admin-space-md) var(--admin-space-lg); + margin: var(--admin-space-md) 0 0; +} + +.admin-detail dt { + color: var(--admin-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.admin-detail dd { + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.admin-block { + margin-top: var(--admin-space-lg); +} + +.admin-block h2 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--admin-accent); + margin: 0 0 var(--admin-space-sm); +} + +.admin-block-body { + background: #141414; + border: 1px solid #3d3d3d; + border-left: 3px solid var(--admin-accent); + border-radius: 0 8px 8px 0; + padding: var(--admin-space-md) var(--admin-space-lg); + overflow-wrap: anywhere; + white-space: pre-wrap; + font-size: 0.8125rem; +} + +.admin-toast { + position: fixed; + bottom: var(--admin-space-lg); + right: var(--admin-space-lg); + background: var(--admin-surface); + padding: var(--admin-space-md); + border-left: 3px solid var(--admin-accent); +} + +.admin-toast-error { + border-left-color: var(--admin-danger); +} diff --git a/backend/resources/admin/index.html b/backend/resources/admin/index.html new file mode 100644 index 0000000000..5103e35720 --- /dev/null +++ b/backend/resources/admin/index.html @@ -0,0 +1,13 @@ + + + + + + Penpot Admin + + + +
+ + + diff --git a/backend/resources/admin/js/api.js b/backend/resources/admin/js/api.js new file mode 100644 index 0000000000..9cdf4ab054 --- /dev/null +++ b/backend/resources/admin/js/api.js @@ -0,0 +1,68 @@ +// Minimal RPC client against the admin API. +// +// The API root is derived from this module's own URL: the panel is +// served at `/admin/js/api.js` and the API lives at +// `/api/...`, where `` is empty on a root deployment or +// the subpath otherwise. No absolute paths, so subpath deployments +// keep working. +// +// Sessions travel in the cookie (`credentials: "same-origin"`), so there +// is no token handling here. 401/403 responses are propagated as thrown +// errors with a `status` field so callers can tell "no session" and +// "no permission" apart. + +const API_ROOT = new URL("../../api/admin/methods/", import.meta.url); +const MAIN_API_ROOT = new URL("../../api/main/methods/", import.meta.url); + +async function call(root, command, params = {}) { + const response = await fetch(new URL(command, root), { + method: "POST", + credentials: "same-origin", + headers: { + "content-type": "application/json", + accept: "application/json", + }, + body: JSON.stringify(params), + }); + + if (response.status === 204) { + return null; + } + + if (!response.ok) { + const error = new Error("rpc failed: " + command); + error.status = response.status; + try { + error.data = await response.json(); + } catch { + error.data = null; + } + throw error; + } + + return response.json(); +} + +export async function rpc(command, params = {}) { + return call(API_ROOT, command, params); +} + +export async function rpcMain(command, params = {}) { + return call(MAIN_API_ROOT, command, params); +} + +// Human-readable detail from a failed RPC call, or null when the +// server gave nothing usable. Lets the panel repeat the backend +// reason (e.g. why a delete was refused) instead of a generic +// message. Every string is painted as text by the caller. +export function errorHint(err) { + const hint = err?.data?.["~:hint"]; + return typeof hint === "string" && hint.length > 0 ? hint : null; +} + +// Binary transfer calls go through the RPC methods endpoint directly +// (a download and a multipart upload do not fit the JSON rpc() +// helper, which always parses the answer as JSON). +export function methodUrl(name) { + return new URL("../../api/admin/methods/" + name, import.meta.url); +} diff --git a/backend/resources/admin/js/auth.js b/backend/resources/admin/js/auth.js new file mode 100644 index 0000000000..6f49bbfca2 --- /dev/null +++ b/backend/resources/admin/js/auth.js @@ -0,0 +1,40 @@ +// Session gate against the current contract: the product +// `get-profile` (public, so anonymous callers get an answer too) +// carries `:is-superuser` when there is a session. It lives on the +// main API, hence `rpcMain`. JSON responses use camelCase, hence +// `isSuperuser`. `:is-admin` is team membership and means nothing +// here. + +import { rpcMain } from "./api.js"; + +const ANONYMOUS_ID = "00000000-0000-0000-0000-000000000000"; + +// Login hop for a dead session (deleted profile, expired cookie): +// the product login lives at the deployment root (`/`), next +// to the panel mount (`/admin/`). It anchors on this module's +// own url — panel scripts always live at `/admin/js/`, so two +// levels up is `/` in every deployment (root or subpath) and +// from any page depth. Returns path plus query (a bare pathname +// would drop the screen). `moduleUrl` defaults to this file; tests +// pass their own url. +export function loginUrl(moduleUrl = import.meta.url) { + const url = new URL("../../?screen=auth-login", moduleUrl); + return url.pathname + url.search; +} + +export async function checkSuperuserSession() { + let profile; + try { + profile = await rpcMain("get-profile", {}); + } catch (err) { + if (err.status === 401) { + return "anonymous"; + } + throw err; + } + + if (!profile || !profile.id || profile.id === ANONYMOUS_ID) { + return "anonymous"; + } + return profile.isSuperuser === true ? "superuser" : "denied"; +} diff --git a/backend/resources/admin/js/auth.test.js b/backend/resources/admin/js/auth.test.js new file mode 100644 index 0000000000..0c071d1b58 --- /dev/null +++ b/backend/resources/admin/js/auth.test.js @@ -0,0 +1,31 @@ +// Contract test for the login hop: run with plain node, no +// harness: +// +// node --experimental-detect-module --test \ +// backend/resources/admin/js/auth.test.js +// +// (The flag lets node read the panel's extensionless-ESM `.js` +// files; the panel itself has no build and no test runner.) +// `loginUrl` takes the page url as a plain string, so no browser +// globals are needed. `auth.js` also imports `./api.js`, which only +// builds urls at import time and never touches the network. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { loginUrl } from "./auth.js"; + +describe("loginUrl", () => { + it("lands on the deployment root with the auth-login screen", () => { + assert.equal( + loginUrl("http://localhost:3450/admin/js/auth.js"), + "/?screen=auth-login" + ); + }); + + it("keeps working under a subpath deployment", () => { + assert.equal( + loginUrl("https://example.com/penpot/admin/js/auth.js"), + "/penpot/?screen=auth-login" + ); + }); +}); diff --git a/backend/resources/admin/js/components/badges.js b/backend/resources/admin/js/components/badges.js new file mode 100644 index 0000000000..8b32f7937d --- /dev/null +++ b/backend/resources/admin/js/components/badges.js @@ -0,0 +1,81 @@ +// Shared status badges for the admin panel: fixed class maps with +// values painted as text, never the raw value as a class. These used +// to live in `pages/users.js`, which coupled every list (and the user +// detail) to the users page; they live here now. + +const STATUS_BADGES = { + deleted: "admin-badge-status-deleted", + blocked: "admin-badge-status-blocked", + inactive: "admin-badge-status-inactive", + demo: "admin-badge-status-demo", + active: "admin-badge-status-active", +}; + +const DEFAULT_BADGES = { + yes: "admin-badge-status-active", + no: "admin-badge-status-inactive", +}; + +// Derive the badge key from a profile row. +export function statusOf(item) { + if (item.deletedAt) { + return "deleted"; + } + if (item.isBlocked) { + return "blocked"; + } + if (item.isDemo) { + return "demo"; + } + if (!item.isActive) { + return "inactive"; + } + return "active"; +} + +export function statusCell(status) { + const badge = document.createElement("span"); + badge.className = "admin-badge " + (STATUS_BADGES[status] || STATUS_BADGES.active); + badge.textContent = status; + return badge; +} + +// Yes/no badge for the team "Default" column. +export function defaultCell(value) { + const badge = document.createElement("span"); + badge.className = "admin-badge " + (DEFAULT_BADGES[value] || DEFAULT_BADGES.no); + badge.textContent = value; + return badge; +} + +const JOB_STATUS_BADGES = { + new: "admin-badge-status-demo", + scheduled: "admin-badge-status-demo", + retry: "admin-badge-status-demo", + running: "admin-badge-status-active", + completed: "admin-badge-status-inactive", + failed: "admin-badge-status-blocked", + cancelled: "admin-badge-status-deleted", + aborted: "admin-badge-status-deleted", +}; + +const JOB_KIND_BADGES = { + system: "admin-badge-source-legacy", + user: "admin-badge-source-logging", +}; + +// Badge for a job status or kind: fixed class maps, never the raw +// value as a class. Unknown values fall back to a neutral badge. +export function jobStatusCell(status) { + const badge = document.createElement("span"); + badge.className = "admin-badge " + (JOB_STATUS_BADGES[status] || "admin-badge-status-inactive"); + badge.textContent = status; + return badge; +} + +export function jobKindCell(kind) { + const badge = document.createElement("span"); + badge.className = "admin-badge " + (JOB_KIND_BADGES[kind] || "admin-badge-source-legacy"); + badge.textContent = kind; + return badge; +} diff --git a/backend/resources/admin/js/components/date.js b/backend/resources/admin/js/components/date.js new file mode 100644 index 0000000000..767e5face2 --- /dev/null +++ b/backend/resources/admin/js/components/date.js @@ -0,0 +1,23 @@ +// Shared date formatting for the admin panel: every date column +// in every list and detail uses this, so dates always read +// `YYYY/MM/DD HH:MM:SS AM/PM` (local time, 12-hour clock). +// Unparseable input echoes back raw so a bad value is visible +// instead of crashing. The caller paints the result as text. + +function pad2(value) { + return String(value).padStart(2, "0"); +} + +export function formatDate(iso) { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return String(iso ?? ""); + } + const hours = date.getHours(); + const meridiem = hours < 12 ? "AM" : "PM"; + const twelve = hours % 12 || 12; + return ( + `${date.getFullYear()}/${pad2(date.getMonth() + 1)}/${pad2(date.getDate())} ` + + `${pad2(twelve)}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())} ${meridiem}` + ); +} diff --git a/backend/resources/admin/js/components/date.test.js b/backend/resources/admin/js/components/date.test.js new file mode 100644 index 0000000000..e14336e5f3 --- /dev/null +++ b/backend/resources/admin/js/components/date.test.js @@ -0,0 +1,35 @@ +// Contract test for the shared date formatter: run with plain +// node, no harness: +// +// node --experimental-detect-module --test \ +// backend/resources/admin/js/components/date.test.js +// +// (The flag lets node read the panel's extensionless-ESM `.js` +// files; the panel itself has no build and no test runner.) + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { formatDate } from "./date.js"; + +describe("formatDate", () => { + it("paints local time as YYYY/MM/DD HH:MM:SS AM/PM", () => { + // 2026-10-04 15:04:05 local time, built without a zone suffix + // so the expectation holds in any timezone. + const out = formatDate("2026-10-04T15:04:05"); + assert.match(out, /^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} (AM|PM)$/); + assert.equal(out, "2026/10/04 03:04:05 PM"); + }); + + it("renders midnight and noon on the 12-hour clock", () => { + assert.match(formatDate("2026-01-02T00:00:00"), /12:00:00 AM$/); + assert.match(formatDate("2026-01-02T12:00:00"), /12:00:00 PM$/); + }); + + it("echoes unparseable input back instead of crashing", () => { + assert.equal(formatDate("not-a-date"), "not-a-date"); + assert.equal(formatDate(undefined), ""); + // null coerces to the epoch, same as the old toLocaleString + // version: valid date in, formatted date out. + assert.match(formatDate(null), /^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} (AM|PM)$/); + }); +}); diff --git a/backend/resources/admin/js/components/delete.js b/backend/resources/admin/js/components/delete.js new file mode 100644 index 0000000000..577b7c7b7d --- /dev/null +++ b/backend/resources/admin/js/components/delete.js @@ -0,0 +1,47 @@ +// Shared delete UI for a live object. +// +// command: "delete-team" and friends; id: the object id. +// label: "this team" for messages; kind: "Team" for toasts; +// name: the object name for the confirm. +// onDeleted(): what the page does after success (usually reloading, +// which then paints the restore actions). +// Asks for confirmation first. Every string is painted as text. + +import { rpc, errorHint } from "../api.js"; +import { showToast } from "./toast.js"; + +// Delete action for the bar of a live object's detail page. +// Carries a tooltip; asks for confirmation first. Returns a +// cleanup function removing everything added. +export function paintDeleteAction(bar, { + command, + id, + label, + name, + kind, + onDeleted, +}) { + const del = document.createElement("button"); + del.className = "admin-button admin-button-danger"; + del.textContent = "Delete"; + del.title = `Delete ${label}. It can be restored afterwards.`; + del.addEventListener("click", async () => { + if (!window.confirm(`Delete ${name}? It can be restored afterwards.`)) { + return; + } + del.disabled = true; + try { + await rpc(command, { id }); + showToast(`${kind} deleted.`); + onDeleted(); + } catch (err) { + showToast(errorHint(err) ?? `Could not delete ${label}.`, "error"); + del.disabled = false; + } + }); + bar.appendChild(del); + + return () => { + del.remove(); + }; +} diff --git a/backend/resources/admin/js/components/filters.js b/backend/resources/admin/js/components/filters.js new file mode 100644 index 0000000000..fad64784cd --- /dev/null +++ b/backend/resources/admin/js/components/filters.js @@ -0,0 +1,35 @@ +// Shared list filters: the deleted-state select used by every +// admin list. `noun` is the plural ("users", "teams", …) for the +// "all" label. Values are "" (everything, the historic behaviour), +// "active" (only live rows) and "deleted" (only deleted rows), +// persisted in the `deleted` query key. + +// Build the select, preselected to `current` (falls back to ""). +export function deletedSelect(noun, current) { + const select = document.createElement("select"); + select.className = "admin-select"; + select.setAttribute("aria-label", "Filter by deleted state"); + for (const [value, label] of [ + ["", `All ${noun}`], + ["active", "Active only"], + ["deleted", "Deleted only"], + ]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + select.appendChild(option); + } + select.value = ["", "active", "deleted"].includes(current) ? current : ""; + return select; +} + +// RPC params fragment for a select value: {} lists everything. +export function deletedParams(value) { + if (value === "active") { + return { deleted: false }; + } + if (value === "deleted") { + return { deleted: true }; + } + return {}; +} diff --git a/backend/resources/admin/js/components/filters.test.js b/backend/resources/admin/js/components/filters.test.js new file mode 100644 index 0000000000..4759a96ed6 --- /dev/null +++ b/backend/resources/admin/js/components/filters.test.js @@ -0,0 +1,55 @@ +// Contract test for the deleted-filter helpers: run with plain +// node, no harness: +// +// node --experimental-detect-module --test \ +// backend/resources/admin/js/components/filters.test.js +// +// (The flag lets node read the panel's extensionless-ESM `.js` +// files; the panel itself has no build and no test runner.) +// `deletedParams` is pure; `deletedSelect` gets a tiny document +// stub (select/option halves only). + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { deletedParams, deletedSelect } from "./filters.js"; + +function fakeElement() { + return { + className: "", + value: "", + textContent: "", + children: [], + setAttribute() {}, + appendChild(child) { + this.children.push(child); + return child; + }, + }; +} + +globalThis.document = { createElement: () => fakeElement() }; + +describe("deletedParams", () => { + it("maps the tri-state to RPC params", () => { + assert.deepEqual(deletedParams(""), {}); + assert.deepEqual(deletedParams("active"), { deleted: false }); + assert.deepEqual(deletedParams("deleted"), { deleted: true }); + assert.deepEqual(deletedParams("bogus"), {}); + assert.deepEqual(deletedParams(undefined), {}); + }); +}); + +describe("deletedSelect", () => { + it("preselects known values and falls back to all", () => { + assert.equal(deletedSelect("teams", "deleted").value, "deleted"); + assert.equal(deletedSelect("teams", "active").value, "active"); + assert.equal(deletedSelect("teams", "bogus").value, ""); + assert.equal(deletedSelect("teams", undefined).value, ""); + }); + + it("labels the all-option with the noun", () => { + const select = deletedSelect("teams", ""); + assert.equal(select.children[0].value, ""); + assert.equal(select.children[0].textContent, "All teams"); + }); +}); diff --git a/backend/resources/admin/js/components/header.js b/backend/resources/admin/js/components/header.js new file mode 100644 index 0000000000..c45be77a6f --- /dev/null +++ b/backend/resources/admin/js/components/header.js @@ -0,0 +1,27 @@ +// Header: renders a
with the page title, painted as text. + +export function renderHeader(title) { + const header = document.createElement("header"); + header.className = "admin-header"; + + const heading = document.createElement("h1"); + heading.textContent = title; + header.appendChild(heading); + + return header; +} + +// Detail title: `Team > Acme`, with the section part linking back +// to the list (same target as "Back to list"). Everything is +// painted as text. +export function paintDetailTitle(header, { section, query, name, onNavigate }) { + const heading = header.querySelector("h1"); + heading.replaceChildren(); + const crumb = document.createElement("button"); + crumb.className = "admin-crumb"; + crumb.textContent = section; + crumb.setAttribute("aria-label", "Back to " + section); + crumb.addEventListener("click", () => onNavigate(query)); + heading.appendChild(crumb); + heading.append(" > " + name); +} diff --git a/backend/resources/admin/js/components/list-page.js b/backend/resources/admin/js/components/list-page.js new file mode 100644 index 0000000000..7d4a57982b --- /dev/null +++ b/backend/resources/admin/js/components/list-page.js @@ -0,0 +1,257 @@ +// Shared list page for the admin panel: lookup input, deleted +// filter, count line, table, and cursor pager in one place. The four +// admin lists (users, teams, projects, files) are thin configs over +// this helper instead of four copies of the same controls/paint/load +// loop. See `filters.js` for the deleted-filter contract (the `deleted` +// URL key carries "", "active" or "deleted"; the RPC takes a boolean). +// JSON responses use camelCase (`nextSince`, …). +// +// A page config looks like: +// +// listPage(root, { +// title: "Teams", +// noun: "teams", // deleted-select "All …" label +// itemNoun: "team", // count line singular +// emptyText: "No teams match the current filters.", +// columns: COLUMNS, +// rpcCommand: "get-teams", +// loadErrorText: "Could not load teams.", +// lookupPlaceholder: "Look up by id…", +// lookupAriaLabel: "Look up by team id", +// detailKind: "team", // detailUrl kind for row clicks +// readRpcParams: (state) => (state.lookup ? { id: state.lookup } : {}), +// decorateRow: (item) => ({ ...item }), +// patchRow: (rowEl, row, item, { cellByKey }) => { … }, +// onNavigate, +// }) +// +// Optional extras: `extraQueryKeys` (URL state beyond lookup/deleted, +// e.g. teamId), `initialState`/`resetExtra` for that state, +// `beforeLoad` (async name resolution), `refreshHeader` (title crumbs), +// `extraControls` (bulk/import buttons), `paintExtra` (per-paint tweaks +// like disabling import while loading), `tableClass`, and +// `hideEmptyTable` (projects/files only render the table with rows). + +import { rpc } from "../api.js"; +import { deletedParams, deletedSelect } from "./filters.js"; +import { renderHeader } from "./header.js"; +import { renderTable } from "./table.js"; +import { showToast } from "./toast.js"; +import { readQuery, writeQuery, detailUrl } from "../url.js"; + +const PAGE_SIZE = 25; + +// Ids are UUIDs (8-4-4-4-12 hex). Anything without "@" that does +// not look like one cannot match: the RPC would only answer with a +// generic load error, so the page says so inline instead and skips +// the call (Task 4, review finding F3). +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function listPage(root, config) { + const { + title, + noun, + itemNoun, + emptyText, + columns, + rpcCommand, + loadErrorText, + lookupPlaceholder, + lookupAriaLabel, + detailKind, + extraQueryKeys = [], + initialState = () => ({}), + resetExtra = () => {}, + readRpcParams = () => ({}), + decorateRow = (item) => ({ ...item }), + patchRow = () => {}, + tableClass = null, + hideEmptyTable = false, + extraControls = null, + paintExtra = null, + refreshHeader = null, + beforeLoad = null, + onNavigate, + } = config; + + const fromUrl = readQuery(["lookup", "deleted", ...extraQueryKeys]); + const state = { + lookup: fromUrl.lookup ?? "", + deletedFilter: fromUrl.deleted ?? "", + items: [], + nextSince: null, + nextId: null, + loading: false, + loadedOnce: false, + invalidLookup: false, + ...initialState(fromUrl), + }; + + root.appendChild(renderHeader(title)); + + const controls = document.createElement("div"); + controls.className = "admin-controls"; + + function submit() { + state.lookup = lookupInput.value.trim(); + state.deletedFilter = deletedFilterEl.value; + state.nextSince = null; + state.nextId = null; + load(); + } + + const lookupInput = document.createElement("input"); + lookupInput.className = "admin-input"; + lookupInput.type = "search"; + lookupInput.placeholder = lookupPlaceholder; + lookupInput.setAttribute("aria-label", lookupAriaLabel); + lookupInput.value = state.lookup; + lookupInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + submit(); + } + }); + controls.appendChild(lookupInput); + + const deletedFilterEl = deletedSelect(noun, state.deletedFilter); + controls.appendChild(deletedFilterEl); + + const lookupButton = document.createElement("button"); + lookupButton.className = "admin-button"; + lookupButton.textContent = "Look up"; + lookupButton.addEventListener("click", submit); + controls.appendChild(lookupButton); + + const clearButton = document.createElement("button"); + clearButton.className = "admin-button admin-button-ghost"; + clearButton.textContent = "Clear"; + clearButton.addEventListener("click", () => { + lookupInput.value = ""; + deletedFilterEl.value = ""; + state.lookup = ""; + state.deletedFilter = ""; + resetExtra(state); + state.nextSince = null; + state.nextId = null; + load(); + }); + controls.appendChild(clearButton); + + if (extraControls) { + extraControls(controls, { state, onNavigate, reload: () => load() }); + } + root.appendChild(controls); + + const countLine = document.createElement("p"); + countLine.className = "admin-count admin-count-spaced"; + root.appendChild(countLine); + + const tableWrap = document.createElement("div"); + root.appendChild(tableWrap); + + const pager = document.createElement("div"); + pager.className = "admin-pager"; + const nextButton = document.createElement("button"); + nextButton.className = "admin-button"; + nextButton.textContent = "Next page"; + nextButton.addEventListener("click", () => load({ append: true })); + pager.appendChild(nextButton); + root.appendChild(pager); + + // Find a body cell by column key instead of by position, so adding + // or reordering columns cannot shift badges into the wrong cell. + function cellByKey(rowEl, key) { + return rowEl.querySelector(`td[data-key="${key}"]`); + } + + function paint() { + tableWrap.replaceChildren(); + if (state.loading && !state.loadedOnce) { + countLine.textContent = "Loading…"; + } else if (state.items.length === 0 && state.invalidLookup) { + countLine.textContent = + `“${state.lookup}” is not a valid id: ids have 8-4-4-4-12 hex characters. Check the paste.`; + } else if (state.items.length === 0) { + countLine.textContent = emptyText; + } else { + countLine.textContent = + `Showing ${state.items.length} ${itemNoun}` + (state.items.length === 1 ? "" : "s") + + (state.nextSince === null ? " (all)" : " — more available"); + } + + if (refreshHeader) { + refreshHeader({ state, root, onNavigate }); + } + + if (state.items.length > 0 || !hideEmptyTable) { + const rows = state.items.map(decorateRow); + const table = renderTable(columns, rows); + if (tableClass) { + table.classList.add(tableClass); + } + const bodyRows = table.querySelector("tbody").rows; + for (let i = 0; i < bodyRows.length; i++) { + patchRow(bodyRows[i], rows[i], state.items[i], { state, cellByKey }); + bodyRows[i].addEventListener("click", () => + onNavigate(detailUrl(detailKind, state.items[i].id))); + } + tableWrap.appendChild(table); + } + nextButton.disabled = state.loading || state.nextSince === null; + lookupButton.disabled = state.loading; + clearButton.disabled = state.loading; + pager.style.display = + state.items.length === 0 || state.nextSince === null ? "none" : ""; + if (paintExtra) { + paintExtra({ state }); + } + } + + function currentQuery() { + const query = { lookup: state.lookup, deleted: state.deletedFilter }; + for (const key of extraQueryKeys) { + query[key] = state[key]; + } + return query; + } + + async function load({ append = false } = {}) { + if (!append && state.lookup && !state.lookup.includes("@") && !UUID_RE.test(state.lookup)) { + state.items = []; + state.nextSince = null; + state.nextId = null; + state.loadedOnce = true; + state.invalidLookup = true; + writeQuery(currentQuery()); + paint(); + return; + } + state.invalidLookup = false; + state.loading = true; + paint(); + writeQuery(currentQuery()); + const params = { limit: PAGE_SIZE, ...deletedParams(state.deletedFilter), ...readRpcParams(state) }; + if (append && state.nextSince) { + params.since = state.nextSince; + params.sinceId = state.nextId; + } + try { + if (beforeLoad) { + await beforeLoad(state); + } + const data = await rpc(rpcCommand, params); + state.items = append ? state.items.concat(data.items) : data.items; + state.nextSince = data.nextSince ?? null; + state.nextId = data.nextId ?? null; + state.loadedOnce = true; + } catch { + showToast(loadErrorText, "error"); + } finally { + state.loading = false; + paint(); + } + } + + paint(); + load(); +} diff --git a/backend/resources/admin/js/components/restore.js b/backend/resources/admin/js/components/restore.js new file mode 100644 index 0000000000..c4206c6c9c --- /dev/null +++ b/backend/resources/admin/js/components/restore.js @@ -0,0 +1,114 @@ +// Shared restore UI for a deleted object. +// +// command: "restore-file" and friends; id: the object id. +// label: "this file" for messages; `scope` names what else comes +// back with it ("its project and team", "everything under it"). +// `warn`: optional extra warning appended to the confirm. +// onRestored(): what the page does after success (usually +// reloading). Restores are always recursive now: there is no +// single-level restore. The button asks for confirmation first. +// Every string is painted as text. + +import { rpc } from "../api.js"; +import { showToast } from "./toast.js"; + +export function deletedNotice(text) { + const notice = document.createElement("p"); + notice.className = "admin-count"; + notice.textContent = text; + return notice; +} + +// Restore action for the top bar of a deleted object's detail +// page. Carries a tooltip; asks for confirmation first (naming +// what comes back). Returns a cleanup function removing +// everything added. +export function paintRestoreActions(bar, { + command, + id, + label, + name, + scope, + warn, + onRestored, +}) { + const message = `Restore ${name} ${scope}?` + (warn ? ` ${warn}` : ""); + + const restore = document.createElement("button"); + restore.className = "admin-button admin-button-danger"; + restore.textContent = "Restore"; + restore.title = `Restore ${label} ${scope}.`; + restore.addEventListener("click", async () => { + if (!window.confirm(message)) { + return; + } + restore.disabled = true; + try { + await rpc(command, { id }); + showToast(`Restored ${label} ${scope}.`); + onRestored(); + } catch { + showToast("Could not restore.", "error"); + restore.disabled = false; + } + }); + bar.appendChild(restore); + + return () => { + restore.remove(); + }; +} + +export function restoreBlock({ command, id, label, scope, warn, onRestored }) { + const section = document.createElement("section"); + section.className = "admin-block"; + const heading = document.createElement("h2"); + heading.textContent = "Restore"; + section.appendChild(heading); + + const hint = document.createElement("p"); + hint.className = "admin-count"; + hint.textContent = `Restore brings back ${label} ${scope}.`; + section.appendChild(hint); + + if (warn) { + const warnEl = document.createElement("p"); + warnEl.className = "admin-count"; + warnEl.textContent = warn; + section.appendChild(warnEl); + } + + const buttons = document.createElement("div"); + buttons.className = "admin-confirm"; + + const restore = document.createElement("button"); + restore.className = "admin-button admin-button-danger"; + restore.textContent = "Restore"; + const message = `Restore ${label} ${scope}?` + (warn ? ` ${warn}` : ""); + restore.title = `Restore ${label} ${scope}.` + (warn ? ` ${warn}` : ""); + buttons.appendChild(restore); + section.appendChild(buttons); + + const result = document.createElement("div"); + result.className = "admin-result"; + section.appendChild(result); + + restore.addEventListener("click", async () => { + if (!window.confirm(message)) { + return; + } + restore.disabled = true; + result.textContent = "Restoring…"; + try { + await rpc(command, { id }); + showToast(`Restored ${label} ${scope}.`); + onRestored(); + } catch { + result.textContent = "Could not restore."; + showToast("Could not restore.", "error"); + restore.disabled = false; + } + }); + + return section; +} diff --git a/backend/resources/admin/js/components/sidebar.js b/backend/resources/admin/js/components/sidebar.js new file mode 100644 index 0000000000..0abb170d5e --- /dev/null +++ b/backend/resources/admin/js/components/sidebar.js @@ -0,0 +1,28 @@ +// Sidebar navigation: renders a