From 88534459513d7f38bee4a760384b8067101a9750 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 1 Oct 2026 17:32:34 +0000 Subject: [PATCH] :tada: Add the superuser admin panel Operators get a full instance administration panel behind a new "superuser" access rule. The :app.auth/superusers service resolves the configured :superusers emails to profile ids once at startup, so guarded calls are a plain contains? with no query. A token carrying "superuser" as an operator-granted scope also passes, for automation without a session; tokens are created with empty perms, so this opens nothing by itself. The check is a branch of wrap-authentication, after auth and before params validation, so outsiders get 403 :superuser-required without leaking expected params. Other token scopes keep their exact meaning. get-profile and the login response report the same answer as :is-superuser; the team-scoped :is-admin is untouched. On devenv any authenticated profile passes, as the /dbg gate does. The :admins config becomes :superusers (PENPOT_SUPERUSERS) with no compatibility layer: operators must rename it or instance-admin access fails closed. Quote notifications go to the same set. Admin operations live in app.rpc.admin.* behind /api/admin/methods, with the same auth wrapping as main but excluded from the main API docs. Profiles can be listed, inspected, blocked, unblocked, deleted and restored, teams listed with members and features, projects and files listed with validate and repair, error reports listed and shared through JWE links, and the virtual clock read and set. Delete and restore run as durable jobs on the unified job table: the command stamps the object's own row inline so it reads back instantly, then a delete-object or restore-object job walks the rest of the tree through app.features.object-cascade. Listing uses created-at cursors over the new 0157 indexes. The panel itself is a static SPA served under /admin (public like any asset; the JS gate reads :is-superuser from get-profile) with dashboard, users, teams, projects, files and error-report pages. Devenv points /admin at the backend. Covered by backend-tests rpc-superuser-test plus rpc-admin-{users,teams,project,file,delete,restore,misc, error-reports,error-share} and http-admin tests, with green regressions on profile, auth and quotes. Relates to #12015 AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/backend/core.md | 5 +- .serena/memories/backend/subtleties.md | 8 +- backend/resources/admin/css/admin.css | 536 ++++++++++++++++++ backend/resources/admin/index.html | 13 + backend/resources/admin/js/api.js | 68 +++ backend/resources/admin/js/auth.js | 40 ++ backend/resources/admin/js/auth.test.js | 31 + .../resources/admin/js/components/badges.js | 81 +++ backend/resources/admin/js/components/date.js | 23 + .../admin/js/components/date.test.js | 35 ++ .../resources/admin/js/components/delete.js | 47 ++ .../resources/admin/js/components/filters.js | 35 ++ .../admin/js/components/filters.test.js | 55 ++ .../resources/admin/js/components/header.js | 27 + .../admin/js/components/list-page.js | 257 +++++++++ .../resources/admin/js/components/restore.js | 114 ++++ .../resources/admin/js/components/sidebar.js | 28 + .../resources/admin/js/components/table.js | 42 ++ .../resources/admin/js/components/toast.js | 13 + backend/resources/admin/js/main.js | 134 +++++ backend/resources/admin/js/pages/dashboard.js | 67 +++ .../resources/admin/js/pages/error-detail.js | 191 +++++++ .../resources/admin/js/pages/error-reports.js | 173 ++++++ .../resources/admin/js/pages/file-detail.js | 378 ++++++++++++ backend/resources/admin/js/pages/files.js | 167 ++++++ .../resources/admin/js/pages/job-detail.js | 243 ++++++++ backend/resources/admin/js/pages/jobs.js | 251 ++++++++ .../admin/js/pages/project-detail.js | 157 +++++ backend/resources/admin/js/pages/projects.js | 91 +++ .../resources/admin/js/pages/team-detail.js | 355 ++++++++++++ backend/resources/admin/js/pages/teams.js | 49 ++ .../resources/admin/js/pages/user-detail.js | 271 +++++++++ .../resources/admin/js/pages/users-delete.js | 148 +++++ backend/resources/admin/js/pages/users.js | 63 ++ .../resources/admin/js/pages/virtual-clock.js | 122 ++++ backend/resources/admin/js/url.js | 71 +++ backend/src/app/auth.clj | 34 +- backend/src/app/config.clj | 2 +- backend/src/app/features/object_cascade.clj | 335 +++++++++++ backend/src/app/http.clj | 3 + backend/src/app/http/admin.clj | 108 ++++ backend/src/app/http/debug.clj | 2 +- backend/src/app/main.clj | 42 +- backend/src/app/migrations.clj | 8 +- .../0157-add-created-at-cursor-indexes.sql | 33 ++ .../sql/0158-add-job-admin-list-index.sql | 21 + backend/src/app/rpc.clj | 79 ++- backend/src/app/rpc/admin/errors.clj | 271 +++++++++ backend/src/app/rpc/admin/file.clj | 434 ++++++++++++++ backend/src/app/rpc/admin/job.clj | 258 +++++++++ backend/src/app/rpc/admin/list.clj | 62 ++ backend/src/app/rpc/admin/misc.clj | 72 +++ backend/src/app/rpc/admin/profile.clj | 331 +++++++++++ backend/src/app/rpc/admin/project.clj | 208 +++++++ backend/src/app/rpc/admin/team.clj | 369 ++++++++++++ backend/src/app/rpc/commands/auth.clj | 5 +- .../src/app/rpc/commands/error_reports.clj | 20 +- backend/src/app/rpc/commands/profile.clj | 10 +- backend/src/app/rpc/quotes.clj | 3 +- backend/src/app/srepl/main.clj | 89 ++- backend/src/app/tasks/delete_object.clj | 117 +--- backend/src/app/tasks/restore_object.clj | 69 +++ .../file_change_retention_test.clj | 28 + backend/test/backend_tests/helpers.clj | 2 + .../test/backend_tests/http_admin_test.clj | 61 ++ .../backend_tests/rpc_admin_delete_test.clj | 194 +++++++ .../rpc_admin_error_reports_test.clj | 182 ++++++ .../rpc_admin_error_share_test.clj | 232 ++++++++ .../backend_tests/rpc_admin_file_test.clj | 473 ++++++++++++++++ .../backend_tests/rpc_admin_jobs_test.clj | 309 ++++++++++ .../backend_tests/rpc_admin_misc_test.clj | 151 +++++ .../backend_tests/rpc_admin_project_test.clj | 265 +++++++++ .../backend_tests/rpc_admin_restore_test.clj | 292 ++++++++++ .../backend_tests/rpc_admin_teams_test.clj | 420 ++++++++++++++ .../backend_tests/rpc_admin_users_test.clj | 469 +++++++++++++++ .../test/backend_tests/rpc_superuser_test.clj | 336 +++++++++++ docker/devenv/files/nginx.conf | 8 +- frontend/src/app/main/data/exports/files.cljs | 7 +- frontend/src/app/main/data/imports.cljs | 4 + frontend/src/app/main/ui/exports/files.cljs | 4 +- .../frontend_tests/data/imports_test.cljs | 2 +- .../frontend_tests/errors_governor_test.cljs | 11 + 82 files changed, 10629 insertions(+), 195 deletions(-) create mode 100644 backend/resources/admin/css/admin.css create mode 100644 backend/resources/admin/index.html create mode 100644 backend/resources/admin/js/api.js create mode 100644 backend/resources/admin/js/auth.js create mode 100644 backend/resources/admin/js/auth.test.js create mode 100644 backend/resources/admin/js/components/badges.js create mode 100644 backend/resources/admin/js/components/date.js create mode 100644 backend/resources/admin/js/components/date.test.js create mode 100644 backend/resources/admin/js/components/delete.js create mode 100644 backend/resources/admin/js/components/filters.js create mode 100644 backend/resources/admin/js/components/filters.test.js create mode 100644 backend/resources/admin/js/components/header.js create mode 100644 backend/resources/admin/js/components/list-page.js create mode 100644 backend/resources/admin/js/components/restore.js create mode 100644 backend/resources/admin/js/components/sidebar.js create mode 100644 backend/resources/admin/js/components/table.js create mode 100644 backend/resources/admin/js/components/toast.js create mode 100644 backend/resources/admin/js/main.js create mode 100644 backend/resources/admin/js/pages/dashboard.js create mode 100644 backend/resources/admin/js/pages/error-detail.js create mode 100644 backend/resources/admin/js/pages/error-reports.js create mode 100644 backend/resources/admin/js/pages/file-detail.js create mode 100644 backend/resources/admin/js/pages/files.js create mode 100644 backend/resources/admin/js/pages/job-detail.js create mode 100644 backend/resources/admin/js/pages/jobs.js create mode 100644 backend/resources/admin/js/pages/project-detail.js create mode 100644 backend/resources/admin/js/pages/projects.js create mode 100644 backend/resources/admin/js/pages/team-detail.js create mode 100644 backend/resources/admin/js/pages/teams.js create mode 100644 backend/resources/admin/js/pages/user-detail.js create mode 100644 backend/resources/admin/js/pages/users-delete.js create mode 100644 backend/resources/admin/js/pages/users.js create mode 100644 backend/resources/admin/js/pages/virtual-clock.js create mode 100644 backend/resources/admin/js/url.js create mode 100644 backend/src/app/features/object_cascade.clj create mode 100644 backend/src/app/http/admin.clj create mode 100644 backend/src/app/migrations/sql/0157-add-created-at-cursor-indexes.sql create mode 100644 backend/src/app/migrations/sql/0158-add-job-admin-list-index.sql create mode 100644 backend/src/app/rpc/admin/errors.clj create mode 100644 backend/src/app/rpc/admin/file.clj create mode 100644 backend/src/app/rpc/admin/job.clj create mode 100644 backend/src/app/rpc/admin/list.clj create mode 100644 backend/src/app/rpc/admin/misc.clj create mode 100644 backend/src/app/rpc/admin/profile.clj create mode 100644 backend/src/app/rpc/admin/project.clj create mode 100644 backend/src/app/rpc/admin/team.clj create mode 100644 backend/src/app/tasks/restore_object.clj create mode 100644 backend/test/backend_tests/http_admin_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_delete_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_error_reports_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_error_share_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_file_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_jobs_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_misc_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_project_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_restore_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_teams_test.clj create mode 100644 backend/test/backend_tests/rpc_admin_users_test.clj create mode 100644 backend/test/backend_tests/rpc_superuser_test.clj 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