mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🎉 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
This commit is contained in:
parent
1eb5bacc82
commit
8853445951
@ -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/<cmd-name>`.
|
||||
- `app.rpc.commands.*`: RPC command implementations, all reachable under `/api/main/methods/<cmd-name>`. `/api/rpc/command/<cmd-name>` 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/<cmd-name>` (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 <PORT>` for a different port. Use `-t <MS>` 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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
536
backend/resources/admin/css/admin.css
Normal file
536
backend/resources/admin/css/admin.css
Normal file
@ -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);
|
||||
}
|
||||
13
backend/resources/admin/index.html
Normal file
13
backend/resources/admin/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Penpot Admin</title>
|
||||
<link rel="stylesheet" href="css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
68
backend/resources/admin/js/api.js
Normal file
68
backend/resources/admin/js/api.js
Normal file
@ -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 `<root>/admin/js/api.js` and the API lives at
|
||||
// `<root>/api/...`, where `<root>` 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);
|
||||
}
|
||||
40
backend/resources/admin/js/auth.js
Normal file
40
backend/resources/admin/js/auth.js
Normal file
@ -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 (`<root>/`), next
|
||||
// to the panel mount (`<root>/admin/`). It anchors on this module's
|
||||
// own url — panel scripts always live at `<root>/admin/js/`, so two
|
||||
// levels up is `<root>/` 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";
|
||||
}
|
||||
31
backend/resources/admin/js/auth.test.js
Normal file
31
backend/resources/admin/js/auth.test.js
Normal file
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
81
backend/resources/admin/js/components/badges.js
Normal file
81
backend/resources/admin/js/components/badges.js
Normal file
@ -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;
|
||||
}
|
||||
23
backend/resources/admin/js/components/date.js
Normal file
23
backend/resources/admin/js/components/date.js
Normal file
@ -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}`
|
||||
);
|
||||
}
|
||||
35
backend/resources/admin/js/components/date.test.js
Normal file
35
backend/resources/admin/js/components/date.test.js
Normal file
@ -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)$/);
|
||||
});
|
||||
});
|
||||
47
backend/resources/admin/js/components/delete.js
Normal file
47
backend/resources/admin/js/components/delete.js
Normal file
@ -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();
|
||||
};
|
||||
}
|
||||
35
backend/resources/admin/js/components/filters.js
Normal file
35
backend/resources/admin/js/components/filters.js
Normal file
@ -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 {};
|
||||
}
|
||||
55
backend/resources/admin/js/components/filters.test.js
Normal file
55
backend/resources/admin/js/components/filters.test.js
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
27
backend/resources/admin/js/components/header.js
Normal file
27
backend/resources/admin/js/components/header.js
Normal file
@ -0,0 +1,27 @@
|
||||
// Header: renders a <header> 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);
|
||||
}
|
||||
257
backend/resources/admin/js/components/list-page.js
Normal file
257
backend/resources/admin/js/components/list-page.js
Normal file
@ -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();
|
||||
}
|
||||
114
backend/resources/admin/js/components/restore.js
Normal file
114
backend/resources/admin/js/components/restore.js
Normal file
@ -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;
|
||||
}
|
||||
28
backend/resources/admin/js/components/sidebar.js
Normal file
28
backend/resources/admin/js/components/sidebar.js
Normal file
@ -0,0 +1,28 @@
|
||||
// Sidebar navigation: renders a <nav> of links. Labels and queries
|
||||
// are painted as text; navigation goes through the given callback.
|
||||
// Hrefs are bare query strings: they resolve against the current
|
||||
// page, so subpath deployments keep working with no path math. The
|
||||
// active entry matches by section (`screens`), so filtered list
|
||||
// URLs and detail pages keep their section highlighted.
|
||||
|
||||
export function renderSidebar(items, onNavigate, currentScreen = "") {
|
||||
const nav = document.createElement("nav");
|
||||
nav.className = "admin-sidebar";
|
||||
|
||||
for (const item of items) {
|
||||
const link = document.createElement("a");
|
||||
link.className = "admin-sidebar-link";
|
||||
if ((item.screens ?? [""]).includes(currentScreen)) {
|
||||
link.classList.add("admin-sidebar-link-active");
|
||||
}
|
||||
link.textContent = item.label;
|
||||
link.href = item.query;
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
onNavigate(item.query);
|
||||
});
|
||||
nav.appendChild(link);
|
||||
}
|
||||
|
||||
return nav;
|
||||
}
|
||||
42
backend/resources/admin/js/components/table.js
Normal file
42
backend/resources/admin/js/components/table.js
Normal file
@ -0,0 +1,42 @@
|
||||
// Generic table: `columns` is [{key, label}], `rows` is [object].
|
||||
// Every cell is painted as text (`textContent`), never as HTML:
|
||||
// future data (error hints) is not trustworthy. Each body cell also
|
||||
// carries its column key (`data-key`) so callers can find a cell by
|
||||
// key instead of by position.
|
||||
|
||||
export function renderTable(columns, rows) {
|
||||
const table = document.createElement("table");
|
||||
table.className = "admin-table";
|
||||
|
||||
const head = document.createElement("thead");
|
||||
const headRow = document.createElement("tr");
|
||||
for (const column of columns) {
|
||||
const cell = document.createElement("th");
|
||||
if (column.class) {
|
||||
cell.className = column.class;
|
||||
}
|
||||
cell.textContent = column.label;
|
||||
headRow.appendChild(cell);
|
||||
}
|
||||
head.appendChild(headRow);
|
||||
table.appendChild(head);
|
||||
|
||||
const body = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const rowEl = document.createElement("tr");
|
||||
for (const column of columns) {
|
||||
const cell = document.createElement("td");
|
||||
cell.dataset.key = column.key;
|
||||
if (column.class) {
|
||||
cell.className = column.class;
|
||||
}
|
||||
const value = row[column.key];
|
||||
cell.textContent = value === null || value === undefined ? "" : String(value);
|
||||
rowEl.appendChild(cell);
|
||||
}
|
||||
body.appendChild(rowEl);
|
||||
}
|
||||
table.appendChild(body);
|
||||
|
||||
return table;
|
||||
}
|
||||
13
backend/resources/admin/js/components/toast.js
Normal file
13
backend/resources/admin/js/components/toast.js
Normal file
@ -0,0 +1,13 @@
|
||||
// Toasts: short-lived notifications. The message is painted as text,
|
||||
// and only "info" and "error" types exist (anything else is info).
|
||||
|
||||
const DISPLAY_MS = 4000;
|
||||
|
||||
export function showToast(message, type = "info") {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "admin-toast admin-toast-" + (type === "error" ? "error" : "info");
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
window.setTimeout(() => toast.remove(), DISPLAY_MS);
|
||||
}
|
||||
134
backend/resources/admin/js/main.js
Normal file
134
backend/resources/admin/js/main.js
Normal file
@ -0,0 +1,134 @@
|
||||
// Bootstrap: paint a loading state, resolve the session, and show
|
||||
// the matching view. Navigation is a bare query string
|
||||
// (`?screen=error-reports`, `?screen=error-report&id=…`): it resolves
|
||||
// against the current page, so subpath deployments keep working and
|
||||
// there is no router. A dead session (deleted profile, expired
|
||||
// cookie) bounces to the product login at the deployment root
|
||||
// (`<root>/?screen=auth-login`, resolved from the script url in
|
||||
// `auth.js` so subpath deployments keep working).
|
||||
|
||||
import { checkSuperuserSession, loginUrl } from "./auth.js";
|
||||
import { renderHeader } from "./components/header.js";
|
||||
import { renderSidebar } from "./components/sidebar.js";
|
||||
import { dashboardPage } from "./pages/dashboard.js";
|
||||
import { errorDetailPage } from "./pages/error-detail.js";
|
||||
import { errorReportsPage } from "./pages/error-reports.js";
|
||||
import { userDetailPage } from "./pages/user-detail.js";
|
||||
import { usersDeletePage } from "./pages/users-delete.js";
|
||||
import { teamsPage } from "./pages/teams.js";
|
||||
import { teamDetailPage } from "./pages/team-detail.js";
|
||||
import { filesPage } from "./pages/files.js";
|
||||
import { fileDetailPage } from "./pages/file-detail.js";
|
||||
import { projectsPage } from "./pages/projects.js";
|
||||
import { projectDetailPage } from "./pages/project-detail.js";
|
||||
import { usersPage } from "./pages/users.js";
|
||||
import { jobsPage } from "./pages/jobs.js";
|
||||
import { jobDetailPage } from "./pages/job-detail.js";
|
||||
import { virtualClockPage } from "./pages/virtual-clock.js";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
// Home targets the path itself, not an empty query: pushing ""
|
||||
// would inherit the current query string and go nowhere.
|
||||
{ query: location.pathname, label: "Dashboard", screens: [""] },
|
||||
{ query: "?screen=error-reports", label: "Error reports", screens: ["error-reports", "error-report"] },
|
||||
{ query: "?screen=users", label: "Users", screens: ["users", "user", "users-delete"] },
|
||||
{ query: "?screen=teams", label: "Teams", screens: ["teams", "team"] },
|
||||
{ query: "?screen=projects", label: "Projects", screens: ["projects", "project"] },
|
||||
{ query: "?screen=files", label: "Files", screens: ["files", "file"] },
|
||||
{ query: "?screen=jobs", label: "Jobs", screens: ["jobs", "job"] },
|
||||
{ query: "?screen=virtual-clock", label: "Virtual clock", screens: ["virtual-clock"] },
|
||||
];
|
||||
|
||||
let sessionStatus = "anonymous";
|
||||
|
||||
function screenParams() {
|
||||
return new URLSearchParams(location.search);
|
||||
}
|
||||
|
||||
function deniedView(root) {
|
||||
root.appendChild(renderHeader("Access denied"));
|
||||
|
||||
const message = document.createElement("p");
|
||||
message.textContent = "This panel is for instance superusers.";
|
||||
root.appendChild(message);
|
||||
|
||||
const login = document.createElement("a");
|
||||
login.textContent = "Go to login";
|
||||
login.href = loginUrl();
|
||||
root.appendChild(login);
|
||||
}
|
||||
|
||||
function renderAuthed(root) {
|
||||
root.replaceChildren();
|
||||
if (sessionStatus !== "superuser") {
|
||||
deniedView(root);
|
||||
return;
|
||||
}
|
||||
const layout = document.createElement("div");
|
||||
layout.className = "admin-layout";
|
||||
const navigate = (query) => {
|
||||
history.pushState({}, "", query);
|
||||
renderAuthed(root);
|
||||
};
|
||||
layout.appendChild(renderSidebar(NAV_ITEMS, navigate, screenParams().get("screen") ?? ""));
|
||||
const content = document.createElement("main");
|
||||
content.className = "admin-content";
|
||||
layout.appendChild(content);
|
||||
root.appendChild(layout);
|
||||
|
||||
const params = screenParams();
|
||||
if (params.get("screen") === "error-reports") {
|
||||
errorReportsPage(content, { onNavigate: navigate });
|
||||
} else if (params.get("screen") === "error-report" && params.get("id")) {
|
||||
errorDetailPage(content, { id: params.get("id"), onNavigate: navigate });
|
||||
} else if (params.get("screen") === "users") {
|
||||
usersPage(content, { onNavigate: navigate });
|
||||
} else if (params.get("screen") === "user" && params.get("id")) {
|
||||
userDetailPage(content, { id: params.get("id"), onNavigate: navigate });
|
||||
} else if (params.get("screen") === "users-delete") {
|
||||
usersDeletePage(content, { onNavigate: navigate });
|
||||
} else if (params.get("screen") === "teams") {
|
||||
teamsPage(content, { onNavigate: navigate });
|
||||
} else if (params.get("screen") === "team" && params.get("id")) {
|
||||
teamDetailPage(content, { id: params.get("id"), onNavigate: navigate });
|
||||
} else if (params.get("screen") === "files") {
|
||||
filesPage(content, { onNavigate: navigate });
|
||||
} else if (params.get("screen") === "projects") {
|
||||
projectsPage(content, { onNavigate: navigate });
|
||||
} else if (params.get("screen") === "project" && params.get("id")) {
|
||||
projectDetailPage(content, { id: params.get("id"), onNavigate: navigate });
|
||||
} else if (params.get("screen") === "file" && params.get("id")) {
|
||||
fileDetailPage(content, { id: params.get("id"), onNavigate: navigate });
|
||||
} else if (params.get("screen") === "jobs") {
|
||||
jobsPage(content, { onNavigate: navigate });
|
||||
} else if (params.get("screen") === "job" && params.get("id")) {
|
||||
jobDetailPage(content, { id: params.get("id"), onNavigate: navigate });
|
||||
} else if (params.get("screen") === "virtual-clock") {
|
||||
virtualClockPage(content, { onNavigate: navigate });
|
||||
} else {
|
||||
dashboardPage(content, { onNavigate: navigate });
|
||||
}
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const root = document.getElementById("app");
|
||||
root.textContent = "Loading admin…";
|
||||
|
||||
try {
|
||||
sessionStatus = await checkSuperuserSession();
|
||||
} catch {
|
||||
root.replaceChildren();
|
||||
root.textContent = "Admin — cannot reach the server.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionStatus === "anonymous") {
|
||||
location.assign(loginUrl());
|
||||
return;
|
||||
}
|
||||
|
||||
renderAuthed(root);
|
||||
window.addEventListener("popstate", () => renderAuthed(root));
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
67
backend/resources/admin/js/pages/dashboard.js
Normal file
67
backend/resources/admin/js/pages/dashboard.js
Normal file
@ -0,0 +1,67 @@
|
||||
// Dashboard: the panel home. A short welcome plus one card per
|
||||
// section, so the menu entries have somewhere to land and every
|
||||
// corner of the panel is one click away.
|
||||
|
||||
import { renderHeader } from "../components/header.js";
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
query: "?screen=error-reports",
|
||||
title: "Error reports",
|
||||
text: "Browse server error reports: filter by source, read traces.",
|
||||
},
|
||||
{
|
||||
query: "?screen=users",
|
||||
title: "Users",
|
||||
text: "Search profiles, review teams, block or delete accounts.",
|
||||
},
|
||||
{
|
||||
query: "?screen=teams",
|
||||
title: "Teams",
|
||||
text: "Browse teams, review members and feature flags.",
|
||||
},
|
||||
{
|
||||
query: "?screen=projects",
|
||||
title: "Projects",
|
||||
text: "Browse projects across teams, restore deleted ones.",
|
||||
},
|
||||
{
|
||||
query: "?screen=files",
|
||||
title: "Files",
|
||||
text: "Browse files across projects, validate and repair.",
|
||||
},
|
||||
{
|
||||
query: "?screen=virtual-clock",
|
||||
title: "Virtual clock",
|
||||
text: "Shift this session's clock for testing.",
|
||||
},
|
||||
];
|
||||
|
||||
export function dashboardPage(root, { onNavigate }) {
|
||||
root.appendChild(renderHeader("Dashboard"));
|
||||
|
||||
const intro = document.createElement("p");
|
||||
intro.className = "admin-count";
|
||||
intro.textContent = "Welcome to the Penpot admin panel. Pick a section:";
|
||||
root.appendChild(intro);
|
||||
|
||||
const cards = document.createElement("div");
|
||||
cards.className = "admin-cards";
|
||||
for (const section of SECTIONS) {
|
||||
const card = document.createElement("a");
|
||||
card.className = "admin-card";
|
||||
card.href = section.query;
|
||||
card.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
onNavigate(section.query);
|
||||
});
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = section.title;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = section.text;
|
||||
card.appendChild(title);
|
||||
card.appendChild(text);
|
||||
cards.appendChild(card);
|
||||
}
|
||||
root.appendChild(cards);
|
||||
}
|
||||
191
backend/resources/admin/js/pages/error-detail.js
Normal file
191
backend/resources/admin/js/pages/error-detail.js
Normal file
@ -0,0 +1,191 @@
|
||||
// Error report detail: shows every field of one report. Known fields
|
||||
// go first in a fixed order, the rest follows alphabetically, so a new
|
||||
// content key never goes missing again. Values are painted as text
|
||||
// (with line breaks preserved via CSS), never as HTML: report bodies
|
||||
// are not trustworthy. An unknown id shows "not found" instead of
|
||||
// failing.
|
||||
|
||||
import { rpc } from "../api.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
import { renderHeader } from "../components/header.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
import { backUrl } from "../url.js";
|
||||
|
||||
const KNOWN_FIRST = [
|
||||
"id",
|
||||
"createdAt",
|
||||
"source",
|
||||
"profileId",
|
||||
"kind",
|
||||
"tenant",
|
||||
"version",
|
||||
];
|
||||
|
||||
// Long free-text payloads get their own visual block instead of a
|
||||
// definition row, so they stand apart from the short metadata.
|
||||
const BLOCK_FIELDS = ["trace", "context"];
|
||||
|
||||
const BLOCK_TITLES = {
|
||||
trace: "Trace",
|
||||
context: "Context",
|
||||
};
|
||||
|
||||
const LABELS = {
|
||||
id: "Id",
|
||||
createdAt: "Date",
|
||||
source: "Source",
|
||||
profileId: "Profile",
|
||||
kind: "Kind",
|
||||
tenant: "Tenant",
|
||||
version: "Version",
|
||||
hint: "Hint",
|
||||
};
|
||||
|
||||
function labelFor(key) {
|
||||
return LABELS[key] || key;
|
||||
}
|
||||
|
||||
function textFor(value) {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export function errorDetailPage(root, { id, onNavigate }) {
|
||||
const header = renderHeader("Error report");
|
||||
root.appendChild(header);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "admin-actions";
|
||||
root.appendChild(actions);
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "admin-button admin-button-ghost";
|
||||
back.textContent = "Back to list";
|
||||
back.addEventListener("click", () => onNavigate(backUrl("?screen=error-reports")));
|
||||
actions.appendChild(back);
|
||||
|
||||
// Share lives next to Back: it mints a stateless link (valid a few
|
||||
// hours) that anyone with the url can open with curl, no login
|
||||
// needed. The token only opens this report. Stays disabled until
|
||||
// the report loads, so there is no id to share yet.
|
||||
const shareButton = document.createElement("button");
|
||||
shareButton.className = "admin-button";
|
||||
shareButton.textContent = "Create share link (valid 4h)";
|
||||
shareButton.disabled = true;
|
||||
actions.appendChild(shareButton);
|
||||
|
||||
// Share results land here, above the detail, painted as text.
|
||||
const shareOut = document.createElement("div");
|
||||
root.appendChild(shareOut);
|
||||
|
||||
shareButton.addEventListener("click", async () => {
|
||||
shareButton.disabled = true;
|
||||
shareOut.textContent = "Creating…";
|
||||
try {
|
||||
const share = await rpc("create-error-report-share", { id });
|
||||
shareOut.replaceChildren();
|
||||
|
||||
const section = document.createElement("section");
|
||||
section.className = "admin-block";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Share link";
|
||||
section.appendChild(heading);
|
||||
|
||||
const expiry = document.createElement("p");
|
||||
expiry.textContent = "Expires: " + formatDate(share.expiresAt);
|
||||
section.appendChild(expiry);
|
||||
|
||||
const urlLine = document.createElement("div");
|
||||
urlLine.className = "admin-block-body";
|
||||
urlLine.textContent = share.url;
|
||||
section.appendChild(urlLine);
|
||||
shareOut.appendChild(section);
|
||||
|
||||
// The link copies itself: no copy button to hunt for.
|
||||
try {
|
||||
await navigator.clipboard.writeText(share.url);
|
||||
showToast("Share link copied.", "ok");
|
||||
} catch {
|
||||
showToast("Select the link above to copy it.", "error");
|
||||
}
|
||||
} catch {
|
||||
shareOut.textContent = "Could not create the share link.";
|
||||
showToast("Could not create the share link.", "error");
|
||||
shareButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.textContent = "Loading…";
|
||||
root.appendChild(body);
|
||||
|
||||
load();
|
||||
|
||||
async function load() {
|
||||
let data;
|
||||
try {
|
||||
data = await rpc("get-error-report", { id });
|
||||
} catch (err) {
|
||||
body.replaceChildren();
|
||||
if (err.status === 404) {
|
||||
body.textContent = "Error report not found.";
|
||||
} else {
|
||||
body.textContent = "Could not load the error report.";
|
||||
showToast("Could not load the error report.", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The hint is the page title; it never renders as a row.
|
||||
const keys = Object.keys(data).filter(
|
||||
(key) =>
|
||||
key !== "hint" &&
|
||||
data[key] !== null &&
|
||||
data[key] !== undefined &&
|
||||
data[key] !== ""
|
||||
);
|
||||
if (typeof data.hint === "string" && data.hint !== "") {
|
||||
header.querySelector("h1").textContent = data.hint;
|
||||
}
|
||||
const ordered = [
|
||||
...KNOWN_FIRST.filter((key) => keys.includes(key)),
|
||||
...keys.filter((key) => !KNOWN_FIRST.includes(key)).sort(),
|
||||
];
|
||||
|
||||
const list = document.createElement("dl");
|
||||
list.className = "admin-detail";
|
||||
for (const key of ordered) {
|
||||
if (BLOCK_FIELDS.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
const term = document.createElement("dt");
|
||||
term.textContent = labelFor(key);
|
||||
const desc = document.createElement("dd");
|
||||
desc.textContent = textFor(data[key]);
|
||||
list.appendChild(term);
|
||||
list.appendChild(desc);
|
||||
}
|
||||
body.replaceChildren();
|
||||
body.appendChild(list);
|
||||
|
||||
for (const key of ordered) {
|
||||
if (!BLOCK_FIELDS.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
const section = document.createElement("section");
|
||||
section.className = "admin-block";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = BLOCK_TITLES[key] || key;
|
||||
const pre = document.createElement("div");
|
||||
pre.className = "admin-block-body";
|
||||
pre.textContent = textFor(data[key]);
|
||||
section.appendChild(heading);
|
||||
section.appendChild(pre);
|
||||
body.appendChild(section);
|
||||
}
|
||||
|
||||
shareButton.disabled = false;
|
||||
}
|
||||
}
|
||||
173
backend/resources/admin/js/pages/error-reports.js
Normal file
173
backend/resources/admin/js/pages/error-reports.js
Normal file
@ -0,0 +1,173 @@
|
||||
// Error reports list: source filter with pagination. Every value
|
||||
// is painted as text; source badges use a fixed class map, never the
|
||||
// raw value. JSON responses use camelCase (`nextSince`, …).
|
||||
// The `hint` search is gone from the panel on purpose (no index
|
||||
// serves it at PRO scale); a bookmarked `?hint=` is ignored and
|
||||
// shows everything.
|
||||
|
||||
import { rpc } from "../api.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
import { renderHeader } from "../components/header.js";
|
||||
import { renderTable } from "../components/table.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
import { readQuery, writeQuery, detailUrl } from "../url.js";
|
||||
|
||||
const SOURCES = ["logging", "audit-log", "rlimit", "legacy-v1", "legacy-v2"];
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const SOURCE_BADGES = {
|
||||
logging: "admin-badge-source-logging",
|
||||
"audit-log": "admin-badge-source-audit",
|
||||
rlimit: "admin-badge-source-rlimit",
|
||||
"legacy-v1": "admin-badge-source-legacy",
|
||||
"legacy-v2": "admin-badge-source-legacy",
|
||||
};
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: "createdAt", label: "Date", class: "admin-cell-date" },
|
||||
{ key: "source", label: "Source", class: "admin-cell-source" },
|
||||
{ key: "kind", label: "Kind", class: "admin-cell-kind" },
|
||||
{ key: "hint", label: "Hint", class: "admin-cell-hint" },
|
||||
];
|
||||
|
||||
function sourceCell(source) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "admin-badge " + (SOURCE_BADGES[source] || "admin-badge-source-legacy");
|
||||
badge.textContent = source;
|
||||
return badge;
|
||||
}
|
||||
|
||||
export function errorReportsPage(root, { onNavigate }) {
|
||||
const fromUrl = readQuery(["source"]);
|
||||
const state = {
|
||||
source: SOURCES.includes(fromUrl.source) ? fromUrl.source : "",
|
||||
items: [],
|
||||
nextSince: null,
|
||||
nextId: null,
|
||||
loading: false,
|
||||
loadedOnce: false,
|
||||
};
|
||||
|
||||
root.appendChild(renderHeader("Error reports"));
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "admin-controls";
|
||||
|
||||
const sourceSelect = document.createElement("select");
|
||||
sourceSelect.className = "admin-select";
|
||||
sourceSelect.setAttribute("aria-label", "Filter by source");
|
||||
const allOption = document.createElement("option");
|
||||
allOption.value = "";
|
||||
allOption.textContent = "All sources";
|
||||
sourceSelect.appendChild(allOption);
|
||||
for (const source of SOURCES) {
|
||||
const option = document.createElement("option");
|
||||
option.value = source;
|
||||
option.textContent = source;
|
||||
sourceSelect.appendChild(option);
|
||||
}
|
||||
sourceSelect.value = state.source;
|
||||
controls.appendChild(sourceSelect);
|
||||
|
||||
const searchButton = document.createElement("button");
|
||||
searchButton.className = "admin-button";
|
||||
searchButton.textContent = "Search";
|
||||
searchButton.addEventListener("click", () => {
|
||||
state.source = sourceSelect.value;
|
||||
state.nextSince = null;
|
||||
state.nextId = null;
|
||||
load();
|
||||
});
|
||||
controls.appendChild(searchButton);
|
||||
|
||||
const clearButton = document.createElement("button");
|
||||
clearButton.className = "admin-button admin-button-ghost";
|
||||
clearButton.textContent = "Clear";
|
||||
clearButton.addEventListener("click", () => {
|
||||
sourceSelect.value = "";
|
||||
state.source = "";
|
||||
state.nextSince = null;
|
||||
state.nextId = null;
|
||||
load();
|
||||
});
|
||||
controls.appendChild(clearButton);
|
||||
root.appendChild(controls);
|
||||
|
||||
const countLine = document.createElement("p");
|
||||
countLine.className = "admin-count";
|
||||
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);
|
||||
|
||||
function paint() {
|
||||
tableWrap.replaceChildren();
|
||||
if (state.loading && !state.loadedOnce) {
|
||||
countLine.textContent = "Loading…";
|
||||
} else if (state.items.length === 0) {
|
||||
countLine.textContent = "No error reports match the current filters.";
|
||||
} else {
|
||||
countLine.textContent =
|
||||
`Showing ${state.items.length} report` + (state.items.length === 1 ? "" : "s") +
|
||||
(state.nextSince === null ? " (all)" : " — more available");
|
||||
}
|
||||
|
||||
const rows = state.items.map((item) => ({
|
||||
...item,
|
||||
createdAt: formatDate(item.createdAt),
|
||||
}));
|
||||
const table = renderTable(COLUMNS, rows);
|
||||
const bodyRows = table.querySelector("tbody").rows;
|
||||
for (let i = 0; i < bodyRows.length; i++) {
|
||||
const row = bodyRows[i];
|
||||
const item = state.items[i];
|
||||
row.cells[1].replaceChildren(sourceCell(item.source));
|
||||
row.addEventListener("click", () =>
|
||||
onNavigate(detailUrl("error-report", item.id)));
|
||||
}
|
||||
tableWrap.appendChild(table);
|
||||
nextButton.disabled = state.loading || state.nextSince === null;
|
||||
searchButton.disabled = state.loading;
|
||||
clearButton.disabled = state.loading;
|
||||
pager.style.display =
|
||||
state.items.length === 0 || state.nextSince === null ? "none" : "";
|
||||
}
|
||||
|
||||
async function load({ append = false } = {}) {
|
||||
state.loading = true;
|
||||
paint();
|
||||
writeQuery({ source: state.source });
|
||||
const params = { limit: PAGE_SIZE };
|
||||
if (state.source) {
|
||||
params.source = state.source;
|
||||
}
|
||||
if (append && state.nextSince) {
|
||||
params.since = state.nextSince;
|
||||
params.sinceId = state.nextId;
|
||||
}
|
||||
try {
|
||||
const data = await rpc("get-error-reports", 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("Could not load error reports.", "error");
|
||||
} finally {
|
||||
state.loading = false;
|
||||
paint();
|
||||
}
|
||||
}
|
||||
|
||||
paint();
|
||||
load();
|
||||
}
|
||||
378
backend/resources/admin/js/pages/file-detail.js
Normal file
378
backend/resources/admin/js/pages/file-detail.js
Normal file
@ -0,0 +1,378 @@
|
||||
// File detail: metadata plus validate, repair, and export.
|
||||
// The file resolves by id through `get-file`, regardless of its
|
||||
// own or its parents' deleted state: the admin must reach stray
|
||||
// objects the lists hide. (Lists keep hiding children of deleted
|
||||
// parents.) Validating paints the error table; repairing asks to
|
||||
// type the error count first (like the bulk user delete) and
|
||||
// snapshots by default; exporting is a plain download link with
|
||||
// the session cookie. Every value is painted as text. An unknown
|
||||
// id shows "not found" instead of failing.
|
||||
|
||||
import { rpc, methodUrl } from "../api.js";
|
||||
import { renderHeader, paintDetailTitle } from "../components/header.js";
|
||||
import { deletedNotice, restoreBlock } from "../components/restore.js";
|
||||
import { paintDeleteAction } from "../components/delete.js";
|
||||
import { renderTable } from "../components/table.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
import { backUrl } from "../url.js";
|
||||
|
||||
const ERROR_COLUMNS = [
|
||||
{ key: "code", label: "Code", class: "admin-cell-name" },
|
||||
{ key: "hint", label: "Hint", class: "admin-cell-left" },
|
||||
{ key: "shapeId", label: "Shape", class: "admin-cell-left" },
|
||||
{ key: "pageId", label: "Page", class: "admin-cell-left" },
|
||||
];
|
||||
|
||||
export function fileDetailPage(root, { id, onNavigate }) {
|
||||
const header = renderHeader("File");
|
||||
root.appendChild(header);
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "admin-actions";
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "admin-button admin-button-ghost";
|
||||
back.textContent = "Back to list";
|
||||
back.addEventListener("click", () => onNavigate(backUrl("?screen=files")));
|
||||
bar.appendChild(back);
|
||||
root.appendChild(bar);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.textContent = "Loading…";
|
||||
root.appendChild(body);
|
||||
|
||||
const state = { file: null, errors: null };
|
||||
let refreshRepair = () => {};
|
||||
let clearDeleteAction = null;
|
||||
|
||||
load();
|
||||
|
||||
async function load() {
|
||||
let file;
|
||||
try {
|
||||
file = await rpc("get-file", { id });
|
||||
} catch (err) {
|
||||
body.replaceChildren();
|
||||
if (clearDeleteAction) {
|
||||
clearDeleteAction();
|
||||
clearDeleteAction = null;
|
||||
}
|
||||
if (err.status === 404) {
|
||||
body.textContent = "File not found.";
|
||||
} else {
|
||||
body.textContent = "Could not load the file.";
|
||||
showToast("Could not load the file.", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.file = file;
|
||||
paintDetailTitle(header, {
|
||||
section: "File",
|
||||
query: backUrl("?screen=files"),
|
||||
name: file.name,
|
||||
onNavigate,
|
||||
});
|
||||
body.replaceChildren();
|
||||
body.appendChild(infoBlock(file));
|
||||
body.appendChild(validateBlock());
|
||||
body.appendChild(repairBlock());
|
||||
body.appendChild(exportBlock());
|
||||
if (file.deletedAt) {
|
||||
body.appendChild(deletedNotice("This file was marked for deletion."));
|
||||
if (clearDeleteAction) {
|
||||
clearDeleteAction();
|
||||
clearDeleteAction = null;
|
||||
}
|
||||
body.appendChild(restoreBlock({
|
||||
command: "restore-file",
|
||||
id,
|
||||
label: "this file",
|
||||
scope: "with its project and team",
|
||||
onRestored: () => load(),
|
||||
}));
|
||||
} else {
|
||||
if (clearDeleteAction) {
|
||||
clearDeleteAction();
|
||||
}
|
||||
clearDeleteAction = paintDeleteAction(bar, {
|
||||
command: "delete-file",
|
||||
id,
|
||||
label: "this file",
|
||||
name: file.name,
|
||||
kind: "File",
|
||||
onDeleted: () => load(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function infoBlock(file) {
|
||||
const list = document.createElement("dl");
|
||||
list.className = "admin-detail";
|
||||
const idTerm = document.createElement("dt");
|
||||
idTerm.textContent = "Id";
|
||||
const idDesc = document.createElement("dd");
|
||||
idDesc.textContent = String(file.id ?? "—");
|
||||
list.appendChild(idTerm);
|
||||
list.appendChild(idDesc);
|
||||
|
||||
const projectTerm = document.createElement("dt");
|
||||
projectTerm.textContent = "Project";
|
||||
const projectDesc = document.createElement("dd");
|
||||
const projectLink = document.createElement("a");
|
||||
projectLink.textContent = String(file.projectName ?? file.projectId ?? "—");
|
||||
projectLink.href = "?screen=project&id=" + encodeURIComponent(file.projectId);
|
||||
projectLink.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
onNavigate("?screen=project&id=" + encodeURIComponent(file.projectId));
|
||||
});
|
||||
projectDesc.appendChild(projectLink);
|
||||
list.appendChild(projectTerm);
|
||||
list.appendChild(projectDesc);
|
||||
|
||||
const teamTerm = document.createElement("dt");
|
||||
teamTerm.textContent = "Team";
|
||||
const teamDesc = document.createElement("dd");
|
||||
const teamLink = document.createElement("a");
|
||||
teamLink.textContent = String(file.teamName ?? file.teamId ?? "—");
|
||||
teamLink.href = "?screen=team&id=" + encodeURIComponent(file.teamId);
|
||||
teamLink.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
onNavigate("?screen=team&id=" + encodeURIComponent(file.teamId));
|
||||
});
|
||||
teamDesc.appendChild(teamLink);
|
||||
list.appendChild(teamTerm);
|
||||
list.appendChild(teamDesc);
|
||||
return list;
|
||||
}
|
||||
|
||||
function validateBlock() {
|
||||
const section = document.createElement("section");
|
||||
section.className = "admin-block";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Validate";
|
||||
section.appendChild(heading);
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.className = "admin-button";
|
||||
button.textContent = "Validate now";
|
||||
section.appendChild(button);
|
||||
|
||||
const result = document.createElement("div");
|
||||
result.className = "admin-result";
|
||||
section.appendChild(result);
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
result.textContent = "Validating…";
|
||||
try {
|
||||
const data = await rpc("validate-file", { fileId: id });
|
||||
state.errors = data.errors ?? [];
|
||||
refreshRepair();
|
||||
result.replaceChildren();
|
||||
if (state.errors.length === 0) {
|
||||
result.textContent = "No validation errors found.";
|
||||
} else {
|
||||
const note = document.createElement("p");
|
||||
note.className = "admin-count";
|
||||
note.textContent =
|
||||
`${state.errors.length} error` + (state.errors.length === 1 ? "" : "s") + " found.";
|
||||
result.appendChild(note);
|
||||
const rows = state.errors.map((error) => ({
|
||||
...error,
|
||||
shapeId: error.shapeId ?? "—",
|
||||
pageId: error.pageId ?? "—",
|
||||
}));
|
||||
result.appendChild(renderTable(ERROR_COLUMNS, rows));
|
||||
}
|
||||
} catch {
|
||||
result.textContent = "Could not validate the file.";
|
||||
showToast("Could not validate the file.", "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
return section;
|
||||
}
|
||||
|
||||
function repairBlock() {
|
||||
const section = document.createElement("section");
|
||||
section.className = "admin-block";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Repair";
|
||||
section.appendChild(heading);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "admin-count";
|
||||
hint.textContent =
|
||||
"Repairing writes the file; a snapshot is kept by default. Dangerous actions ask for confirmation first.";
|
||||
section.appendChild(hint);
|
||||
|
||||
const snapshotLabel = document.createElement("label");
|
||||
snapshotLabel.className = "admin-check";
|
||||
const snapshot = document.createElement("input");
|
||||
snapshot.type = "checkbox";
|
||||
snapshot.checked = true;
|
||||
snapshotLabel.appendChild(snapshot);
|
||||
snapshotLabel.append(" Keep a repair snapshot");
|
||||
section.appendChild(snapshotLabel);
|
||||
|
||||
const confirmWrap = document.createElement("div");
|
||||
confirmWrap.className = "admin-confirm";
|
||||
|
||||
const confirm = document.createElement("button");
|
||||
confirm.className = "admin-button admin-button-danger";
|
||||
confirm.textContent = "Repair";
|
||||
confirm.title = "Repair the file, fixing the validated errors.";
|
||||
confirmWrap.appendChild(confirm);
|
||||
section.appendChild(confirmWrap);
|
||||
|
||||
const result = document.createElement("div");
|
||||
result.className = "admin-result";
|
||||
section.appendChild(result);
|
||||
|
||||
function expected() {
|
||||
return state.errors === null ? null : state.errors.length;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const count = expected();
|
||||
if (count === null) {
|
||||
confirm.textContent = "Repair";
|
||||
confirm.title = "Validate first, then repair.";
|
||||
confirm.disabled = true;
|
||||
return;
|
||||
}
|
||||
confirm.textContent = count > 0 ? `Repair ${count}` : "Repair (nothing to fix)";
|
||||
confirm.title = count > 0
|
||||
? `Repair ${count} errors. A snapshot is kept.`
|
||||
: "Nothing to fix.";
|
||||
confirm.disabled = count === 0;
|
||||
}
|
||||
|
||||
refresh();
|
||||
refreshRepair = refresh;
|
||||
|
||||
confirm.addEventListener("click", async () => {
|
||||
const count = expected() ?? 0;
|
||||
if (!window.confirm(`Repair ${count} errors? A snapshot is kept by default.`)) {
|
||||
return;
|
||||
}
|
||||
confirm.disabled = true;
|
||||
result.textContent = "Repairing…";
|
||||
try {
|
||||
const params = { fileId: id };
|
||||
if (!snapshot.checked) {
|
||||
params.skipSnapshot = true;
|
||||
}
|
||||
const data = await rpc("repair-file", params);
|
||||
state.errors = data.errors ?? [];
|
||||
refresh();
|
||||
result.textContent =
|
||||
`Applied ${data.changes} change` + (data.changes === 1 ? "" : "s") +
|
||||
(state.errors.length === 0 ? "; no errors left." : `; ${state.errors.length} errors left.`) +
|
||||
(data.snapshotTaken ? " Snapshot kept." : "");
|
||||
showToast("Repair finished.");
|
||||
} catch {
|
||||
result.textContent = "Could not repair the file.";
|
||||
showToast("Could not repair the file.", "error");
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
return section;
|
||||
}
|
||||
|
||||
function exportBlock() {
|
||||
const section = document.createElement("section");
|
||||
section.className = "admin-block";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Export";
|
||||
section.appendChild(heading);
|
||||
|
||||
const libsLabel = document.createElement("label");
|
||||
libsLabel.className = "admin-check";
|
||||
const libs = document.createElement("input");
|
||||
libs.type = "checkbox";
|
||||
libsLabel.appendChild(libs);
|
||||
libsLabel.append(" Include libraries");
|
||||
section.appendChild(libsLabel);
|
||||
|
||||
const embedLabel = document.createElement("label");
|
||||
embedLabel.className = "admin-check";
|
||||
const embed = document.createElement("input");
|
||||
embed.type = "checkbox";
|
||||
embedLabel.appendChild(embed);
|
||||
embedLabel.append(" Embed assets");
|
||||
section.appendChild(embedLabel);
|
||||
|
||||
const buttons = document.createElement("div");
|
||||
buttons.className = "admin-confirm";
|
||||
|
||||
const download = document.createElement("a");
|
||||
download.className = "admin-button";
|
||||
download.textContent = "Download";
|
||||
buttons.appendChild(download);
|
||||
|
||||
const clone = document.createElement("button");
|
||||
clone.className = "admin-button admin-button-ghost";
|
||||
clone.textContent = "Clone into my project";
|
||||
buttons.appendChild(clone);
|
||||
section.appendChild(buttons);
|
||||
|
||||
function exportBody(extra = {}) {
|
||||
return JSON.stringify({
|
||||
fileIds: [id],
|
||||
includelibs: libs.checked,
|
||||
embedassets: embed.checked,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchExport(extra = {}) {
|
||||
const response = await fetch(methodUrl("export-files"), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: exportBody(extra),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("export failed");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function downloadName(response) {
|
||||
const disposition = response.headers.get("content-disposition") ?? "";
|
||||
const filename = disposition.split("filename=")[1];
|
||||
return (filename ?? id + ".penpot").trim();
|
||||
}
|
||||
|
||||
download.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const response = await fetchExport();
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = downloadName(response);
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
showToast("Could not download the file.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
clone.addEventListener("click", async () => {
|
||||
clone.disabled = true;
|
||||
try {
|
||||
await fetchExport({ clone: true });
|
||||
showToast("Cloned into your project.");
|
||||
} catch {
|
||||
showToast("Could not clone the file.", "error");
|
||||
} finally {
|
||||
clone.disabled = false;
|
||||
}
|
||||
});
|
||||
return section;
|
||||
}
|
||||
}
|
||||
167
backend/resources/admin/js/pages/files.js
Normal file
167
backend/resources/admin/js/pages/files.js
Normal file
@ -0,0 +1,167 @@
|
||||
// Files list: id lookup with a deleted filter and pagination, plus
|
||||
// import.
|
||||
//
|
||||
// Lookup is by exact id (primary key); substring search has no
|
||||
// usable index at PRO scale. `teamId`/`projectId` are not offered
|
||||
// as selectors here: they only arrive through the URL, from the
|
||||
// "view files" links, and show as removable active filters. Every
|
||||
// value is painted as text. JSON responses use camelCase
|
||||
// (`nextSince`, …).
|
||||
|
||||
import { rpc, methodUrl } from "../api.js";
|
||||
import { listPage } from "../components/list-page.js";
|
||||
import { statusCell } from "../components/badges.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Name", class: "admin-cell-name admin-cell-fill" },
|
||||
{ key: "projectName", label: "Project", class: "admin-cell-project" },
|
||||
{ key: "teamName", label: "Team", class: "admin-cell-team" },
|
||||
{ key: "createdAt", label: "Created", class: "admin-cell-date" },
|
||||
{ key: "modifiedAt", label: "Modified", class: "admin-cell-date" },
|
||||
{ key: "deletedAt", label: "Deleted", class: "admin-cell-date" },
|
||||
{ key: "status", label: "Status", class: "admin-cell-status" },
|
||||
];
|
||||
|
||||
export function filesPage(root, { onNavigate }) {
|
||||
let importLabel = null;
|
||||
|
||||
async function upload(picked, state, reload) {
|
||||
showToast("Importing " + picked.name + "…");
|
||||
const form = new FormData();
|
||||
form.append("file", picked, picked.name);
|
||||
try {
|
||||
const response = await fetch(methodUrl("import-files"), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("import failed");
|
||||
}
|
||||
showToast("Imported " + picked.name + ".");
|
||||
state.nextSince = null;
|
||||
state.nextId = null;
|
||||
reload();
|
||||
} catch {
|
||||
showToast("Could not import " + picked.name + ".", "error");
|
||||
}
|
||||
}
|
||||
|
||||
listPage(root, {
|
||||
title: "Files",
|
||||
noun: "files",
|
||||
itemNoun: "file",
|
||||
emptyText: "No files match the current filters.",
|
||||
columns: COLUMNS,
|
||||
rpcCommand: "get-files",
|
||||
loadErrorText: "Could not load files.",
|
||||
lookupPlaceholder: "Look up by id…",
|
||||
lookupAriaLabel: "Look up by file id",
|
||||
detailKind: "file",
|
||||
tableClass: "admin-table-auto",
|
||||
hideEmptyTable: true,
|
||||
extraQueryKeys: ["teamId", "projectId"],
|
||||
initialState: (fromUrl) => ({
|
||||
teamId: fromUrl.teamId ?? null,
|
||||
teamName: null,
|
||||
projectId: fromUrl.projectId ?? null,
|
||||
projectName: null,
|
||||
}),
|
||||
resetExtra: (state) => {
|
||||
state.teamId = null;
|
||||
state.teamName = null;
|
||||
state.projectId = null;
|
||||
state.projectName = null;
|
||||
},
|
||||
readRpcParams: (state) => ({
|
||||
...(state.lookup ? { id: state.lookup } : {}),
|
||||
...(state.teamId ? { teamId: state.teamId } : {}),
|
||||
...(state.projectId ? { projectId: state.projectId } : {}),
|
||||
}),
|
||||
beforeLoad: async (state) => {
|
||||
if (state.teamId && state.teamName === null) {
|
||||
try {
|
||||
const team = await rpc("get-team", { id: state.teamId });
|
||||
state.teamName = team.name;
|
||||
} catch {
|
||||
state.teamName = null;
|
||||
}
|
||||
}
|
||||
if (state.projectId && state.projectName === null) {
|
||||
try {
|
||||
const project = await rpc("get-project", { id: state.projectId });
|
||||
state.projectName = project.name ?? null;
|
||||
} catch {
|
||||
state.projectName = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
refreshHeader: ({ state, root: pageRoot, onNavigate: navigate }) => {
|
||||
const titleEl = pageRoot.querySelector("h1");
|
||||
titleEl.replaceChildren();
|
||||
const crumbs = [];
|
||||
if (state.teamId !== null) {
|
||||
crumbs.push({
|
||||
label: "Team: " + (state.teamName ?? state.teamId),
|
||||
query: "?screen=team&id=" + encodeURIComponent(state.teamId),
|
||||
});
|
||||
}
|
||||
if (state.projectId !== null) {
|
||||
crumbs.push({
|
||||
label: "Project: " + (state.projectName ?? state.projectId),
|
||||
query: "?screen=project&id=" + encodeURIComponent(state.projectId),
|
||||
});
|
||||
}
|
||||
crumbs.push({ label: "Files" });
|
||||
crumbs.forEach((crumb, index) => {
|
||||
if (index > 0) {
|
||||
titleEl.append(" > ");
|
||||
}
|
||||
if (crumb.query) {
|
||||
const crumbButton = document.createElement("button");
|
||||
crumbButton.className = "admin-crumb";
|
||||
crumbButton.textContent = crumb.label;
|
||||
crumbButton.setAttribute("aria-label", "Back to " + crumb.label);
|
||||
crumbButton.addEventListener("click", () => navigate(crumb.query));
|
||||
titleEl.appendChild(crumbButton);
|
||||
} else {
|
||||
titleEl.append(crumb.label);
|
||||
}
|
||||
});
|
||||
},
|
||||
decorateRow: (item) => ({
|
||||
...item,
|
||||
createdAt: formatDate(item.createdAt),
|
||||
modifiedAt: formatDate(item.modifiedAt),
|
||||
deletedAt: item.deletedAt ? formatDate(item.deletedAt) : "—",
|
||||
status: item.deletedAt ? "deleted" : "active",
|
||||
}),
|
||||
patchRow: (rowEl, row, _item, { cellByKey }) => {
|
||||
cellByKey(rowEl, "status")?.replaceChildren(statusCell(row.status));
|
||||
},
|
||||
extraControls: (controls, { state, reload }) => {
|
||||
importLabel = document.createElement("label");
|
||||
importLabel.className = "admin-button";
|
||||
importLabel.textContent = "Import…";
|
||||
const importInput = document.createElement("input");
|
||||
importInput.type = "file";
|
||||
importInput.accept = ".penpot";
|
||||
importInput.hidden = true;
|
||||
importInput.setAttribute("aria-label", "Import a penpot file");
|
||||
importInput.addEventListener("change", () => {
|
||||
if (importInput.files.length > 0) {
|
||||
upload(importInput.files[0], state, reload);
|
||||
importInput.value = "";
|
||||
}
|
||||
});
|
||||
importLabel.appendChild(importInput);
|
||||
controls.appendChild(importLabel);
|
||||
},
|
||||
paintExtra: ({ state }) => {
|
||||
importLabel?.classList.toggle("admin-button-disabled", state.loading);
|
||||
},
|
||||
onNavigate,
|
||||
});
|
||||
}
|
||||
243
backend/resources/admin/js/pages/job-detail.js
Normal file
243
backend/resources/admin/js/pages/job-detail.js
Normal file
@ -0,0 +1,243 @@
|
||||
// Job detail: metadata, owner, bounded params, error/result and
|
||||
// the append-only event history, plus Cancel for live jobs.
|
||||
// The job resolves by id through `get-job`; a GC-swept id shows
|
||||
// "not found" instead of failing. Cancelling a running job stops
|
||||
// it at its next heartbeat; a job the runner just finished answers
|
||||
// as a no-op ("already picked up, reload"). Every value is painted
|
||||
// as text.
|
||||
|
||||
import { rpc, errorHint } from "../api.js";
|
||||
import { jobKindCell, jobStatusCell } from "../components/badges.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
import { renderHeader, paintDetailTitle } from "../components/header.js";
|
||||
import { renderTable } from "../components/table.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
import { backUrl } from "../url.js";
|
||||
|
||||
const CANCELLABLE = ["new", "scheduled", "retry", "running"];
|
||||
|
||||
const EVENT_COLUMNS = [
|
||||
{ key: "createdAt", label: "Date", class: "admin-cell-date" },
|
||||
{ key: "kind", label: "Kind", class: "admin-cell-kind" },
|
||||
{ key: "detail", label: "Detail", class: "admin-cell-left" },
|
||||
];
|
||||
|
||||
export function jobDetailPage(root, { id, onNavigate }) {
|
||||
const header = renderHeader("Job");
|
||||
root.appendChild(header);
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "admin-actions";
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "admin-button admin-button-ghost";
|
||||
back.textContent = "Back to list";
|
||||
back.title = "Back to the jobs list.";
|
||||
back.addEventListener("click", () => onNavigate(backUrl("?screen=jobs")));
|
||||
bar.appendChild(back);
|
||||
root.appendChild(bar);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.textContent = "Loading…";
|
||||
root.appendChild(body);
|
||||
|
||||
let clearCancelAction = null;
|
||||
|
||||
load();
|
||||
|
||||
async function load() {
|
||||
let job;
|
||||
try {
|
||||
job = await rpc("get-job", { id });
|
||||
} catch (err) {
|
||||
body.replaceChildren();
|
||||
if (clearCancelAction) {
|
||||
clearCancelAction();
|
||||
clearCancelAction = null;
|
||||
}
|
||||
if (err.status === 404) {
|
||||
body.textContent = "Job not found.";
|
||||
} else {
|
||||
body.textContent = "Could not load the job.";
|
||||
showToast("Could not load the job.", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
paintDetailTitle(header, {
|
||||
section: "Job",
|
||||
query: backUrl("?screen=jobs"),
|
||||
name: job.name,
|
||||
onNavigate,
|
||||
});
|
||||
body.replaceChildren();
|
||||
body.appendChild(infoBlock(job, onNavigate));
|
||||
body.appendChild(paramsBlock(job));
|
||||
body.appendChild(eventsBlock(job));
|
||||
if (clearCancelAction) {
|
||||
clearCancelAction();
|
||||
clearCancelAction = null;
|
||||
}
|
||||
if (CANCELLABLE.includes(job.status)) {
|
||||
clearCancelAction = paintCancelAction(bar, { job, onCancelled: () => load() });
|
||||
}
|
||||
}
|
||||
|
||||
function infoRow(list, label, value) {
|
||||
const term = document.createElement("dt");
|
||||
term.textContent = label;
|
||||
const desc = document.createElement("dd");
|
||||
desc.textContent = value;
|
||||
list.appendChild(term);
|
||||
list.appendChild(desc);
|
||||
}
|
||||
|
||||
function infoBlock(job, onNavigate) {
|
||||
const section = document.createElement("section");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = "Job";
|
||||
section.appendChild(title);
|
||||
|
||||
const badges = document.createElement("p");
|
||||
badges.appendChild(jobStatusCell(job.status));
|
||||
badges.appendChild(document.createTextNode(" "));
|
||||
badges.appendChild(jobKindCell(job.kind));
|
||||
section.appendChild(badges);
|
||||
|
||||
const list = document.createElement("dl");
|
||||
list.className = "admin-detail";
|
||||
infoRow(list, "Id", job.id);
|
||||
infoRow(list, "Name", job.name);
|
||||
infoRow(list, "Queue", job.queue);
|
||||
if (job.label) {
|
||||
infoRow(list, "Label", job.label);
|
||||
}
|
||||
infoRow(list, "Tenant", job.tenant);
|
||||
infoRow(list, "Priority", String(job.priority));
|
||||
infoRow(list, "Retries", `${job.retryNum}/${job.maxRetries}`);
|
||||
infoRow(list, "Scheduled", formatDate(job.scheduledAt));
|
||||
infoRow(list, "Created", formatDate(job.createdAt));
|
||||
infoRow(list, "Modified", formatDate(job.modifiedAt));
|
||||
if (job.startedAt) {
|
||||
infoRow(list, "Started", formatDate(job.startedAt));
|
||||
}
|
||||
if (job.completedAt) {
|
||||
infoRow(list, "Completed", formatDate(job.completedAt));
|
||||
}
|
||||
if (job.expiresAt) {
|
||||
infoRow(list, "Expires", formatDate(job.expiresAt));
|
||||
}
|
||||
section.appendChild(list);
|
||||
|
||||
const ownerTitle = document.createElement("h2");
|
||||
ownerTitle.textContent = "Owner";
|
||||
section.appendChild(ownerTitle);
|
||||
if (job.profileId) {
|
||||
const owner = document.createElement("p");
|
||||
const link = document.createElement("a");
|
||||
link.textContent = job.ownerEmail ?? job.profileId;
|
||||
link.title = "Open the owner profile.";
|
||||
link.href = "?screen=user&id=" + encodeURIComponent(job.profileId);
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
onNavigate("?screen=user&id=" + encodeURIComponent(job.profileId));
|
||||
});
|
||||
owner.appendChild(link);
|
||||
if (job.ownerFullname) {
|
||||
owner.appendChild(document.createTextNode(` (${job.ownerFullname})`));
|
||||
}
|
||||
section.appendChild(owner);
|
||||
} else {
|
||||
const none = document.createElement("p");
|
||||
none.className = "admin-count";
|
||||
none.textContent = "System job — no profile.";
|
||||
section.appendChild(none);
|
||||
}
|
||||
|
||||
if (job.error) {
|
||||
const errorTitle = document.createElement("h2");
|
||||
errorTitle.textContent = "Error";
|
||||
section.appendChild(errorTitle);
|
||||
section.appendChild(codeBlock(JSON.stringify(job.error, null, 2)));
|
||||
}
|
||||
if (job.result) {
|
||||
const resultTitle = document.createElement("h2");
|
||||
resultTitle.textContent = "Result";
|
||||
section.appendChild(resultTitle);
|
||||
section.appendChild(codeBlock(JSON.stringify(job.result, null, 2)));
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function paramsBlock(job) {
|
||||
const section = document.createElement("section");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = "Params";
|
||||
section.appendChild(title);
|
||||
const note = document.createElement("p");
|
||||
note.className = "admin-count";
|
||||
note.textContent = "Bounded preview: depth and length limited.";
|
||||
section.appendChild(note);
|
||||
section.appendChild(codeBlock(job.paramsPretty));
|
||||
return section;
|
||||
}
|
||||
|
||||
function codeBlock(text) {
|
||||
const pre = document.createElement("pre");
|
||||
pre.className = "admin-block-body";
|
||||
pre.textContent = text;
|
||||
return pre;
|
||||
}
|
||||
|
||||
function eventsBlock(job) {
|
||||
const section = document.createElement("section");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = `Events (${job.events.length})`;
|
||||
section.appendChild(title);
|
||||
if (job.events.length === 0) {
|
||||
const none = document.createElement("p");
|
||||
none.className = "admin-count";
|
||||
none.textContent = "No events recorded yet.";
|
||||
section.appendChild(none);
|
||||
return section;
|
||||
}
|
||||
const rows = job.events.map((event) => ({
|
||||
createdAt: formatDate(event.createdAt),
|
||||
kind: event.kind,
|
||||
detail: JSON.stringify(event.payload),
|
||||
}));
|
||||
section.appendChild(renderTable(EVENT_COLUMNS, rows));
|
||||
return section;
|
||||
}
|
||||
|
||||
function paintCancelAction(bar, { job, onCancelled }) {
|
||||
const cancel = document.createElement("button");
|
||||
cancel.className = "admin-button admin-button-danger";
|
||||
cancel.textContent = "Cancel";
|
||||
cancel.title = "Cancel this job. A running job stops at its next heartbeat.";
|
||||
cancel.addEventListener("click", async () => {
|
||||
if (!window.confirm(
|
||||
`Cancel ${job.name}? A running job stops at its next heartbeat.`
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
cancel.disabled = true;
|
||||
try {
|
||||
const out = await rpc("cancel-job", { id: job.id });
|
||||
if (out.cancelled) {
|
||||
showToast("Job cancelled.");
|
||||
} else {
|
||||
showToast("The job already finished. Reload to see it.", "error");
|
||||
}
|
||||
onCancelled();
|
||||
} catch (err) {
|
||||
showToast(errorHint(err) ?? "Could not cancel the job.", "error");
|
||||
cancel.disabled = false;
|
||||
}
|
||||
});
|
||||
bar.appendChild(cancel);
|
||||
|
||||
return () => {
|
||||
cancel.remove();
|
||||
};
|
||||
}
|
||||
}
|
||||
251
backend/resources/admin/js/pages/jobs.js
Normal file
251
backend/resources/admin/js/pages/jobs.js
Normal file
@ -0,0 +1,251 @@
|
||||
// Jobs list: name and status filters with keyset pagination.
|
||||
// Every value is painted as text; status and kind badges use fixed
|
||||
// class maps, never the raw value. JSON responses use camelCase
|
||||
// (`nextSince`, …). Modelled on `error-reports.js`: this list has no
|
||||
// `deleted_at` column, so the shared `list-page.js` (built around
|
||||
// the tri-state deleted filter) does not fit.
|
||||
|
||||
import { rpc } from "../api.js";
|
||||
import { jobKindCell, jobStatusCell } from "../components/badges.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
import { renderHeader } from "../components/header.js";
|
||||
import { renderTable } from "../components/table.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
import { readQuery, writeQuery, detailUrl } from "../url.js";
|
||||
|
||||
// Every job-def name the backend can produce today. The backend
|
||||
// accepts any text for the filter; this list only feeds the
|
||||
// dropdown, with "All" for everything else (including future names).
|
||||
const NAMES = [
|
||||
"audit-log-archive",
|
||||
"audit-log-gc",
|
||||
"delete-object",
|
||||
"demo-purge",
|
||||
"export-binfile",
|
||||
"file-gc",
|
||||
"file-gc-scheduler",
|
||||
"import-binfile",
|
||||
"jobs-gc",
|
||||
"objects-gc",
|
||||
"offload-file-data",
|
||||
"process-webhook-event",
|
||||
"restore-object",
|
||||
"run-webhook",
|
||||
"sendmail",
|
||||
"session-gc",
|
||||
"storage-gc-deleted",
|
||||
"storage-gc-touched",
|
||||
"storage-pending-gc",
|
||||
"telemetry",
|
||||
];
|
||||
|
||||
const STATUSES = [
|
||||
"new",
|
||||
"scheduled",
|
||||
"running",
|
||||
"retry",
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"aborted",
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: "createdAt", label: "Created", class: "admin-cell-date" },
|
||||
{ key: "name", label: "Name", class: "admin-cell-kind" },
|
||||
{ key: "queue", label: "Queue", class: "admin-cell-kind" },
|
||||
{ key: "kind", label: "Kind", class: "admin-cell-status" },
|
||||
{ key: "status", label: "Status", class: "admin-cell-status" },
|
||||
{ key: "retries", label: "Retries", class: "admin-cell-status" },
|
||||
{ key: "scheduledAt", label: "Scheduled", class: "admin-cell-date" },
|
||||
];
|
||||
|
||||
export function jobsPage(root, { onNavigate }) {
|
||||
const fromUrl = readQuery(["name", "status", "lookup"]);
|
||||
const state = {
|
||||
name: NAMES.includes(fromUrl.name) ? fromUrl.name : "",
|
||||
status: STATUSES.includes(fromUrl.status) ? fromUrl.status : "",
|
||||
lookup: fromUrl.lookup ?? "",
|
||||
items: [],
|
||||
nextSince: null,
|
||||
nextId: null,
|
||||
loading: false,
|
||||
loadedOnce: false,
|
||||
};
|
||||
|
||||
root.appendChild(renderHeader("Jobs"));
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "admin-controls";
|
||||
|
||||
const lookupInput = document.createElement("input");
|
||||
lookupInput.className = "admin-input";
|
||||
lookupInput.type = "search";
|
||||
lookupInput.placeholder = "Look up by id…";
|
||||
lookupInput.setAttribute("aria-label", "Look up by job id");
|
||||
lookupInput.value = state.lookup;
|
||||
lookupInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
submit();
|
||||
}
|
||||
});
|
||||
controls.appendChild(lookupInput);
|
||||
|
||||
const nameSelect = document.createElement("select");
|
||||
nameSelect.className = "admin-select";
|
||||
nameSelect.setAttribute("aria-label", "Filter by job name");
|
||||
const allNames = document.createElement("option");
|
||||
allNames.value = "";
|
||||
allNames.textContent = "All names";
|
||||
nameSelect.appendChild(allNames);
|
||||
for (const name of NAMES) {
|
||||
const option = document.createElement("option");
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
nameSelect.appendChild(option);
|
||||
}
|
||||
nameSelect.value = state.name;
|
||||
controls.appendChild(nameSelect);
|
||||
|
||||
const statusSelect = document.createElement("select");
|
||||
statusSelect.className = "admin-select";
|
||||
statusSelect.setAttribute("aria-label", "Filter by job status");
|
||||
const allStatuses = document.createElement("option");
|
||||
allStatuses.value = "";
|
||||
allStatuses.textContent = "All statuses";
|
||||
statusSelect.appendChild(allStatuses);
|
||||
for (const status of STATUSES) {
|
||||
const option = document.createElement("option");
|
||||
option.value = status;
|
||||
option.textContent = status;
|
||||
statusSelect.appendChild(option);
|
||||
}
|
||||
statusSelect.value = state.status;
|
||||
controls.appendChild(statusSelect);
|
||||
|
||||
function submit() {
|
||||
state.lookup = lookupInput.value.trim();
|
||||
state.name = nameSelect.value;
|
||||
state.status = statusSelect.value;
|
||||
state.nextSince = null;
|
||||
state.nextId = null;
|
||||
load();
|
||||
}
|
||||
|
||||
const searchButton = document.createElement("button");
|
||||
searchButton.className = "admin-button";
|
||||
searchButton.textContent = "Search";
|
||||
searchButton.title = "Apply the current filters.";
|
||||
searchButton.addEventListener("click", submit);
|
||||
controls.appendChild(searchButton);
|
||||
|
||||
const clearButton = document.createElement("button");
|
||||
clearButton.className = "admin-button admin-button-ghost";
|
||||
clearButton.textContent = "Clear";
|
||||
clearButton.title = "Clear all filters.";
|
||||
clearButton.addEventListener("click", () => {
|
||||
lookupInput.value = "";
|
||||
nameSelect.value = "";
|
||||
statusSelect.value = "";
|
||||
state.lookup = "";
|
||||
state.name = "";
|
||||
state.status = "";
|
||||
state.nextSince = null;
|
||||
state.nextId = null;
|
||||
load();
|
||||
});
|
||||
controls.appendChild(clearButton);
|
||||
root.appendChild(controls);
|
||||
|
||||
const countLine = document.createElement("p");
|
||||
countLine.className = "admin-count";
|
||||
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.title = "Load the next page of jobs.";
|
||||
nextButton.addEventListener("click", () => load({ append: true }));
|
||||
pager.appendChild(nextButton);
|
||||
root.appendChild(pager);
|
||||
|
||||
function paint() {
|
||||
tableWrap.replaceChildren();
|
||||
if (state.loading && !state.loadedOnce) {
|
||||
countLine.textContent = "Loading…";
|
||||
} else if (state.items.length === 0) {
|
||||
countLine.textContent = "No jobs match the current filters.";
|
||||
} else {
|
||||
countLine.textContent =
|
||||
`Showing ${state.items.length} job` + (state.items.length === 1 ? "" : "s") +
|
||||
(state.nextSince === null ? " (all)" : " — more available");
|
||||
}
|
||||
|
||||
const rows = state.items.map((item) => ({
|
||||
...item,
|
||||
createdAt: formatDate(item.createdAt),
|
||||
scheduledAt: formatDate(item.scheduledAt),
|
||||
retries: `${item.retryNum}/${item.maxRetries}`,
|
||||
}));
|
||||
const table = renderTable(COLUMNS, rows);
|
||||
const bodyRows = table.querySelector("tbody").rows;
|
||||
for (let i = 0; i < bodyRows.length; i++) {
|
||||
const row = bodyRows[i];
|
||||
const item = state.items[i];
|
||||
const cells = row.querySelectorAll("td");
|
||||
cells[3].replaceChildren(jobKindCell(item.kind));
|
||||
cells[4].replaceChildren(jobStatusCell(item.status));
|
||||
row.addEventListener("click", () =>
|
||||
onNavigate(detailUrl("job", item.id)));
|
||||
}
|
||||
tableWrap.appendChild(table);
|
||||
nextButton.disabled = state.loading || state.nextSince === null;
|
||||
searchButton.disabled = state.loading;
|
||||
clearButton.disabled = state.loading;
|
||||
pager.style.display =
|
||||
state.items.length === 0 || state.nextSince === null ? "none" : "";
|
||||
}
|
||||
|
||||
async function load({ append = false } = {}) {
|
||||
state.loading = true;
|
||||
paint();
|
||||
writeQuery({ name: state.name, status: state.status, lookup: state.lookup });
|
||||
const params = { limit: PAGE_SIZE };
|
||||
if (state.lookup) {
|
||||
params.id = state.lookup;
|
||||
} else {
|
||||
if (state.name) {
|
||||
params.name = state.name;
|
||||
}
|
||||
if (state.status) {
|
||||
params.status = state.status;
|
||||
}
|
||||
}
|
||||
if (append && state.nextSince) {
|
||||
params.since = state.nextSince;
|
||||
params.sinceId = state.nextId;
|
||||
}
|
||||
try {
|
||||
const data = await rpc("get-jobs", 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("Could not load jobs.", "error");
|
||||
} finally {
|
||||
state.loading = false;
|
||||
paint();
|
||||
}
|
||||
}
|
||||
|
||||
paint();
|
||||
load();
|
||||
}
|
||||
157
backend/resources/admin/js/pages/project-detail.js
Normal file
157
backend/resources/admin/js/pages/project-detail.js
Normal file
@ -0,0 +1,157 @@
|
||||
// Project detail: metadata plus a jump to its files.
|
||||
// The project resolves by id through `get-project`, regardless of
|
||||
// its own or its team's deleted state: the admin must reach stray
|
||||
// objects the lists hide. (Lists keep hiding children of deleted
|
||||
// parents.) The "View files" button opens the files list filtered
|
||||
// by this project; the team name links back to the team page.
|
||||
// Every value is painted as text. An unknown id shows "not found"
|
||||
// instead of failing.
|
||||
|
||||
import { rpc } from "../api.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
import { renderHeader, paintDetailTitle } from "../components/header.js";
|
||||
import { deletedNotice, restoreBlock } from "../components/restore.js";
|
||||
import { paintDeleteAction } from "../components/delete.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
import { backUrl } from "../url.js";
|
||||
|
||||
export function projectDetailPage(root, { id, onNavigate }) {
|
||||
const header = renderHeader("Project");
|
||||
root.appendChild(header);
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "admin-actions";
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "admin-button admin-button-ghost";
|
||||
back.textContent = "Back to list";
|
||||
back.addEventListener("click", () => onNavigate(backUrl("?screen=projects")));
|
||||
bar.appendChild(back);
|
||||
|
||||
const filesLink = document.createElement("button");
|
||||
filesLink.className = "admin-button admin-button-ghost";
|
||||
filesLink.textContent = "View files";
|
||||
filesLink.addEventListener("click", () =>
|
||||
onNavigate("?screen=files&projectId=" + encodeURIComponent(id)));
|
||||
bar.appendChild(filesLink);
|
||||
root.appendChild(bar);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.textContent = "Loading…";
|
||||
root.appendChild(body);
|
||||
|
||||
let clearDeleteAction = null;
|
||||
|
||||
load();
|
||||
|
||||
async function load() {
|
||||
let project;
|
||||
try {
|
||||
project = await rpc("get-project", { id });
|
||||
} catch (err) {
|
||||
body.replaceChildren();
|
||||
if (clearDeleteAction) {
|
||||
clearDeleteAction();
|
||||
clearDeleteAction = null;
|
||||
}
|
||||
if (err.status === 404) {
|
||||
body.textContent = "Project not found.";
|
||||
} else {
|
||||
body.textContent = "Could not load the project.";
|
||||
showToast("Could not load the project.", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
paintDetailTitle(header, {
|
||||
section: "Project",
|
||||
query: backUrl("?screen=projects"),
|
||||
name: project.name,
|
||||
onNavigate,
|
||||
});
|
||||
body.replaceChildren();
|
||||
body.appendChild(infoBlock(project));
|
||||
if (project.deletedAt) {
|
||||
body.appendChild(deletedNotice("This project was marked for deletion."));
|
||||
if (clearDeleteAction) {
|
||||
clearDeleteAction();
|
||||
clearDeleteAction = null;
|
||||
}
|
||||
// NOTE: default projects come back with their owner profile,
|
||||
// so no Restore button is painted for them.
|
||||
if (project.isDefault) {
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "admin-count";
|
||||
hint.textContent =
|
||||
"This is a default project. Restore its owner profile to bring it back.";
|
||||
body.appendChild(hint);
|
||||
} else {
|
||||
body.appendChild(restoreBlock({
|
||||
command: "restore-project",
|
||||
id,
|
||||
label: "this project",
|
||||
scope: "with its files and team",
|
||||
onRestored: () => load(),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if (clearDeleteAction) {
|
||||
clearDeleteAction();
|
||||
}
|
||||
// NOTE: default projects cannot be deleted (the backend
|
||||
// refuses them too), so no Delete button is painted for them.
|
||||
if (!project.isDefault) {
|
||||
clearDeleteAction = paintDeleteAction(bar, {
|
||||
command: "delete-project",
|
||||
id,
|
||||
label: "this project",
|
||||
name: project.name,
|
||||
kind: "Project",
|
||||
onDeleted: () => load(),
|
||||
});
|
||||
} else {
|
||||
clearDeleteAction = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function infoBlock(project) {
|
||||
const list = document.createElement("dl");
|
||||
list.className = "admin-detail";
|
||||
const rows = [
|
||||
["id", "Id", String(project.id ?? "—"), null],
|
||||
["createdAt", "Created", formatDate(project.createdAt), null],
|
||||
["modifiedAt", "Modified", formatDate(project.modifiedAt), null],
|
||||
["totalFiles", "Files", String(project.totalFiles ?? 0), null],
|
||||
];
|
||||
for (const [, label, text] of rows) {
|
||||
const term = document.createElement("dt");
|
||||
term.textContent = label;
|
||||
const desc = document.createElement("dd");
|
||||
desc.textContent = text;
|
||||
list.appendChild(term);
|
||||
list.appendChild(desc);
|
||||
}
|
||||
if (project.isDefault) {
|
||||
const term = document.createElement("dt");
|
||||
term.textContent = "Default";
|
||||
const desc = document.createElement("dd");
|
||||
desc.textContent = "This is the default project of its team.";
|
||||
list.appendChild(term);
|
||||
list.appendChild(desc);
|
||||
}
|
||||
const teamTerm = document.createElement("dt");
|
||||
teamTerm.textContent = "Team";
|
||||
const teamDesc = document.createElement("dd");
|
||||
const teamLink = document.createElement("a");
|
||||
teamLink.textContent = String(project.teamName ?? project.teamId ?? "—");
|
||||
teamLink.href = "?screen=team&id=" + encodeURIComponent(project.teamId);
|
||||
teamLink.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
onNavigate("?screen=team&id=" + encodeURIComponent(project.teamId));
|
||||
});
|
||||
teamDesc.appendChild(teamLink);
|
||||
list.appendChild(teamTerm);
|
||||
list.appendChild(teamDesc);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
91
backend/resources/admin/js/pages/projects.js
Normal file
91
backend/resources/admin/js/pages/projects.js
Normal file
@ -0,0 +1,91 @@
|
||||
// Projects list: id lookup with a deleted filter and pagination.
|
||||
//
|
||||
// Lookup is by exact id (primary key); substring search has no
|
||||
// usable index at PRO scale. `teamId` is not offered as a
|
||||
// selector here: it only arrives through the URL, from the "view
|
||||
// projects" link on a team page, and shows as a removable active
|
||||
// filter. Every value is painted as text. JSON responses use
|
||||
// camelCase (`nextSince`, …).
|
||||
|
||||
import { rpc } from "../api.js";
|
||||
import { listPage } from "../components/list-page.js";
|
||||
import { statusCell } from "../components/badges.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Name", class: "admin-cell-name admin-cell-fill" },
|
||||
{ key: "teamName", label: "Team", class: "admin-cell-team" },
|
||||
{ key: "totalFiles", label: "Files", class: "admin-cell-count" },
|
||||
{ key: "createdAt", label: "Created", class: "admin-cell-date" },
|
||||
{ key: "modifiedAt", label: "Modified", class: "admin-cell-date" },
|
||||
{ key: "deletedAt", label: "Deleted", class: "admin-cell-date" },
|
||||
{ key: "status", label: "Status", class: "admin-cell-status" },
|
||||
];
|
||||
|
||||
export function projectsPage(root, { onNavigate }) {
|
||||
listPage(root, {
|
||||
title: "Projects",
|
||||
noun: "projects",
|
||||
itemNoun: "project",
|
||||
emptyText: "No projects match the current filters.",
|
||||
columns: COLUMNS,
|
||||
rpcCommand: "get-projects",
|
||||
loadErrorText: "Could not load projects.",
|
||||
lookupPlaceholder: "Look up by id…",
|
||||
lookupAriaLabel: "Look up by project id",
|
||||
detailKind: "project",
|
||||
tableClass: "admin-table-auto",
|
||||
hideEmptyTable: true,
|
||||
extraQueryKeys: ["teamId"],
|
||||
initialState: (fromUrl) => ({
|
||||
teamId: fromUrl.teamId ?? null,
|
||||
teamName: null,
|
||||
}),
|
||||
resetExtra: (state) => {
|
||||
state.teamId = null;
|
||||
state.teamName = null;
|
||||
},
|
||||
readRpcParams: (state) => ({
|
||||
...(state.lookup ? { id: state.lookup } : {}),
|
||||
...(state.teamId ? { teamId: state.teamId } : {}),
|
||||
}),
|
||||
beforeLoad: async (state) => {
|
||||
if (state.teamId && state.teamName === null) {
|
||||
try {
|
||||
const team = await rpc("get-team", { id: state.teamId });
|
||||
state.teamName = team.name;
|
||||
} catch {
|
||||
state.teamName = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
refreshHeader: ({ state, root: pageRoot, onNavigate: navigate }) => {
|
||||
const titleEl = pageRoot.querySelector("h1");
|
||||
titleEl.replaceChildren();
|
||||
if (state.teamId !== null) {
|
||||
const crumbButton = document.createElement("button");
|
||||
crumbButton.className = "admin-crumb";
|
||||
crumbButton.textContent = "Team: " + (state.teamName ?? state.teamId);
|
||||
crumbButton.setAttribute(
|
||||
"aria-label", "Back to Team: " + (state.teamName ?? state.teamId));
|
||||
crumbButton.addEventListener("click", () =>
|
||||
navigate("?screen=team&id=" + encodeURIComponent(state.teamId)));
|
||||
titleEl.appendChild(crumbButton);
|
||||
titleEl.append(" > ");
|
||||
}
|
||||
titleEl.append("Projects");
|
||||
},
|
||||
decorateRow: (item) => ({
|
||||
...item,
|
||||
totalFiles: String(item.totalFiles ?? 0),
|
||||
createdAt: formatDate(item.createdAt),
|
||||
modifiedAt: formatDate(item.modifiedAt),
|
||||
deletedAt: item.deletedAt ? formatDate(item.deletedAt) : "—",
|
||||
status: item.deletedAt ? "deleted" : "active",
|
||||
}),
|
||||
patchRow: (rowEl, row, _item, { cellByKey }) => {
|
||||
cellByKey(rowEl, "status")?.replaceChildren(statusCell(row.status));
|
||||
},
|
||||
onNavigate,
|
||||
});
|
||||
}
|
||||
355
backend/resources/admin/js/pages/team-detail.js
Normal file
355
backend/resources/admin/js/pages/team-detail.js
Normal file
@ -0,0 +1,355 @@
|
||||
// Team detail: metadata, feature toggles, and members. The name
|
||||
// is the page title. Every value is painted as text. Enabling a
|
||||
// feature applies at once; disabling asks for confirmation first.
|
||||
// An unknown id shows "not found" instead of failing.
|
||||
|
||||
import { rpc } from "../api.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
import { renderHeader, paintDetailTitle } from "../components/header.js";
|
||||
import { deletedNotice, paintRestoreActions } from "../components/restore.js";
|
||||
import { paintDeleteAction } from "../components/delete.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
import { defaultCell, statusCell, statusOf } from "../components/badges.js";
|
||||
import { backUrl } from "../url.js";
|
||||
|
||||
// Mirror of `supported-features` in `common/src/app/common/features.cljc`.
|
||||
// It only paints the toggles; the backend still validates every name.
|
||||
const SUPPORTED_FEATURES = [
|
||||
"components/v2",
|
||||
"design-tokens/v1",
|
||||
"fdata/objects-map",
|
||||
"fdata/path-data",
|
||||
"fdata/pointer-map",
|
||||
"fdata/shape-data-type",
|
||||
"layout/grid",
|
||||
"plugins/runtime",
|
||||
"render-wasm/v1",
|
||||
"styles/v2",
|
||||
"text-editor/v2",
|
||||
"text-editor/v2-html-paste",
|
||||
"text-editor-wasm/v1",
|
||||
"tokens/numeric-input",
|
||||
"variants/v1",
|
||||
];
|
||||
|
||||
const ROWS = [
|
||||
["id", "Id"],
|
||||
["createdAt", "Created"],
|
||||
["deletedAt", "Deleted"],
|
||||
["totalMembers", "Members"],
|
||||
["totalProjects", "Projects"],
|
||||
];
|
||||
|
||||
function roleOf(membership) {
|
||||
if (membership.isOwner) {
|
||||
return "owner";
|
||||
}
|
||||
if (membership.isAdmin) {
|
||||
return "admin";
|
||||
}
|
||||
if (membership.canEdit) {
|
||||
return "editor";
|
||||
}
|
||||
return "viewer";
|
||||
}
|
||||
|
||||
const MEMBER_ROLES = ["owner", "admin", "editor", "viewer"];
|
||||
|
||||
export function teamDetailPage(root, { id, onNavigate }) {
|
||||
const header = renderHeader("Team");
|
||||
root.appendChild(header);
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "admin-actions";
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "admin-button admin-button-ghost";
|
||||
back.textContent = "Back to list";
|
||||
back.addEventListener("click", () => onNavigate(backUrl("?screen=teams")));
|
||||
bar.appendChild(back);
|
||||
|
||||
const filesLink = document.createElement("button");
|
||||
filesLink.className = "admin-button admin-button-ghost";
|
||||
filesLink.textContent = "View files";
|
||||
filesLink.addEventListener("click", () =>
|
||||
onNavigate("?screen=files&teamId=" + encodeURIComponent(id)));
|
||||
bar.appendChild(filesLink);
|
||||
|
||||
const projectsLink = document.createElement("button");
|
||||
projectsLink.className = "admin-button admin-button-ghost";
|
||||
projectsLink.textContent = "View projects";
|
||||
projectsLink.addEventListener("click", () =>
|
||||
onNavigate("?screen=projects&teamId=" + encodeURIComponent(id)));
|
||||
bar.appendChild(projectsLink);
|
||||
root.appendChild(bar);
|
||||
|
||||
let clearRestoreActions = null;
|
||||
let clearDeleteAction = null;
|
||||
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.textContent = "Loading…";
|
||||
root.appendChild(body);
|
||||
|
||||
load();
|
||||
|
||||
async function load() {
|
||||
let data;
|
||||
let members;
|
||||
try {
|
||||
[data, members] = await Promise.all([
|
||||
rpc("get-team", { id }),
|
||||
rpc("get-team-members", { teamId: id }),
|
||||
]);
|
||||
} catch (err) {
|
||||
body.replaceChildren();
|
||||
if (clearRestoreActions) {
|
||||
clearRestoreActions();
|
||||
clearRestoreActions = null;
|
||||
}
|
||||
if (clearDeleteAction) {
|
||||
clearDeleteAction();
|
||||
clearDeleteAction = null;
|
||||
}
|
||||
if (err.status === 404) {
|
||||
body.textContent = "Team not found.";
|
||||
} else {
|
||||
body.textContent = "Could not load the team.";
|
||||
showToast("Could not load the team.", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
paintDetailTitle(header, {
|
||||
section: "Team",
|
||||
query: backUrl("?screen=teams"),
|
||||
name: data.name,
|
||||
onNavigate,
|
||||
});
|
||||
|
||||
const list = document.createElement("dl");
|
||||
list.className = "admin-detail";
|
||||
for (const [key, label] of ROWS) {
|
||||
if (data[key] === null || data[key] === undefined || data[key] === "") {
|
||||
continue;
|
||||
}
|
||||
const term = document.createElement("dt");
|
||||
term.textContent = label;
|
||||
const desc = document.createElement("dd");
|
||||
desc.textContent = key === "createdAt" || key === "deletedAt" ? formatDate(data[key]) : String(data[key]);
|
||||
list.appendChild(term);
|
||||
list.appendChild(desc);
|
||||
}
|
||||
if (data.isDefault) {
|
||||
const term = document.createElement("dt");
|
||||
term.textContent = "Default";
|
||||
const desc = document.createElement("dd");
|
||||
desc.appendChild(defaultCell("yes"));
|
||||
list.appendChild(term);
|
||||
list.appendChild(desc);
|
||||
}
|
||||
body.replaceChildren();
|
||||
|
||||
if (data.deletedAt) {
|
||||
body.appendChild(deletedNotice("This team was marked for deletion."));
|
||||
if (clearDeleteAction) {
|
||||
clearDeleteAction();
|
||||
clearDeleteAction = null;
|
||||
}
|
||||
if (clearRestoreActions) {
|
||||
clearRestoreActions();
|
||||
}
|
||||
// NOTE: default teams come back with their owner profile, so
|
||||
// no Restore button is painted for them.
|
||||
if (data.isDefault) {
|
||||
clearRestoreActions = null;
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "admin-count";
|
||||
hint.textContent =
|
||||
"This is a default team. Restore its owner profile to bring it back.";
|
||||
body.appendChild(hint);
|
||||
} else {
|
||||
clearRestoreActions = paintRestoreActions(bar, {
|
||||
command: "restore-team",
|
||||
id,
|
||||
label: "this team",
|
||||
name: data.name,
|
||||
scope: "with everything under it (projects, files)",
|
||||
onRestored: () => load(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (clearRestoreActions) {
|
||||
clearRestoreActions();
|
||||
clearRestoreActions = null;
|
||||
}
|
||||
if (clearDeleteAction) {
|
||||
clearDeleteAction();
|
||||
}
|
||||
// NOTE: default teams cannot be deleted (the backend refuses
|
||||
// them too), so no Delete button is painted for them.
|
||||
if (!data.isDefault) {
|
||||
clearDeleteAction = paintDeleteAction(bar, {
|
||||
command: "delete-team",
|
||||
id,
|
||||
label: "this team",
|
||||
name: data.name,
|
||||
kind: "Team",
|
||||
onDeleted: () => load(),
|
||||
});
|
||||
} else {
|
||||
clearDeleteAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
body.appendChild(list);
|
||||
|
||||
body.appendChild(featuresBlock(data));
|
||||
body.appendChild(membersBlock(members ?? []));
|
||||
}
|
||||
|
||||
function featuresBlock(data) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "admin-block";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Features";
|
||||
section.appendChild(heading);
|
||||
|
||||
const enabled = new Set(data.features ?? []);
|
||||
const items = document.createElement("ul");
|
||||
items.className = "admin-list";
|
||||
for (const feature of SUPPORTED_FEATURES) {
|
||||
const item = document.createElement("li");
|
||||
const label = document.createElement("label");
|
||||
const toggle = document.createElement("input");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = enabled.has(feature);
|
||||
toggle.setAttribute("aria-label", `Toggle ${feature}`);
|
||||
toggle.addEventListener("change", async () => {
|
||||
toggle.disabled = true;
|
||||
const turnOn = toggle.checked;
|
||||
if (!turnOn && !window.confirm(
|
||||
`Turn off ${feature} for this team? Files may depend on it.`)) {
|
||||
toggle.checked = true;
|
||||
toggle.disabled = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await rpc(turnOn ? "enable-team-feature" : "disable-team-feature",
|
||||
{ teamId: data.id, feature });
|
||||
showToast(turnOn ? `Enabled ${feature}.` : `Disabled ${feature}.`);
|
||||
load();
|
||||
} catch {
|
||||
showToast("Could not change the feature.", "error");
|
||||
toggle.checked = !turnOn;
|
||||
toggle.disabled = false;
|
||||
}
|
||||
});
|
||||
label.appendChild(toggle);
|
||||
label.append(` ${feature}`);
|
||||
item.appendChild(label);
|
||||
items.appendChild(item);
|
||||
}
|
||||
section.appendChild(items);
|
||||
|
||||
const globalOnly = (data.effectiveFeatures ?? [])
|
||||
.filter((feature) => !enabled.has(feature));
|
||||
if (globalOnly.length > 0) {
|
||||
const note = document.createElement("p");
|
||||
note.className = "admin-count";
|
||||
note.textContent =
|
||||
"Also active for everyone (global switch, not per team): " +
|
||||
globalOnly.join(", ") + ".";
|
||||
section.appendChild(note);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function membersBlock(members) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "admin-block";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = `Members (${members.length})`;
|
||||
section.appendChild(heading);
|
||||
if (members.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "admin-count";
|
||||
empty.textContent = "None.";
|
||||
section.appendChild(empty);
|
||||
return section;
|
||||
}
|
||||
const table = document.createElement("table");
|
||||
table.className = "admin-table";
|
||||
const head = document.createElement("thead");
|
||||
const headRow = document.createElement("tr");
|
||||
for (const label of ["Name", "Email", "Role", "Status"]) {
|
||||
const cell = document.createElement("th");
|
||||
cell.textContent = label;
|
||||
headRow.appendChild(cell);
|
||||
}
|
||||
head.appendChild(headRow);
|
||||
table.appendChild(head);
|
||||
const body = document.createElement("tbody");
|
||||
for (const member of members) {
|
||||
body.appendChild(memberRow(member));
|
||||
}
|
||||
table.appendChild(body);
|
||||
section.appendChild(table);
|
||||
return section;
|
||||
}
|
||||
|
||||
function memberRow(member) {
|
||||
const row = document.createElement("tr");
|
||||
const name = document.createElement("td");
|
||||
name.textContent = member.fullname;
|
||||
row.appendChild(name);
|
||||
|
||||
const email = document.createElement("td");
|
||||
email.textContent = member.email;
|
||||
row.appendChild(email);
|
||||
|
||||
const roleCell = document.createElement("td");
|
||||
const select = document.createElement("select");
|
||||
select.className = "admin-select";
|
||||
select.setAttribute("aria-label", `Role of ${member.email}`);
|
||||
for (const role of MEMBER_ROLES) {
|
||||
const option = document.createElement("option");
|
||||
option.value = role;
|
||||
option.textContent = role;
|
||||
select.appendChild(option);
|
||||
}
|
||||
select.value = roleOf(member);
|
||||
if (member.isOwner) {
|
||||
// Ownership moves by promoting someone else, which demotes
|
||||
// the current owner; the backend rejects direct demotion.
|
||||
select.disabled = true;
|
||||
select.title = "Promote another member to transfer ownership.";
|
||||
}
|
||||
select.addEventListener("change", async () => {
|
||||
const previous = roleOf(member);
|
||||
const next = select.value;
|
||||
if ((previous === "owner" || next === "owner") && !window.confirm(
|
||||
`Make ${member.email} ${next}? The previous owner becomes admin.`)) {
|
||||
select.value = previous;
|
||||
return;
|
||||
}
|
||||
select.disabled = true;
|
||||
try {
|
||||
await rpc("update-team-member-role", { teamId: id, memberId: member.id, role: next });
|
||||
showToast(`${member.email} is now ${next}.`);
|
||||
load();
|
||||
} catch {
|
||||
showToast("Could not change the role.", "error");
|
||||
select.value = previous;
|
||||
select.disabled = false;
|
||||
}
|
||||
});
|
||||
roleCell.appendChild(select);
|
||||
row.appendChild(roleCell);
|
||||
|
||||
const status = document.createElement("td");
|
||||
status.appendChild(statusCell(statusOf(member)));
|
||||
row.appendChild(status);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
49
backend/resources/admin/js/pages/teams.js
Normal file
49
backend/resources/admin/js/pages/teams.js
Normal file
@ -0,0 +1,49 @@
|
||||
// Teams list: id lookup with a deleted filter and pagination.
|
||||
// Lookup is by exact id (primary key); substring search has no
|
||||
// usable index at PRO scale. Every value is painted as text. JSON
|
||||
// responses use camelCase (`nextSince`, …).
|
||||
|
||||
import { listPage } from "../components/list-page.js";
|
||||
import { defaultCell, statusCell } from "../components/badges.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: "name", label: "Name", class: "admin-cell-name" },
|
||||
{ key: "owner", label: "Owner", class: "admin-cell-email admin-cell-left" },
|
||||
{ key: "totalMembers", label: "Members", class: "admin-cell-count" },
|
||||
{ key: "createdAt", label: "Created", class: "admin-cell-date" },
|
||||
{ key: "modifiedAt", label: "Modified", class: "admin-cell-date" },
|
||||
{ key: "deletedAt", label: "Deleted", class: "admin-cell-date" },
|
||||
{ key: "isDefault", label: "Default", class: "admin-cell-status admin-cell-center" },
|
||||
{ key: "status", label: "Status", class: "admin-cell-status" },
|
||||
];
|
||||
|
||||
export function teamsPage(root, { onNavigate }) {
|
||||
listPage(root, {
|
||||
title: "Teams",
|
||||
noun: "teams",
|
||||
itemNoun: "team",
|
||||
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",
|
||||
readRpcParams: (state) => (state.lookup ? { id: state.lookup } : {}),
|
||||
decorateRow: (item) => ({
|
||||
...item,
|
||||
owner: item.owner ?? "—",
|
||||
createdAt: formatDate(item.createdAt),
|
||||
modifiedAt: formatDate(item.modifiedAt),
|
||||
isDefault: item.isDefault ? "yes" : "no",
|
||||
deletedAt: item.deletedAt ? formatDate(item.deletedAt) : "—",
|
||||
status: item.deletedAt ? "deleted" : "active",
|
||||
}),
|
||||
patchRow: (rowEl, row, _item, { cellByKey }) => {
|
||||
cellByKey(rowEl, "isDefault")?.replaceChildren(defaultCell(row.isDefault));
|
||||
cellByKey(rowEl, "status")?.replaceChildren(statusCell(row.status));
|
||||
},
|
||||
onNavigate,
|
||||
});
|
||||
}
|
||||
271
backend/resources/admin/js/pages/user-detail.js
Normal file
271
backend/resources/admin/js/pages/user-detail.js
Normal file
@ -0,0 +1,271 @@
|
||||
// User detail: metadata, teams, and admin actions in the top bar.
|
||||
// The email is the page title. Dangerous actions ask for
|
||||
// confirmation first; everything is painted as text. An unknown id
|
||||
// shows "not found" instead of failing.
|
||||
|
||||
import { rpc } from "../api.js";
|
||||
import { renderHeader, paintDetailTitle } from "../components/header.js";
|
||||
import { deletedNotice, paintRestoreActions } from "../components/restore.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
import { statusCell, statusOf } from "../components/badges.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
import { backUrl } from "../url.js";
|
||||
|
||||
const ROWS = [
|
||||
["id", "Id"],
|
||||
["fullname", "Name"],
|
||||
["createdAt", "Created"],
|
||||
["deletedAt", "Deleted"],
|
||||
["authBackend", "Auth backend"],
|
||||
];
|
||||
|
||||
function roleOf(membership) {
|
||||
if (membership.isOwner) {
|
||||
return "owner";
|
||||
}
|
||||
if (membership.isAdmin) {
|
||||
return "admin";
|
||||
}
|
||||
return "member";
|
||||
}
|
||||
|
||||
export function userDetailPage(root, { id, onNavigate }) {
|
||||
const header = renderHeader("User");
|
||||
root.appendChild(header);
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "admin-actions";
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "admin-button admin-button-ghost";
|
||||
back.textContent = "Back to list";
|
||||
back.addEventListener("click", () => onNavigate(backUrl("?screen=users")));
|
||||
bar.appendChild(back);
|
||||
root.appendChild(bar);
|
||||
|
||||
let clearBarActions = null;
|
||||
|
||||
const clearActions = () => {
|
||||
if (clearBarActions) {
|
||||
clearBarActions();
|
||||
clearBarActions = null;
|
||||
}
|
||||
};
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.textContent = "Loading…";
|
||||
root.appendChild(body);
|
||||
|
||||
load();
|
||||
|
||||
async function load() {
|
||||
let data;
|
||||
try {
|
||||
data = await rpc("get-profile", { id });
|
||||
} catch (err) {
|
||||
body.replaceChildren();
|
||||
clearActions();
|
||||
if (err.status === 404) {
|
||||
body.textContent = "User not found.";
|
||||
} else {
|
||||
body.textContent = "Could not load the user.";
|
||||
showToast("Could not load the user.", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
paintDetailTitle(header, {
|
||||
section: "User",
|
||||
query: backUrl("?screen=users"),
|
||||
name: data.email,
|
||||
onNavigate,
|
||||
});
|
||||
|
||||
// Fresh bar for fresh data: actions from a previous state
|
||||
// (e.g. Restore after the user came back) must not linger.
|
||||
clearActions();
|
||||
|
||||
const list = document.createElement("dl");
|
||||
list.className = "admin-detail";
|
||||
for (const [key, label] of ROWS) {
|
||||
if (data[key] === null || data[key] === undefined || data[key] === "") {
|
||||
continue;
|
||||
}
|
||||
const term = document.createElement("dt");
|
||||
term.textContent = label;
|
||||
const desc = document.createElement("dd");
|
||||
desc.textContent = key === "createdAt" || key === "deletedAt"
|
||||
? formatDate(data[key])
|
||||
: String(data[key]);
|
||||
list.appendChild(term);
|
||||
list.appendChild(desc);
|
||||
}
|
||||
const statusTerm = document.createElement("dt");
|
||||
statusTerm.textContent = "Status";
|
||||
const statusDesc = document.createElement("dd");
|
||||
statusDesc.appendChild(statusCell(statusOf(data)));
|
||||
list.appendChild(statusTerm);
|
||||
list.appendChild(statusDesc);
|
||||
body.replaceChildren();
|
||||
body.appendChild(list);
|
||||
|
||||
body.appendChild(teamBlock("Owned teams", data.ownedTeams ?? [], (team) => {
|
||||
const item = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
link.textContent = team.name;
|
||||
link.href = "?screen=team&id=" + encodeURIComponent(team.id);
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
onNavigate("?screen=team&id=" + encodeURIComponent(team.id));
|
||||
});
|
||||
item.appendChild(link);
|
||||
item.appendChild(document.createTextNode(
|
||||
` — ${team.members} member` + (team.members === 1 ? "" : "s")));
|
||||
if (team.deletedAt) {
|
||||
item.appendChild(document.createTextNode(" "));
|
||||
item.appendChild(statusCell("deleted"));
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
body.appendChild(teamBlock("Member of", data.memberTeams ?? [], (team) => {
|
||||
const item = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
link.textContent = team.name;
|
||||
link.href = "?screen=team&id=" + encodeURIComponent(team.id);
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
onNavigate("?screen=team&id=" + encodeURIComponent(team.id));
|
||||
});
|
||||
item.appendChild(link);
|
||||
item.appendChild(document.createTextNode(` — ${roleOf(team)}`));
|
||||
if (team.deletedAt) {
|
||||
item.appendChild(document.createTextNode(" "));
|
||||
item.appendChild(statusCell("deleted"));
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
|
||||
if (data.deletedAt) {
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "admin-count";
|
||||
notice.textContent =
|
||||
`This user was marked for deletion on ${formatDate(data.deletedAt)}.`;
|
||||
body.appendChild(notice);
|
||||
clearBarActions = paintRestoreActions(bar, {
|
||||
command: "restore-profile",
|
||||
id,
|
||||
label: "this user",
|
||||
name: data.email,
|
||||
scope: "with the teams it owned and everything under them",
|
||||
warn: "Teams without any other live owner come back too, " +
|
||||
"because this user owns them again.",
|
||||
onRestored: () => load(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
paintUserActions(data);
|
||||
}
|
||||
|
||||
function paintUserActions(data) {
|
||||
const added = [];
|
||||
|
||||
const toggle = document.createElement("button");
|
||||
toggle.className = "admin-button";
|
||||
toggle.textContent = data.isBlocked ? "Unblock" : "Block";
|
||||
toggle.title = data.isBlocked ? "Unblock this user." : "Block this user.";
|
||||
toggle.addEventListener("click", async () => {
|
||||
const verb = data.isBlocked ? "Unblock" : "Block";
|
||||
if (!window.confirm(`${verb} ${data.email}?`)) {
|
||||
return;
|
||||
}
|
||||
toggle.disabled = true;
|
||||
try {
|
||||
await rpc(data.isBlocked ? "unblock-profile" : "block-profile",
|
||||
{ id: data.id });
|
||||
showToast(data.isBlocked ? "User unblocked." : "User blocked.");
|
||||
load();
|
||||
} catch {
|
||||
showToast("Could not change the block state.", "error");
|
||||
toggle.disabled = false;
|
||||
}
|
||||
});
|
||||
bar.appendChild(toggle);
|
||||
added.push(toggle);
|
||||
|
||||
const resend = document.createElement("button");
|
||||
resend.className = "admin-button";
|
||||
resend.textContent = "Resend verification";
|
||||
resend.title = "Send the verification email again.";
|
||||
resend.addEventListener("click", async () => {
|
||||
if (!window.confirm(`Send a verification email to ${data.email}?`)) {
|
||||
return;
|
||||
}
|
||||
resend.disabled = true;
|
||||
try {
|
||||
await rpc("resend-verification", { id: data.id });
|
||||
showToast("Verification email scheduled.");
|
||||
} catch {
|
||||
showToast("Could not schedule the verification email.", "error");
|
||||
} finally {
|
||||
resend.disabled = false;
|
||||
}
|
||||
});
|
||||
bar.appendChild(resend);
|
||||
added.push(resend);
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "admin-button admin-button-danger";
|
||||
remove.textContent = "Delete";
|
||||
remove.title = "Delete this user. It can be restored afterwards.";
|
||||
remove.addEventListener("click", async () => {
|
||||
if (!window.confirm(`Delete ${data.email}? It can be restored afterwards.`)) {
|
||||
return;
|
||||
}
|
||||
remove.disabled = true;
|
||||
try {
|
||||
const result = await rpc("delete-profiles", { emails: [data.email] });
|
||||
if ((result.skippedSelf ?? []).length > 0) {
|
||||
showToast("You cannot delete your own account.", "error");
|
||||
} else {
|
||||
showToast("User deleted.");
|
||||
}
|
||||
load();
|
||||
} catch {
|
||||
showToast("Could not delete the user.", "error");
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
bar.appendChild(remove);
|
||||
added.push(remove);
|
||||
|
||||
clearBarActions = () => {
|
||||
for (const el of added) {
|
||||
el.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function teamBlock(title, teams, renderItem) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "admin-block";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = title;
|
||||
section.appendChild(heading);
|
||||
if (teams.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "admin-count";
|
||||
empty.textContent = "None.";
|
||||
section.appendChild(empty);
|
||||
return section;
|
||||
}
|
||||
const items = document.createElement("ul");
|
||||
items.className = "admin-list";
|
||||
for (const team of teams) {
|
||||
items.appendChild(renderItem(team));
|
||||
}
|
||||
section.appendChild(items);
|
||||
return section;
|
||||
}
|
||||
|
||||
}
|
||||
148
backend/resources/admin/js/pages/users-delete.js
Normal file
148
backend/resources/admin/js/pages/users-delete.js
Normal file
@ -0,0 +1,148 @@
|
||||
// Bulk delete by email: paste a list, preview the count, confirm
|
||||
// the alert, and run delete-profiles. The answer paints deleted,
|
||||
// not-found, and skipped-self groups separately.
|
||||
// Everything is painted as text.
|
||||
|
||||
import { rpc } from "../api.js";
|
||||
import { renderHeader } from "../components/header.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
import { backUrl } from "../url.js";
|
||||
|
||||
const MAX_EMAILS = 100;
|
||||
|
||||
function parseEmails(raw) {
|
||||
return raw
|
||||
.split(/[\n,]+/)
|
||||
.map((email) => email.trim())
|
||||
.filter((email) => email !== "");
|
||||
}
|
||||
|
||||
export function usersDeletePage(root, { onNavigate }) {
|
||||
root.appendChild(renderHeader("Delete users by email"));
|
||||
|
||||
const back = document.createElement("button");
|
||||
back.className = "admin-button admin-button-ghost";
|
||||
back.textContent = "Back to list";
|
||||
back.addEventListener("click", () => onNavigate(backUrl("?screen=users")));
|
||||
root.appendChild(back);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "admin-count";
|
||||
hint.textContent =
|
||||
`Paste up to ${MAX_EMAILS} emails, one per line or comma separated. ` +
|
||||
"Matches are marked for deletion at once; unknown emails are reported, not deleted.";
|
||||
root.appendChild(hint);
|
||||
|
||||
const area = document.createElement("textarea");
|
||||
area.className = "admin-textarea";
|
||||
area.placeholder = "user1@example.com\nuser2@example.com";
|
||||
area.setAttribute("aria-label", "Emails to delete");
|
||||
root.appendChild(area);
|
||||
|
||||
const countLine = document.createElement("p");
|
||||
countLine.className = "admin-count";
|
||||
countLine.textContent = "Paste at least one email.";
|
||||
root.appendChild(countLine);
|
||||
|
||||
const confirmWrap = document.createElement("div");
|
||||
confirmWrap.className = "admin-confirm";
|
||||
|
||||
const confirm = document.createElement("button");
|
||||
confirm.className = "admin-button admin-button-danger";
|
||||
confirm.textContent = "Delete";
|
||||
confirm.title = "Delete the listed users. This cannot be undone.";
|
||||
confirm.disabled = true;
|
||||
confirmWrap.appendChild(confirm);
|
||||
root.appendChild(confirmWrap);
|
||||
|
||||
const body = document.createElement("div");
|
||||
root.appendChild(body);
|
||||
|
||||
function emailCount() {
|
||||
return parseEmails(area.value).length;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const count = emailCount();
|
||||
confirm.textContent = count > 0 ? `Delete ${count}` : "Delete";
|
||||
if (count === 0) {
|
||||
countLine.textContent = "Paste at least one email.";
|
||||
confirm.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (count > MAX_EMAILS) {
|
||||
countLine.textContent = `Too many emails: ${count} (max ${MAX_EMAILS}).`;
|
||||
confirm.disabled = true;
|
||||
return;
|
||||
}
|
||||
countLine.textContent =
|
||||
`${count} email` + (count === 1 ? "" : "s") + ". This cannot be undone.";
|
||||
confirm.disabled = false;
|
||||
}
|
||||
|
||||
area.addEventListener("input", refresh);
|
||||
|
||||
confirm.addEventListener("click", async () => {
|
||||
const emails = parseEmails(area.value);
|
||||
if (!window.confirm(
|
||||
`Delete ${emails.length} user` + (emails.length === 1 ? "" : "s") +
|
||||
"? Unknown emails are reported, not deleted. This cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
confirm.disabled = true;
|
||||
try {
|
||||
const data = await rpc("delete-profiles", { emails });
|
||||
body.replaceChildren();
|
||||
body.appendChild(resultBlock(data));
|
||||
} catch {
|
||||
showToast("Could not delete the users.", "error");
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
function resultBlock(data) {
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
const summary = document.createElement("p");
|
||||
summary.className = "admin-count";
|
||||
summary.textContent =
|
||||
`Deleted ${data.deleted.length} of ${data.total}. ` +
|
||||
`${data.notFound.length} not found, ${data.skippedSelf.length} skipped (own account).`;
|
||||
wrap.appendChild(summary);
|
||||
|
||||
const groups = [
|
||||
["Deleted", data.deleted],
|
||||
["Not found", data.notFound],
|
||||
["Skipped (own account)", data.skippedSelf],
|
||||
];
|
||||
for (const [title, items] of groups) {
|
||||
if (items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const section = document.createElement("section");
|
||||
section.className = "admin-block";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = `${title} (${items.length})`;
|
||||
section.appendChild(heading);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "admin-list";
|
||||
for (const item of items) {
|
||||
const entry = document.createElement("li");
|
||||
entry.textContent = String(item);
|
||||
list.appendChild(entry);
|
||||
}
|
||||
section.appendChild(list);
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
const done = document.createElement("button");
|
||||
done.className = "admin-button admin-button-ghost";
|
||||
done.textContent = "Back to list";
|
||||
done.addEventListener("click", () => onNavigate(backUrl("?screen=users")));
|
||||
wrap.appendChild(done);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
}
|
||||
63
backend/resources/admin/js/pages/users.js
Normal file
63
backend/resources/admin/js/pages/users.js
Normal file
@ -0,0 +1,63 @@
|
||||
// Users list: id/email lookup with a deleted filter and pagination.
|
||||
// Lookup is by exact id or exact email (both indexed); substring
|
||||
// search has no usable index at PRO scale. Every value is painted
|
||||
// as text; status badges use a fixed class map, never the raw
|
||||
// value. JSON responses use camelCase (`nextSince`, …).
|
||||
|
||||
import { withFrom } from "../url.js";
|
||||
import { listPage } from "../components/list-page.js";
|
||||
import { statusCell, statusOf } from "../components/badges.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: "fullname", label: "Name", class: "admin-cell-name" },
|
||||
{ key: "email", label: "Email", class: "admin-cell-email" },
|
||||
{ key: "createdAt", label: "Created", class: "admin-cell-date" },
|
||||
{ key: "modifiedAt", label: "Modified", class: "admin-cell-date" },
|
||||
{ key: "deletedAt", label: "Deleted", class: "admin-cell-date" },
|
||||
{ key: "status", label: "Status", class: "admin-cell-status" },
|
||||
];
|
||||
|
||||
export function usersPage(root, { onNavigate }) {
|
||||
listPage(root, {
|
||||
title: "Users",
|
||||
noun: "users",
|
||||
itemNoun: "user",
|
||||
emptyText: "No users match the current filters.",
|
||||
columns: COLUMNS,
|
||||
rpcCommand: "get-profiles",
|
||||
loadErrorText: "Could not load users.",
|
||||
lookupPlaceholder: "Look up by id or exact email…",
|
||||
lookupAriaLabel: "Look up by id or exact email",
|
||||
detailKind: "user",
|
||||
readRpcParams: (state) => {
|
||||
if (!state.lookup) {
|
||||
return {};
|
||||
}
|
||||
// Exact email (unique index) when it looks like one,
|
||||
// otherwise an exact id (primary key).
|
||||
if (state.lookup.includes("@")) {
|
||||
return { email: state.lookup };
|
||||
}
|
||||
return { id: state.lookup };
|
||||
},
|
||||
decorateRow: (item) => ({
|
||||
...item,
|
||||
createdAt: formatDate(item.createdAt),
|
||||
modifiedAt: formatDate(item.modifiedAt),
|
||||
deletedAt: item.deletedAt ? formatDate(item.deletedAt) : "—",
|
||||
status: statusOf(item),
|
||||
}),
|
||||
patchRow: (rowEl, row, _item, { cellByKey }) => {
|
||||
cellByKey(rowEl, "status")?.replaceChildren(statusCell(row.status));
|
||||
},
|
||||
extraControls: (controls, { onNavigate: navigate }) => {
|
||||
const bulkButton = document.createElement("button");
|
||||
bulkButton.className = "admin-button admin-button-ghost";
|
||||
bulkButton.textContent = "Delete by email…";
|
||||
bulkButton.addEventListener("click", () => navigate(withFrom("?screen=users-delete")));
|
||||
controls.appendChild(bulkButton);
|
||||
},
|
||||
onNavigate,
|
||||
});
|
||||
}
|
||||
122
backend/resources/admin/js/pages/virtual-clock.js
Normal file
122
backend/resources/admin/js/pages/virtual-clock.js
Normal file
@ -0,0 +1,122 @@
|
||||
// Virtual clock: show the per-profile time offset and set or
|
||||
// reset it. The offset only affects the current profile's session
|
||||
// and is a test aid, never a production control (production
|
||||
// refuses changes). Every value is painted as text.
|
||||
|
||||
import { rpc } from "../api.js";
|
||||
import { formatDate } from "../components/date.js";
|
||||
import { renderHeader } from "../components/header.js";
|
||||
import { showToast } from "../components/toast.js";
|
||||
|
||||
function formatOffset(millis) {
|
||||
if (millis === null || millis === undefined) {
|
||||
return "none";
|
||||
}
|
||||
const totalSeconds = Math.round(millis / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const parts = [];
|
||||
if (hours) {
|
||||
parts.push(`${hours}h`);
|
||||
}
|
||||
if (minutes) {
|
||||
parts.push(`${minutes}m`);
|
||||
}
|
||||
if (!parts.length || seconds) {
|
||||
parts.push(`${seconds}s`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
export function virtualClockPage(root) {
|
||||
root.appendChild(renderHeader("Virtual clock"));
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "admin-count";
|
||||
hint.textContent =
|
||||
"Shift this session's clock for testing (for example 1h, 90m, 2h30m). " +
|
||||
"Reset returns to real time. Production refuses changes.";
|
||||
root.appendChild(hint);
|
||||
|
||||
const status = document.createElement("p");
|
||||
status.className = "admin-count admin-count-spaced";
|
||||
status.textContent = "Loading…";
|
||||
root.appendChild(status);
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "admin-controls";
|
||||
|
||||
const offsetInput = document.createElement("input");
|
||||
offsetInput.className = "admin-input";
|
||||
offsetInput.type = "text";
|
||||
offsetInput.placeholder = "Offset, e.g. 1h";
|
||||
offsetInput.setAttribute("aria-label", "Clock offset");
|
||||
offsetInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
setButton.click();
|
||||
}
|
||||
});
|
||||
controls.appendChild(offsetInput);
|
||||
|
||||
const setButton = document.createElement("button");
|
||||
setButton.className = "admin-button";
|
||||
setButton.textContent = "Set offset";
|
||||
setButton.addEventListener("click", async () => {
|
||||
const offset = offsetInput.value.trim();
|
||||
if (!offset) {
|
||||
showToast("Type an offset first.", "error");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Shift this session's clock by ${offset}?`)) {
|
||||
return;
|
||||
}
|
||||
setButton.disabled = true;
|
||||
try {
|
||||
const data = await rpc("set-virtual-clock", { offset });
|
||||
paint(data);
|
||||
showToast("Clock offset set.");
|
||||
} catch {
|
||||
showToast("Could not set the clock offset.", "error");
|
||||
} finally {
|
||||
setButton.disabled = false;
|
||||
}
|
||||
});
|
||||
controls.appendChild(setButton);
|
||||
|
||||
const resetButton = document.createElement("button");
|
||||
resetButton.className = "admin-button admin-button-ghost";
|
||||
resetButton.textContent = "Reset";
|
||||
resetButton.addEventListener("click", async () => {
|
||||
resetButton.disabled = true;
|
||||
try {
|
||||
const data = await rpc("set-virtual-clock", { reset: true });
|
||||
offsetInput.value = "";
|
||||
paint(data);
|
||||
showToast("Clock reset to real time.");
|
||||
} catch {
|
||||
showToast("Could not reset the clock.", "error");
|
||||
} finally {
|
||||
resetButton.disabled = false;
|
||||
}
|
||||
});
|
||||
controls.appendChild(resetButton);
|
||||
root.appendChild(controls);
|
||||
|
||||
function paint(data) {
|
||||
status.textContent =
|
||||
`Offset: ${formatOffset(data.offsetMillis)} · ` +
|
||||
`Time: ${formatDate(data.now)} · ` +
|
||||
`Clock: ${data.clock}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
paint(await rpc("get-virtual-clock"));
|
||||
} catch {
|
||||
status.textContent = "Could not load the clock.";
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}
|
||||
71
backend/resources/admin/js/url.js
Normal file
71
backend/resources/admin/js/url.js
Normal file
@ -0,0 +1,71 @@
|
||||
// Filter <-> query string sync: list pages keep their filters in
|
||||
// the URL (replaceState, no history spam) so refresh restores the
|
||||
// view and filtered views are bookmarkable. Only the keys the page
|
||||
// owns are touched; `screen` (and `id`) survive untouched. The
|
||||
// page cursor is deliberately left out: refresh restarts at page
|
||||
// one with the same filters.
|
||||
//
|
||||
// List -> detail navigation carries the list URL in `from`, so
|
||||
// "Back to list" returns to the same filters instead of a generic
|
||||
// list. `from` is a plain query string (`?screen=users&search=…`);
|
||||
// backUrl only trusts the five list screens.
|
||||
|
||||
export function readQuery(keys) {
|
||||
const query = new URLSearchParams(location.search);
|
||||
const out = {};
|
||||
for (const key of keys) {
|
||||
const value = query.get(key);
|
||||
if (value !== null) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeQuery(patch) {
|
||||
const query = new URLSearchParams(location.search);
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === "" || value === null || value === undefined) {
|
||||
query.delete(key);
|
||||
} else {
|
||||
query.set(key, value);
|
||||
}
|
||||
}
|
||||
history.replaceState({}, "", "?" + query.toString());
|
||||
}
|
||||
|
||||
const LIST_SCREENS = ["users", "teams", "projects", "files", "jobs", "error-reports"];
|
||||
|
||||
// Wrap a target query with the current list URL, so the detail
|
||||
// page can send "Back to list" to the same filters. Any nested
|
||||
// `from` is stripped first: cross-detail hops never build chains.
|
||||
export function withFrom(base) {
|
||||
const target = new URLSearchParams(base.startsWith("?") ? base.slice(1) : base);
|
||||
const current = new URLSearchParams(location.search);
|
||||
current.delete("from");
|
||||
// The detail screens add `id`; the list screens only carry
|
||||
// filters. Either way the whole query is the return target.
|
||||
const restored = current.toString();
|
||||
if (restored !== "") {
|
||||
target.set("from", "?" + restored);
|
||||
}
|
||||
return "?" + target.toString();
|
||||
}
|
||||
|
||||
// Detail link from a list row: `detailUrl("user", id)`.
|
||||
export function detailUrl(screen, id) {
|
||||
return withFrom(
|
||||
"?screen=" + encodeURIComponent(screen) +
|
||||
"&id=" + encodeURIComponent(id));
|
||||
}
|
||||
|
||||
// Resolve "Back to list": the stored list URL when it points at
|
||||
// a known list screen, else the generic fallback.
|
||||
export function backUrl(fallback) {
|
||||
const from = new URLSearchParams(location.search).get("from");
|
||||
if (from && LIST_SCREENS.some((screen) =>
|
||||
from === "?screen=" + screen || from.startsWith("?screen=" + screen + "&"))) {
|
||||
return from;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@ -6,7 +6,10 @@
|
||||
|
||||
(ns app.auth
|
||||
(:require
|
||||
[buddy.hashers :as hashers]))
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[buddy.hashers :as hashers]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(def ^:private default-options
|
||||
{:alg :argon2id
|
||||
@ -36,3 +39,32 @@
|
||||
(catch Throwable _
|
||||
{:update false
|
||||
:valid false})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Superuser registry
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; Resolves the configured `:superusers` emails to profile ids once, at
|
||||
;; startup. What does not exist then is simply not in the set: a profile
|
||||
;; registered afterwards is recognized after the next restart. Callers
|
||||
;; check membership with a plain `contains?` on the set found under
|
||||
;; `::superusers` in their config map, so the check costs no query.
|
||||
(defmethod ig/init-key ::superusers
|
||||
[_ {:keys [::db/pool]}]
|
||||
(into #{}
|
||||
(keep (fn [email]
|
||||
(:id (db/get* pool :profile {:email email}))))
|
||||
(or (cf/get :superusers) #{})))
|
||||
|
||||
(defn superuser-allowed?
|
||||
"True when the caller satisfies the superuser rule shared by the
|
||||
admin RPC wrapper and the admin HTTP transfer routes: registry
|
||||
membership (any auth type), a token carrying the operator-granted
|
||||
`\"superuser\"` scope, or any authenticated profile on a `devenv`
|
||||
host."
|
||||
[cfg profile-id token-perms]
|
||||
(boolean
|
||||
(or (and (= "devenv" (cf/get :host))
|
||||
(uuid? profile-id))
|
||||
(contains? (::superusers cfg) profile-id)
|
||||
(contains? (set token-perms) "superuser"))))
|
||||
|
||||
@ -131,7 +131,7 @@
|
||||
(do #_sm/optional-keys
|
||||
[:map {:title "config"}
|
||||
[:flags {:optional true} [::sm/set :string]]
|
||||
[:admins {:optional true} [::sm/set ::sm/email]]
|
||||
[:superusers {:optional true} [::sm/set ::sm/email]]
|
||||
[:secret-key {:optional true} :string]
|
||||
|
||||
[:tenant {:optional false} schema:tenant]
|
||||
|
||||
335
backend/src/app/features/object_cascade.clj
Normal file
335
backend/src/app/features/object_cascade.clj
Normal file
@ -0,0 +1,335 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.features.object-cascade
|
||||
"One description of the deletion cascade, walked in both directions.
|
||||
|
||||
Deleting stamps the operation date down the whole tree (profile →
|
||||
teams → projects → files → rows): live rows take the operation
|
||||
date, rows carrying an earlier date keep it, so a later parent
|
||||
delete never postpones their purge. The file row always stops
|
||||
being shared, like the original task did. Restoring clears dates back to
|
||||
nil.
|
||||
|
||||
`file_change` is the exception: a deletion date is assigned once
|
||||
and never moved. Deleting dates only the rows that carry no date
|
||||
yet, and restoring brings back only durable history (snapshots and
|
||||
legacy payload rows, told apart by a non-null `label` or `data`).
|
||||
Revisions are born already scheduled for deletion one hour later,
|
||||
and the collector only looks at rows that carry a date, so moving
|
||||
or clearing their date would leave them in the table for ever.
|
||||
|
||||
The walk is always recursive. Restoring an object also restores
|
||||
its ancestor chain up to the team (chain rows only, never
|
||||
siblings, never profiles); a team without a live owner refuses
|
||||
the restore instead of leaving content nobody can manage. Two
|
||||
one-way steps kept from the original code: deleting unshares the
|
||||
file and absorbs its libraries (irreversible: a restored file
|
||||
does not become shared again), while restoring resets the
|
||||
media-trimmed flag."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as-alias sql]))
|
||||
|
||||
(def ^:dynamic *team-deletion*
|
||||
"True while walking under a team deletion, so shared libraries
|
||||
absorbed at the team level are not absorbed file by file."
|
||||
false)
|
||||
|
||||
(def ^:private file-row-tables
|
||||
[:file-data
|
||||
:file-media-object
|
||||
:file-thumbnail
|
||||
:file-tagged-object-thumbnail])
|
||||
|
||||
(def ^:private sql:owned-team-ids
|
||||
"SELECT t.id
|
||||
FROM team AS t
|
||||
JOIN team_profile_rel AS tp ON (tp.team_id = t.id)
|
||||
WHERE tp.profile_id = ?
|
||||
AND tp.is_owner IS TRUE")
|
||||
|
||||
(defn- child-teams
|
||||
[conn profile-id]
|
||||
(db/exec! conn [sql:owned-team-ids profile-id]))
|
||||
|
||||
(defn- child-projects
|
||||
[conn team-id]
|
||||
(db/query conn :project {:team-id team-id} {::sql/columns [:id]}))
|
||||
|
||||
(defn- child-files
|
||||
[conn project-id]
|
||||
(db/query conn :file {:project-id project-id} {::sql/columns [:id]}))
|
||||
|
||||
(defn- stamp-file-change-rows
|
||||
"Date (or clear) the `file_change` rows of a file, keeping the
|
||||
once-only rule: deleting dates only the rows that carry no date
|
||||
yet, restoring brings back only durable history (a snapshot carries
|
||||
`label`, a legacy payload row carries `data`). Revisions have
|
||||
neither and are deliberately left alone in both directions: they
|
||||
are born already scheduled for deletion one hour later, and the
|
||||
collector only looks at rows that carry a date."
|
||||
[conn file-id deleted-at]
|
||||
(if (nil? deleted-at)
|
||||
(db/update! conn :file-change
|
||||
{:deleted-at nil}
|
||||
["file_id = ? AND (label IS NOT NULL OR data IS NOT NULL)"
|
||||
file-id]
|
||||
{::db/return-keys false})
|
||||
(db/update! conn :file-change
|
||||
{:deleted-at deleted-at}
|
||||
{:file-id file-id :deleted-at nil}
|
||||
{::db/return-keys false})))
|
||||
|
||||
(defn- stamp-file-rows
|
||||
[conn file-id deleted-at]
|
||||
(stamp-file-change-rows conn file-id deleted-at)
|
||||
(doseq [table file-row-tables]
|
||||
(if (nil? deleted-at)
|
||||
(db/update! conn table
|
||||
{:deleted-at nil}
|
||||
{:file-id file-id}
|
||||
{::db/return-keys false})
|
||||
(db/update! conn table
|
||||
{:deleted-at deleted-at}
|
||||
["file_id = ? AND (deleted_at IS NULL OR deleted_at > ?)"
|
||||
file-id deleted-at]
|
||||
{::db/return-keys false}))))
|
||||
|
||||
(declare update-cascade)
|
||||
|
||||
(defn- run-file
|
||||
[{:keys [::db/conn] :as cfg} id {:keys [deleted-at absorb-file! report!]}]
|
||||
(let [row (db/get* conn :file {:id id}
|
||||
{::db/remove-deleted false
|
||||
::sql/columns [:id :is-shared]})]
|
||||
(when row
|
||||
(l/trc :obj "file" :id (str id)
|
||||
:deleted-at (some-> deleted-at ct/format-inst))
|
||||
(if (nil? deleted-at)
|
||||
(do
|
||||
(db/update! conn :file
|
||||
{:deleted-at nil
|
||||
:has-media-trimmed false}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(stamp-file-rows conn id nil))
|
||||
(do
|
||||
;; The row takes the operation date only when it carries no
|
||||
;; earlier one, so a later parent delete never postpones its
|
||||
;; purge; but it always stops being shared, like the
|
||||
;; original task did.
|
||||
(db/update! conn :file
|
||||
{:is-shared false}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/update! conn :file
|
||||
{:deleted-at deleted-at}
|
||||
["id = ? AND (deleted_at IS NULL OR deleted_at > ?)"
|
||||
id deleted-at]
|
||||
{::db/return-keys false})
|
||||
(stamp-file-rows conn id deleted-at)
|
||||
(when (and (:is-shared row)
|
||||
(not *team-deletion*)
|
||||
(some? absorb-file!))
|
||||
;; NOTE: we don't prevent file deletion on absorb
|
||||
;; operation failure
|
||||
(try
|
||||
(absorb-file! cfg id)
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "error on absorbing library"
|
||||
:file-id id
|
||||
:cause cause))))))
|
||||
(when (and (some? deleted-at) (some? report!))
|
||||
(report! :files id)))))
|
||||
|
||||
(defn- run-project
|
||||
[{:keys [::db/conn] :as cfg} id {:keys [deleted-at report!] :as opts}]
|
||||
(l/trc :obj "project" :id (str id)
|
||||
:deleted-at (some-> deleted-at ct/format-inst))
|
||||
(if (nil? deleted-at)
|
||||
(db/update! conn :project
|
||||
{:deleted-at nil}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/update! conn :project
|
||||
{:deleted-at deleted-at}
|
||||
["id = ? AND (deleted_at IS NULL OR deleted_at > ?)"
|
||||
id deleted-at]
|
||||
{::db/return-keys false}))
|
||||
(doseq [file (child-files conn id)]
|
||||
(update-cascade cfg :file (:id file) opts))
|
||||
(when (and (some? deleted-at) (some? report!))
|
||||
(report! :projects id)))
|
||||
|
||||
(def ^:private sql:restore-font-file-objects
|
||||
"UPDATE storage_object
|
||||
SET deleted_at = null
|
||||
WHERE deleted_at IS NOT NULL
|
||||
AND id IN (SELECT unnest(array[woff1_file_id, woff2_file_id, otf_file_id, ttf_file_id])
|
||||
FROM team_font_variant
|
||||
WHERE team_id = ?::uuid
|
||||
AND deleted_at IS NULL)")
|
||||
|
||||
(defn- run-team
|
||||
[{:keys [::db/conn] :as cfg} id {:keys [deleted-at report!] :as opts}]
|
||||
(l/trc :obj "team" :id (str id)
|
||||
:deleted-at (some-> deleted-at ct/format-inst))
|
||||
(if (nil? deleted-at)
|
||||
(do
|
||||
(db/update! conn :team
|
||||
{:deleted-at nil}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/update! conn :team-font-variant
|
||||
{:deleted-at nil}
|
||||
{:team-id id}
|
||||
{::db/return-keys false})
|
||||
;; The font objects of the variants we just brought back must
|
||||
;; stop being marked for deletion, or the storage GC will skip
|
||||
;; them forever (those foreign keys are `NO ACTION`).
|
||||
(db/exec-one! conn [sql:restore-font-file-objects id]))
|
||||
(do
|
||||
(db/update! conn :team
|
||||
{:deleted-at deleted-at}
|
||||
["id = ? AND (deleted_at IS NULL OR deleted_at > ?)"
|
||||
id deleted-at]
|
||||
{::db/return-keys false})
|
||||
(db/update! conn :team-font-variant
|
||||
{:deleted-at deleted-at}
|
||||
["team_id = ? AND (deleted_at IS NULL OR deleted_at > ?)"
|
||||
id deleted-at]
|
||||
{::db/return-keys false})))
|
||||
(binding [*team-deletion* true]
|
||||
(doseq [project (child-projects conn id)]
|
||||
(update-cascade cfg :project (:id project) opts)))
|
||||
(when (and (some? deleted-at) (some? report!))
|
||||
(report! :teams id)))
|
||||
|
||||
(defn- run-profile
|
||||
[{:keys [::db/conn] :as cfg} id {:keys [deleted-at] :as opts}]
|
||||
(l/trc :obj "profile" :id (str id)
|
||||
:deleted-at (some-> deleted-at ct/format-inst))
|
||||
(if (nil? deleted-at)
|
||||
(db/update! conn :profile
|
||||
{:deleted-at nil}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/update! conn :profile
|
||||
{:deleted-at deleted-at}
|
||||
["id = ? AND (deleted_at IS NULL OR deleted_at > ?)"
|
||||
id deleted-at]
|
||||
{::db/return-keys false}))
|
||||
(doseq [team (child-teams conn id)]
|
||||
(update-cascade cfg :team (:id team) opts)))
|
||||
|
||||
(defn update-cascade
|
||||
"Walk the whole object tree stamping `deleted-at`: a timestamp
|
||||
deletes, nil restores. The file row takes the operation date
|
||||
only when it carries no earlier one (project, team and row
|
||||
tables too), so a later parent delete never postpones their
|
||||
purge; the file row always stops being shared, like the original
|
||||
task did. `file_change` follows the once-only rule
|
||||
instead: deleting dates only the rows that carry no date yet, and
|
||||
restoring brings back only durable history, so a revision keeps
|
||||
the schedule it was born with. The `:absorb-file!` hook `(fn [cfg id])` runs when a
|
||||
shared file is deleted outside a team deletion; failures never
|
||||
stop the walk. The `:report!` hook `(fn [kind id])` is called with
|
||||
`:files`, `:projects` or `:teams` for every row the walk marks in
|
||||
the delete direction; the `:delete-object` task uses it to report
|
||||
the cascade as an audit event.
|
||||
|
||||
`cfg` must carry `::db/conn`: call inside a transaction, like the
|
||||
worker and the admin commands do."
|
||||
[cfg object id opts]
|
||||
(case object
|
||||
:file (run-file cfg id opts)
|
||||
:project (run-project cfg id opts)
|
||||
:team (run-team cfg id opts)
|
||||
:profile (run-profile cfg id opts))
|
||||
nil)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Ancestor chain restore
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(def ^:private sql:live-owner-exists
|
||||
"SELECT EXISTS(SELECT 1
|
||||
FROM team_profile_rel AS tp
|
||||
JOIN profile AS p ON (p.id = tp.profile_id)
|
||||
WHERE tp.team_id = ?
|
||||
AND tp.is_owner IS TRUE
|
||||
AND p.deleted_at IS NULL) AS has_live_owner")
|
||||
|
||||
(defn check-live-owner
|
||||
"Raise unless `team-id` has at least one owner whose profile is
|
||||
not deleted. Restoring into an ownerless team would leave content
|
||||
nobody can manage; the caller must restore the owner profile
|
||||
first. Runs before any write, so a refusal leaves nothing behind."
|
||||
[conn team-id]
|
||||
(let [{:keys [has-live-owner]} (db/exec-one! conn [sql:live-owner-exists team-id])]
|
||||
(when-not has-live-owner
|
||||
(ex/raise :type :validation
|
||||
:code :team-has-no-live-owner
|
||||
:hint (str "team " team-id " has no live owner")))))
|
||||
|
||||
(defn- restore-team-rows
|
||||
[conn team-id]
|
||||
(db/update! conn :team
|
||||
{:deleted-at nil}
|
||||
{:id team-id}
|
||||
{::db/return-keys false})
|
||||
(db/update! conn :team-font-variant
|
||||
{:deleted-at nil}
|
||||
{:team-id team-id}
|
||||
{::db/return-keys false})
|
||||
;; Same as in `run-team`: the font objects of the variants we just
|
||||
;; brought back must stop being marked for deletion, or the storage
|
||||
;; GC will skip them forever (those foreign keys are `NO ACTION`).
|
||||
(db/exec-one! conn [sql:restore-font-file-objects team-id]))
|
||||
|
||||
(defn restore-chain
|
||||
"Restore the ancestor chain of `object`/`id` up to the team: chain
|
||||
rows only, never siblings, never profiles. Returns the team id
|
||||
(`:profile` needs no chain and returns nil). Raises
|
||||
`:team-has-no-live-owner` before writing anything when the team
|
||||
has no live owner."
|
||||
[conn object id]
|
||||
(case object
|
||||
:file
|
||||
(let [{:keys [project-id]} (db/get* conn :file {:id id}
|
||||
{::db/remove-deleted false
|
||||
::sql/columns [:id :project-id]})
|
||||
{:keys [team-id]} (db/get* conn :project {:id project-id}
|
||||
{::db/remove-deleted false
|
||||
::sql/columns [:id :team-id]})]
|
||||
(check-live-owner conn team-id)
|
||||
(db/update! conn :project
|
||||
{:deleted-at nil}
|
||||
{:id project-id}
|
||||
{::db/return-keys false})
|
||||
(restore-team-rows conn team-id)
|
||||
team-id)
|
||||
|
||||
:project
|
||||
(let [{:keys [team-id]} (db/get* conn :project {:id id}
|
||||
{::db/remove-deleted false
|
||||
::sql/columns [:id :team-id]})]
|
||||
(check-live-owner conn team-id)
|
||||
(restore-team-rows conn team-id)
|
||||
team-id)
|
||||
|
||||
:team
|
||||
(do
|
||||
(check-live-owner conn id)
|
||||
(restore-team-rows conn id)
|
||||
id)
|
||||
|
||||
:profile
|
||||
nil))
|
||||
@ -13,6 +13,7 @@
|
||||
[app.common.transit :as t]
|
||||
[app.db :as-alias db]
|
||||
[app.http.access-token :as actoken]
|
||||
[app.http.admin :as-alias admin]
|
||||
[app.http.assets :as-alias assets]
|
||||
[app.http.awsns :as-alias awsns]
|
||||
[app.http.debug :as-alias debug]
|
||||
@ -237,6 +238,7 @@
|
||||
[::rpc/routes schema:routes]
|
||||
[::oidc/routes schema:routes]
|
||||
[::assets/routes schema:routes]
|
||||
[::admin/routes schema:routes]
|
||||
[::link-preview/routes schema:routes]
|
||||
[::debug/routes schema:routes]
|
||||
[::mtx/routes schema:routes]
|
||||
@ -267,6 +269,7 @@
|
||||
|
||||
(::mtx/routes cfg)
|
||||
(::assets/routes cfg)
|
||||
(::admin/routes cfg)
|
||||
(::link-preview/routes cfg)
|
||||
(::debug/routes cfg)
|
||||
|
||||
|
||||
108
backend/src/app/http/admin.clj
Normal file
108
backend/src/app/http/admin.clj
Normal file
@ -0,0 +1,108 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.http.admin
|
||||
"Static file serving for the administration panel (`/admin` SPA).
|
||||
|
||||
Every path under `/admin` answers with a file from
|
||||
`backend/resources/admin/`, or with `index.html` when there is no
|
||||
file behind it (subpath support, so a deep reload never 404s).
|
||||
There is no authentication here: the page is public like any
|
||||
static asset, and the JS gate reads `:is-superuser` from
|
||||
`get-profile` once loaded. The real protection lives in the RPC
|
||||
guard, which rejects non-superusers with 403."
|
||||
(:require
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.io :as io]
|
||||
[integrant.core :as ig]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(def ^:private resource-prefix
|
||||
"admin/")
|
||||
|
||||
(def ^:private fallback-resource
|
||||
"index.html")
|
||||
|
||||
(def ^:private content-types
|
||||
{".html" "text/html"
|
||||
".js" "text/javascript"
|
||||
".css" "text/css"
|
||||
".json" "application/json"
|
||||
".svg" "image/svg+xml"
|
||||
".png" "image/png"
|
||||
".ico" "image/x-icon"})
|
||||
|
||||
(defn- extension
|
||||
[rel]
|
||||
(let [name (last (str/split rel "/"))]
|
||||
(when (str/includes? name ".")
|
||||
(str "." (str/lower (last (str/split name ".")))))))
|
||||
|
||||
(defn- safe-rel?
|
||||
"True when `rel` cannot escape `resource-prefix`: no parent
|
||||
references, no backslashes, plain file-name characters only."
|
||||
[rel]
|
||||
(boolean
|
||||
(and (seq rel)
|
||||
(not (str/includes? rel "\\"))
|
||||
(re-matches #"[A-Za-z0-9][A-Za-z0-9._/-]*" rel)
|
||||
(not-any? #(= ".." %) (str/split rel "/")))))
|
||||
|
||||
(defn- resolve-resource
|
||||
"Map the path behind `/admin` to a file under `resource-prefix`.
|
||||
|
||||
A blank path (the `/admin/` root) serves the index; only a safe
|
||||
relative path with a known extension and an existing resource
|
||||
serves its file. Anything else (SPA state travels in the query
|
||||
string, so there are no subpath pages) is nil, answered 404."
|
||||
[rel]
|
||||
(let [rel (str/trim (or rel ""))]
|
||||
(cond
|
||||
(= "" rel)
|
||||
fallback-resource
|
||||
|
||||
(and (safe-rel? rel)
|
||||
(contains? content-types (extension rel))
|
||||
(io/resource (str resource-prefix rel)))
|
||||
rel)))
|
||||
|
||||
(defn- content-type-for
|
||||
[rel]
|
||||
(get content-types (extension rel)))
|
||||
|
||||
(defn admin-handler
|
||||
[_cfg request]
|
||||
(if-let [rel (resolve-resource (some-> request :path-params :path))]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" (content-type-for rel)
|
||||
;; The panel ships unversioned filenames and changes
|
||||
;; often: never let browsers cache it, or the sidebar
|
||||
;; keeps pointing at screens that no longer exist.
|
||||
"cache-control" "no-store"}
|
||||
::yres/body (slurp (io/resource (str resource-prefix rel)) :encoding "UTF-8")}
|
||||
{::yres/status 404
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
::yres/body "not found"}))
|
||||
|
||||
(defn admin-redirect-handler
|
||||
"Redirect the bare `/admin` to `/admin/` with a relative target.
|
||||
|
||||
Without the trailing slash, relative asset URLs in index.html
|
||||
(`css/admin.css`, `js/main.js`) resolve against `/` instead of
|
||||
`/admin/` and the panel loads unstyled and inert. The relative
|
||||
target keeps subpath deployments working."
|
||||
[_cfg _request]
|
||||
{::yres/status 302
|
||||
::yres/headers {"location" "admin/"}})
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ cfg]
|
||||
(assert (io/resource (str resource-prefix fallback-resource))
|
||||
"missing admin/index.html on the classpath")
|
||||
[["/admin"
|
||||
["" {:handler (partial admin-redirect-handler cfg)}]
|
||||
["/" {:handler (partial admin-handler cfg)}]
|
||||
["/*path" {:handler (partial admin-handler cfg)}]]])
|
||||
@ -898,7 +898,7 @@
|
||||
[pool {:keys [::session/profile-id]}]
|
||||
(or (and (= "devenv" (cf/get :host)) profile-id)
|
||||
(let [profile (ex/ignoring (profile/get-profile pool profile-id))
|
||||
admins (or (cf/get :admins) #{})]
|
||||
admins (or (cf/get :superusers) #{})]
|
||||
(contains? admins (:email profile)))))
|
||||
|
||||
(def with-authorization
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
[app.db :as-alias db]
|
||||
[app.email :as-alias email]
|
||||
[app.http :as-alias http]
|
||||
[app.http.admin :as-alias http.admin]
|
||||
[app.http.assets :as-alias http.assets]
|
||||
[app.http.awsns :as http.awsns]
|
||||
[app.http.client :as-alias http.client]
|
||||
@ -513,6 +514,7 @@
|
||||
::oidc/routes (ig/ref ::oidc/routes)
|
||||
::mgmt/routes (ig/ref ::mgmt/routes)
|
||||
::http.debug/routes (ig/ref ::http.debug/routes)
|
||||
::http.admin/routes (ig/ref ::http.admin/routes)
|
||||
::http.assets/routes (ig/ref ::http.assets/routes)
|
||||
::http.link-preview/routes (ig/ref ::http.link-preview/routes)
|
||||
::http.ws/routes (ig/ref ::http.ws/routes)
|
||||
@ -521,6 +523,9 @@
|
||||
::http.link-preview/routes
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
::http.admin/routes
|
||||
{}
|
||||
|
||||
::http.debug/routes
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::rds/pool (ig/ref ::rds/pool)
|
||||
@ -563,7 +568,8 @@
|
||||
(ig/ref :app.loggers.database/reporter)}
|
||||
|
||||
:app.rpc/methods
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
{:app.auth/superusers (ig/ref :app.auth/superusers)
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
::jobs/defs (ig/ref ::jobs/defs)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::rds/pool (ig/ref ::rds/pool)
|
||||
@ -597,6 +603,13 @@
|
||||
;; job-def needs this client — that would cycle through jobs/defs.
|
||||
::setup/shared-keys (ig/ref ::setup/shared-keys)}
|
||||
|
||||
:app.auth/superusers
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
|
||||
;; NOTE: this dependency is only necessary for proper initialization
|
||||
;; ordering, the registry queries the profile table on startup.
|
||||
::migrations (ig/ref :app.migrations/migrations)}
|
||||
|
||||
:app.rpc/management-methods
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
::jobs/defs (ig/ref ::jobs/defs)
|
||||
@ -611,9 +624,30 @@
|
||||
::rds/client (ig/ref ::rds/client)
|
||||
::setup/props (ig/ref ::setup/props)}
|
||||
|
||||
:app.rpc/admin-methods
|
||||
{:app.auth/superusers (ig/ref :app.auth/superusers)
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
::jobs/defs (ig/ref ::jobs/defs)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::rds/pool (ig/ref ::rds/pool)
|
||||
:app.nitrate/client (ig/ref :app.nitrate/client)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::ldap/provider (ig/ref ::ldap/provider)
|
||||
::sto/storage (ig/ref ::sto/storage)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::mbus/msgbus (ig/ref ::mbus/msgbus)
|
||||
::rds/client (ig/ref ::rds/client)
|
||||
::rpc/climit (ig/ref ::rpc/climit)
|
||||
::rpc/rlimit (ig/ref ::rpc/rlimit)
|
||||
::setup/templates (ig/ref ::setup/templates)
|
||||
::setup/props (ig/ref ::setup/props)}
|
||||
|
||||
::rpc/routes
|
||||
{::rpc/methods (ig/ref :app.rpc/methods)
|
||||
::rpc/management-methods (ig/ref :app.rpc/management-methods)
|
||||
::rpc/admin-methods (ig/ref :app.rpc/admin-methods)
|
||||
:app.auth/superusers (ig/ref :app.auth/superusers)
|
||||
|
||||
;; FIXME: revisit if db/pool is necessary here
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
@ -624,6 +658,7 @@
|
||||
::jobs/defs
|
||||
{:sendmail (ig/ref ::email/job-def)
|
||||
:delete-object (ig/ref :app.tasks.delete-object/job-def)
|
||||
:restore-object (ig/ref :app.tasks.restore-object/job-def)
|
||||
:demo-purge (ig/ref :app.tasks.demo-purge/job-def)
|
||||
:run-webhook (ig/ref ::webhooks/run-webhook-job-def)
|
||||
:process-webhook-event (ig/ref ::webhooks/process-webhook-event-job-def)
|
||||
@ -655,6 +690,10 @@
|
||||
::sto/storage (ig/ref ::sto/storage)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||
|
||||
:app.tasks.restore-object/job-def
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||
|
||||
:app.tasks.demo-purge/job-def
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||
@ -756,7 +795,6 @@
|
||||
::email/default-reply-to (cf/get :smtp-default-reply-to)
|
||||
::email/default-from (cf/get :smtp-default-from)}
|
||||
|
||||
|
||||
::srepl/urepl
|
||||
{:port (cf/get :urepl-port 6062)
|
||||
:host (cf/get :urepl-host "localhost")}
|
||||
|
||||
@ -532,7 +532,13 @@
|
||||
:fn (mg/resource "app/migrations/sql/0156-reset-pg-storage-defaults.sql")}
|
||||
|
||||
{:name "0157-add-audit-log-pending-table"
|
||||
:fn (mg/resource "app/migrations/sql/0157-add-audit-log-pending-table.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0157-add-audit-log-pending-table.sql")}
|
||||
|
||||
{:name "0157-add-created-at-cursor-indexes"
|
||||
:fn (mg/resource "app/migrations/sql/0157-add-created-at-cursor-indexes.sql")}
|
||||
|
||||
{:name "0158-add-job-admin-list-index"
|
||||
:fn (mg/resource "app/migrations/sql/0158-add-job-admin-list-index.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
-- Cursor indexes for the admin panel lists (`get-profiles`,
|
||||
-- `get-teams`, `get-projects`, `get-files`).
|
||||
--
|
||||
-- All four lists share one order (`created_at DESC, id DESC`) with
|
||||
-- keyset pagination (`(created_at, id) < (?, ?)`). Without these
|
||||
-- indexes every page rebuilds a full sort of the table, which stops
|
||||
-- working on PRO-sized tables (hundreds of thousands of rows).
|
||||
--
|
||||
-- Plain CREATE INDEX on purpose: migrations run inside a
|
||||
-- transaction, so CONCURRENTLY is not available. Same trade-off as
|
||||
-- every other index in this folder; the lists are admin-only and the
|
||||
-- build is a one-off at deploy.
|
||||
--
|
||||
-- The pre-existing partial `*_deleted_at_idx` indexes
|
||||
-- (WHERE deleted_at IS NOT NULL) keep serving the "only deleted"
|
||||
-- filter; these full indexes serve all three deleted-filter modes
|
||||
-- with the deleted check applied per row during the scan.
|
||||
--
|
||||
-- Deploy note: a plain CREATE INDEX takes a write-blocking lock on
|
||||
-- each table while its index builds. On PRO-sized tables, deploy
|
||||
-- this in a low-traffic window.
|
||||
|
||||
CREATE INDEX profile__admin_list__idx
|
||||
ON profile (created_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX team__admin_list__idx
|
||||
ON team (created_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX project__admin_list__idx
|
||||
ON project (created_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX file__admin_list__idx
|
||||
ON file (created_at DESC, id DESC);
|
||||
@ -0,0 +1,21 @@
|
||||
-- Admin jobs list index (`get-jobs`).
|
||||
--
|
||||
-- The list pages newest-first (`created_at DESC, id DESC`) with keyset
|
||||
-- pagination (`(created_at, id) < (?, ?)`), scoped to the current
|
||||
-- instance tenant and optionally filtered by `status` and `name`.
|
||||
-- Leading with `(tenant, status)` serves the equality filters, and the
|
||||
-- trailing `(created_at DESC, id DESC)` delivers the page order for
|
||||
-- free whenever `status` is filtered (the common case: looking at one
|
||||
-- state such as failed). The unfiltered view still sorts, but only
|
||||
-- over the tenant's own rows through the same index.
|
||||
--
|
||||
-- Plain CREATE INDEX on purpose: migrations run inside a
|
||||
-- transaction, so CONCURRENTLY is not available. Same trade-off as
|
||||
-- every other index in this folder.
|
||||
--
|
||||
-- Deploy note: a plain CREATE INDEX takes a write-blocking lock on
|
||||
-- the table while the index builds. On PRO-sized job tables, deploy
|
||||
-- this in a low-traffic window.
|
||||
|
||||
CREATE INDEX job__admin_list__idx
|
||||
ON job (tenant, status, created_at DESC, id DESC);
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
(ns app.rpc
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.auth.ldap :as-alias ldap]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
@ -171,7 +172,15 @@
|
||||
[_ f mdata]
|
||||
(let [required-auth? (::auth mdata true)
|
||||
required-auth-type (::auth-type mdata)
|
||||
required-perms (into #{} (::perms mdata))]
|
||||
required-perms (set (::perms mdata))
|
||||
;; Token scopes keep their meaning: they force token authentication
|
||||
;; and are checked against the token. "superuser" is satisfied either
|
||||
;; by registry membership (any auth type) or by a token carrying it
|
||||
;; as a granted scope. Token scopes are operator-granted only —
|
||||
;; tokens are created with empty perms — so this grants nothing
|
||||
;; by itself.
|
||||
scope-perms (disj required-perms "superuser")
|
||||
superuser-required (contains? required-perms "superuser")]
|
||||
(fn [cfg params]
|
||||
(let [profile-id (::profile-id params)
|
||||
auth-type (::auth-type params)
|
||||
@ -188,18 +197,24 @@
|
||||
:code :token-auth-required
|
||||
:hint "access token authentication required for this endpoint")
|
||||
|
||||
(and (seq required-perms)
|
||||
(and (seq scope-perms)
|
||||
(not= auth-type :token))
|
||||
(ex/raise :type :authorization
|
||||
:code :token-auth-required
|
||||
:hint "access token authentication required for this endpoint")
|
||||
|
||||
(and (seq required-perms)
|
||||
(not (set/subset? required-perms token-perms)))
|
||||
(and (seq scope-perms)
|
||||
(not (set/subset? scope-perms token-perms)))
|
||||
(ex/raise :type :authorization
|
||||
:code :missing-perms
|
||||
:hint "missing required permissions"
|
||||
:required required-perms)
|
||||
:required scope-perms)
|
||||
|
||||
(and superuser-required
|
||||
(not (auth/superuser-allowed? cfg profile-id token-perms)))
|
||||
(ex/raise :type :authorization
|
||||
:code :superuser-required
|
||||
:hint "superuser required for this endpoint")
|
||||
|
||||
:else
|
||||
(f cfg params))))))
|
||||
@ -477,6 +492,47 @@
|
||||
(let [cfg (d/without-nils cfg)]
|
||||
(resolve-management-methods cfg)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; ADMIN METHODS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- resolve-admin-methods
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::module "admin" ::type "command" ::metrics-id :rpc-admin-timing)]
|
||||
(->> (sv/scan-ns
|
||||
'app.rpc.admin.errors
|
||||
'app.rpc.admin.file
|
||||
'app.rpc.admin.job
|
||||
'app.rpc.admin.misc
|
||||
'app.rpc.admin.profile
|
||||
'app.rpc.admin.project
|
||||
'app.rpc.admin.team)
|
||||
(map (partial process-method cfg wrap))
|
||||
(into {}))))
|
||||
|
||||
(def ^:private schema:admin-methods-params
|
||||
[:map {:title "admin-methods-params"}
|
||||
::session/manager
|
||||
::http.client/client
|
||||
::db/pool
|
||||
::rds/pool
|
||||
::mbus/msgbus
|
||||
::sto/storage
|
||||
::mtx/metrics
|
||||
[::ldap/provider [:maybe ::ldap/provider]]
|
||||
[::climit [:maybe ::climit]]
|
||||
[::rlimit [:maybe ::rlimit]]
|
||||
::setup/props])
|
||||
|
||||
(defmethod ig/assert-key ::admin-methods
|
||||
[_ params]
|
||||
(assert (sm/check schema:admin-methods-params params)))
|
||||
|
||||
(defmethod ig/init-key ::admin-methods
|
||||
[_ cfg]
|
||||
(let [cfg (d/without-nils cfg)]
|
||||
(resolve-admin-methods cfg)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; ROUTES
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@ -501,10 +557,11 @@
|
||||
(assert (db/pool? (::db/pool params)) "expect valid database pool")
|
||||
(assert (session/manager? (::session/manager params)) "expect valid session manager")
|
||||
(assert (valid-methods? (::methods params)) "expect valid methods map")
|
||||
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
|
||||
(assert (valid-methods? (::management-methods params)) "expect valid methods map")
|
||||
(assert (valid-methods? (::admin-methods params)) "expect valid methods map"))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [::methods ::management-methods ::setup/shared-keys] :as cfg}]
|
||||
[_ {:keys [::methods ::management-methods ::admin-methods ::setup/shared-keys] :as cfg}]
|
||||
["/api"
|
||||
["/management"
|
||||
["/methods/:method-name"
|
||||
@ -530,6 +587,14 @@
|
||||
:base-uri (cf/get-public-uri "api/main")
|
||||
:description "MAIN API")]
|
||||
|
||||
["/admin"
|
||||
["/methods/:method-name"
|
||||
{:middleware [[mw/cors]
|
||||
[sec/client-header-check]
|
||||
[session/authz cfg]
|
||||
[actoken/authz cfg]]
|
||||
:handler (make-rpc-handler admin-methods)}]]
|
||||
|
||||
;; BACKWARD COMPATIBILITY
|
||||
["/_doc" {:handler (redirect (cf/get-public-uri "api/main/doc"))}]
|
||||
["/doc" {:handler (redirect (cf/get-public-uri "api/main/doc"))}]
|
||||
|
||||
271
backend/src/app/rpc/admin/errors.clj
Normal file
271
backend/src/app/rpc/admin/errors.clj
Normal file
@ -0,0 +1,271 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.rpc.admin.errors
|
||||
"Admin error-report commands, served through `/api/admin/methods`.
|
||||
|
||||
They mirror `get-error-reports` / `get-error-report` (which stay
|
||||
token-only for integrations) for session callers, reusing their
|
||||
query and schemas. Guards live in wrap-authentication
|
||||
(the 'superuser' permission), not here — except
|
||||
get-shared-error-report, anonymous by design: its share token
|
||||
is the only credential."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as-alias sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.error-reports :as error-reports]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]
|
||||
[yetti.response :as yres]))
|
||||
|
||||
(defn read-report
|
||||
"Read one error report by id, or nil when missing.
|
||||
|
||||
Same shaping as `::get-error-report`, shared with the stateless
|
||||
share-link RPC command so both paint the same data."
|
||||
[cfg id]
|
||||
(when-let [report (db/get-by-id cfg :server-error-report id {::db/check-deleted false})]
|
||||
(let [content (db/decode-transit-pgobject (:content report))]
|
||||
(-> report
|
||||
(dissoc :content)
|
||||
(merge content)
|
||||
(update :source error-reports/source->name)
|
||||
(assoc :kind (or (:kind content) (:origin content)))
|
||||
(assoc :version (:version content))
|
||||
(d/without-nils)))))
|
||||
|
||||
(sv/defmethod ::get-error-reports
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params error-reports/schema:get-error-reports-params
|
||||
::sm/result error-reports/schema:get-error-reports-result}
|
||||
[cfg params]
|
||||
(let [limit (min (or (:limit params) error-reports/default-limit)
|
||||
error-reports/max-limit)
|
||||
params (assoc params :limit (inc limit))
|
||||
[sql & sql-args] (error-reports/build-list-query params)
|
||||
rows (db/exec! cfg (into [sql] sql-args))]
|
||||
(if (seq rows)
|
||||
(let [items (->> (take limit rows)
|
||||
(mapv #(-> %
|
||||
(update :source error-reports/source->name)
|
||||
d/without-nils)))
|
||||
last-item (peek items)
|
||||
has-more? (> (count rows) limit)]
|
||||
{:items items
|
||||
:next-since (when has-more? (:created-at last-item))
|
||||
:next-id (when has-more? (:id last-item))})
|
||||
{:items []})))
|
||||
|
||||
(sv/defmethod ::get-error-report
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params error-reports/schema:get-error-report-params
|
||||
::sm/result error-reports/schema:error-report}
|
||||
[cfg {:keys [id]}]
|
||||
(if-let [report (read-report cfg id)]
|
||||
report
|
||||
(ex/raise :type :not-found
|
||||
:code :report-not-found
|
||||
:hint (str "error report " id " not found"))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SHARE LINKS (stateless)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; A share link is a JWE token minted by `::create-error-report-share`
|
||||
;; and consumed by `::get-shared-error-report` (`GET
|
||||
;; /api/admin/methods/get-shared-error-report?token=<token>`).
|
||||
;; Nothing is stored: the token carries the report id, the creator
|
||||
;; and the expiry, sealed with the server key. There is no
|
||||
;; revocation; protection is a short TTL plus a scope of exactly one
|
||||
;; report. Rotating `secret-key` kills every link at once.
|
||||
(def share-iss
|
||||
"Issuer marker for error-report share tokens."
|
||||
"error-report-share")
|
||||
|
||||
(def ^:private share-default-ttl-hours
|
||||
4)
|
||||
|
||||
(def ^:private share-max-ttl-hours
|
||||
24)
|
||||
|
||||
;; Short identifying rows go first in this fixed order; every other
|
||||
;; key follows alphabetically so a new content key never goes
|
||||
;; missing.
|
||||
(def ^:private share-head-rows
|
||||
[[:id "Id"]
|
||||
[:created-at "Date"]
|
||||
[:source "Source"]])
|
||||
|
||||
(defn- share-drop-separators
|
||||
"Drop filler separator lines (runs of `=`) from a value. They bloat
|
||||
shared reports and carry no information; stored reports keep them."
|
||||
[text]
|
||||
(->> (str/split text #"\r?\n")
|
||||
(remove #(re-find #"^\s*={10,}\s*$" %))
|
||||
(str/join "\n")))
|
||||
|
||||
(defn- share-text-value
|
||||
[v]
|
||||
(cond
|
||||
(string? v) (share-drop-separators v)
|
||||
(or (uuid? v) (ct/inst? v)) (str v)
|
||||
:else (pr-str v)))
|
||||
|
||||
(defn- share-title
|
||||
"Shared title, `# Report: <hint>` (first line only, so a
|
||||
multi-line hint cannot break the heading). Without a hint it falls
|
||||
back to a plain label. Mirrors the web detail page, where the hint
|
||||
is the title and never renders as a row."
|
||||
[report]
|
||||
(let [hint (when (string? (:hint report))
|
||||
(first (str/split (:hint report) #"\r?\n")))
|
||||
label (if (str/blank? hint) "Error report" (str "Report: " (str/trim hint)))]
|
||||
(str "# " label)))
|
||||
|
||||
(defn- share-fence
|
||||
"Fence for a section body. Steps up to four backticks when the body
|
||||
already holds a fence, so the block never closes early."
|
||||
[text]
|
||||
(if (str/includes? text "```") "````" "```"))
|
||||
|
||||
(defn render-shared-report
|
||||
"Render `report` as plain text for shared links and `curl`.
|
||||
|
||||
Same shape as the old `/dbg/error` templates: a title, compact
|
||||
one-line rows, then the long bodies under `##` headings in fenced
|
||||
blocks, `trace` and `context` first, printed verbatim. No field parsing: new content keys show up on
|
||||
their own. `expires-at` feeds the footer so the reader knows the
|
||||
link dies."
|
||||
[report expires-at]
|
||||
(let [known? #{:id :created-at :source :hint}
|
||||
rest-keys (->> (keys report)
|
||||
(remove known?)
|
||||
(sort-by name))
|
||||
row? (fn [k]
|
||||
(let [v (get report k)]
|
||||
(and (some? v)
|
||||
(not (re-find #"[\r\n]" (share-text-value v))))))
|
||||
rows (filter row? rest-keys)
|
||||
sections (remove row? rest-keys)
|
||||
sections (concat (filter #{:trace} sections)
|
||||
(filter #{:context} sections)
|
||||
(remove #{:trace :context} sections))
|
||||
lines (into [(share-title report) ""]
|
||||
(keep (fn [[k label]]
|
||||
(when-some [v (get report k)]
|
||||
(str label ": " (share-text-value v))))
|
||||
share-head-rows))
|
||||
lines (into lines
|
||||
(map (fn [k]
|
||||
(str (name k) ": " (share-text-value (get report k))))
|
||||
rows))
|
||||
lines (into (conj (vec lines) "")
|
||||
(mapcat (fn [k]
|
||||
(let [text (share-text-value (get report k))
|
||||
fence (share-fence text)]
|
||||
[(str "## " (str/capital (name k)))
|
||||
""
|
||||
fence
|
||||
text
|
||||
fence
|
||||
""]))
|
||||
sections))]
|
||||
(str/join "\n" (conj (vec lines)
|
||||
"---"
|
||||
(str "Shared link, expires " (str expires-at)
|
||||
". Single report, no account access.")))))
|
||||
|
||||
(defn- verify-share-token
|
||||
"Verify a share-link token. Returns `[report-id expires-at]`, or
|
||||
nil when the token is bad, expired, foreign or malformed. Never
|
||||
throws and never logs the token."
|
||||
[cfg token]
|
||||
(when (seq (some-> token str/trim))
|
||||
(let [claims (ex/ignoring
|
||||
(tokens/verify cfg {:token (str/trim (str token))
|
||||
:iss share-iss}))
|
||||
report-id (:report-id claims)]
|
||||
(when (uuid? report-id)
|
||||
[report-id (:exp claims)]))))
|
||||
|
||||
(def schema:create-error-report-share-params
|
||||
[:map {:title "create-error-report-share-params"}
|
||||
[:id ::sm/uuid]
|
||||
[:ttl-hours {:optional true} [:int {:min 1 :max share-max-ttl-hours}]]])
|
||||
|
||||
(def schema:create-error-report-share-result
|
||||
[:map {:title "create-error-report-share-result"}
|
||||
[:url ::sm/text]
|
||||
[:expires-at ct/schema:inst]])
|
||||
|
||||
(sv/defmethod ::create-error-report-share
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:create-error-report-share-params
|
||||
::sm/result schema:create-error-report-share-result}
|
||||
[cfg {:keys [id ttl-hours ::rpc/profile-id]}]
|
||||
(let [ttl-hours (or ttl-hours share-default-ttl-hours)]
|
||||
(when-not (read-report cfg id)
|
||||
(ex/raise :type :not-found
|
||||
:code :report-not-found
|
||||
:hint (str "error report " id " not found")))
|
||||
(let [expires-at (ct/plus (ct/now) (ct/duration {:hours ttl-hours}))
|
||||
token (tokens/generate cfg {:iss share-iss
|
||||
:report-id id
|
||||
:created-by profile-id
|
||||
:exp expires-at})]
|
||||
(audit/insert cfg {:name "create-error-report-share"
|
||||
:type "action"
|
||||
:profile-id profile-id
|
||||
:context {:triggered-by "admin-panel"
|
||||
:report-id id
|
||||
:ttl-hours ttl-hours}})
|
||||
{:url (str (cf/get-public-uri "api/admin/methods/get-shared-error-report")
|
||||
"?token=" token)
|
||||
:expires-at expires-at})))
|
||||
|
||||
(def ^:private share-not-found
|
||||
"Same 404 for a bad, expired or foreign token and for a missing
|
||||
report, so probing tokens leaks nothing."
|
||||
{::yres/status 404
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
::yres/body "not found"})
|
||||
|
||||
(def schema:get-shared-error-report-params
|
||||
;; Any string goes (even missing/blank): anything unverifiable
|
||||
;; answers the same 404 below instead of a validation error, so
|
||||
;; probing tokens leaks nothing.
|
||||
[:map {:title "get-shared-error-report-params"}
|
||||
[:token {:optional true} :string]])
|
||||
|
||||
;; NOTE: no `::sm/result` on purpose: like the SSE commands, this
|
||||
;; returns a raw response function instead of data, so there is no
|
||||
;; transit/JSON shape to validate.
|
||||
(sv/defmethod ::get-shared-error-report
|
||||
{::doc/added "2.20"
|
||||
::rpc/auth false
|
||||
::sm/params schema:get-shared-error-report-params}
|
||||
[cfg {:keys [token]}]
|
||||
(fn [_request]
|
||||
(let [[report-id expires-at] (verify-share-token cfg token)
|
||||
report (when (some? report-id)
|
||||
(read-report cfg report-id))]
|
||||
(if (and (some? report) (some? expires-at))
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/markdown; charset=utf-8"
|
||||
"cache-control" "no-store"}
|
||||
::yres/body (render-shared-report report expires-at)}
|
||||
share-not-found))))
|
||||
434
backend/src/app/rpc/admin/file.clj
Normal file
434
backend/src/app/rpc/admin/file.clj
Normal file
@ -0,0 +1,434 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.rpc.admin.file
|
||||
"Admin file commands, served through `/api/admin/methods`.
|
||||
|
||||
Access control lives in `wrap-authentication` (the `\"superuser\"`
|
||||
permission), not here, so a new command cannot forget it."
|
||||
(:require
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.v1 :as bf.v1]
|
||||
[app.binfile.v3 :as bf.v3]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.files.changes :as cfc]
|
||||
[app.common.files.repair :as cfr]
|
||||
[app.common.files.validate :as cfv]
|
||||
[app.common.schema :as-alias sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.features.object-cascade :as cascade]
|
||||
[app.jobs :as jobs]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.admin.list :as adml]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.srepl.helpers :as h]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.tasks.restore-object]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.io :as io]
|
||||
[yetti.response :as yres]))
|
||||
|
||||
(def schema:validation-error
|
||||
[:map
|
||||
[:code ::sm/text]
|
||||
[:hint ::sm/text]
|
||||
[:file-id ::sm/uuid]
|
||||
[:page-id {:optional true} ::sm/uuid]
|
||||
[:shape-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(defn- sanitize-error
|
||||
"Trim a validation error down to JSON-safe scalars.
|
||||
|
||||
The raw error carries the broken `:shape` (arbitrary, potentially
|
||||
huge or unserializable data) and `:args`; neither travels over
|
||||
the wire."
|
||||
[error]
|
||||
(d/without-nils
|
||||
{:code (name (:code error))
|
||||
:hint (:hint error)
|
||||
:file-id (:file-id error)
|
||||
:page-id (:page-id error)
|
||||
:shape-id (:shape-id error)}))
|
||||
|
||||
(defn- get-live-file
|
||||
"Fetch a file that is neither missing nor marked for deletion."
|
||||
[cfg file-id]
|
||||
(or (bfc/get-file cfg file-id
|
||||
:throw-if-not-exists? false
|
||||
:realize? true)
|
||||
(ex/raise :type :not-found
|
||||
:code :file-not-found
|
||||
:hint (str "file " file-id " not found"))))
|
||||
|
||||
(defn- validate-file*
|
||||
[cfg file-id]
|
||||
(let [file (get-live-file cfg file-id)
|
||||
libs (bfc/get-resolved-file-libraries cfg file)
|
||||
errors (or (cfv/validate-file file libs) [])]
|
||||
(mapv sanitize-error errors)))
|
||||
|
||||
(def schema:validate-file-params
|
||||
[:map
|
||||
[:file-id ::sm/uuid]])
|
||||
|
||||
(def schema:validate-file-result
|
||||
[:map
|
||||
[:file-id ::sm/uuid]
|
||||
[:errors [:vector schema:validation-error]]])
|
||||
|
||||
(sv/defmethod ::validate-file
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:validate-file-params
|
||||
::sm/result schema:validate-file-result}
|
||||
[cfg {:keys [file-id]}]
|
||||
(let [errors (db/tx-run! (assoc cfg ::db/rollback true)
|
||||
(fn [cfg] (validate-file* cfg file-id)))]
|
||||
{:file-id file-id
|
||||
:errors errors}))
|
||||
|
||||
(def schema:repair-file-params
|
||||
[:map
|
||||
[:file-id ::sm/uuid]
|
||||
[:skip-snapshot {:optional true} ::sm/boolean]])
|
||||
|
||||
(def schema:repair-file-result
|
||||
[:map
|
||||
[:file-id ::sm/uuid]
|
||||
[:errors [:vector schema:validation-error]]
|
||||
[:changes ::sm/int]
|
||||
[:snapshot-taken ::sm/boolean]])
|
||||
|
||||
;; NOTE: no `::db/transaction` here on purpose: `h/process-file!`
|
||||
;; opens its own transaction, and nesting `tx-run!` calls reuses the
|
||||
;; outer one instead of a savepoint.
|
||||
(sv/defmethod ::repair-file
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:repair-file-params
|
||||
::sm/result schema:repair-file-result}
|
||||
[cfg {:keys [file-id skip-snapshot ::rpc/profile-id]}]
|
||||
;; Existence (and not-deleted) check up front: `process-file!`
|
||||
;; silently does nothing on a missing file.
|
||||
(db/tx-run! (assoc cfg ::db/rollback true)
|
||||
(fn [cfg] (get-live-file cfg file-id)))
|
||||
|
||||
(let [state (atom {:changes 0})
|
||||
update-fn (fn [file libs _opts]
|
||||
(let [errors (or (cfv/validate-file file libs) [])]
|
||||
(if (empty? errors)
|
||||
file
|
||||
(let [changes (cfr/repair-file file libs errors)]
|
||||
(swap! state assoc :changes (count changes))
|
||||
(-> file
|
||||
(update :data cfc/process-changes changes))))))
|
||||
wrote? (db/tx-run! cfg h/process-file!
|
||||
file-id update-fn
|
||||
{::h/with-libraries? true
|
||||
::h/validate? false
|
||||
::h/profile-id profile-id
|
||||
::h/snapshot-label (when-not skip-snapshot "repair")})
|
||||
errors (db/tx-run! (assoc cfg ::db/rollback true)
|
||||
(fn [cfg] (validate-file* cfg file-id)))]
|
||||
{:file-id file-id
|
||||
:errors errors
|
||||
:changes (:changes @state)
|
||||
:snapshot-taken (boolean (and wrote? (not skip-snapshot)))}))
|
||||
|
||||
(def ^:private files-default-limit 50)
|
||||
(def ^:private files-max-limit 200)
|
||||
|
||||
(def schema:file-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:project-id ::sm/uuid]
|
||||
[:project-name ::sm/text]
|
||||
[:team-id ::sm/uuid]
|
||||
[:team-name ::sm/text]
|
||||
[:created-at ct/schema:inst]
|
||||
[:modified-at ct/schema:inst]
|
||||
[:deleted-at {:optional true} ct/schema:inst]])
|
||||
|
||||
(def schema:get-files-params
|
||||
[:map {:title "get-files-params"}
|
||||
[:since {:optional true} ct/schema:inst]
|
||||
[:since-id {:optional true} ::sm/uuid]
|
||||
[:limit {:optional true}
|
||||
[:and ::sm/int [:fn #(<= 1 % files-max-limit)]]]
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
[:team-id {:optional true} ::sm/uuid]
|
||||
[:project-id {:optional true} ::sm/uuid]
|
||||
[:deleted {:optional true} ::sm/boolean]])
|
||||
|
||||
(def schema:get-files-result
|
||||
[:map
|
||||
[:items [:vector schema:file-summary]]
|
||||
[:next-since {:optional true} ct/schema:inst]
|
||||
[:next-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(defn- build-files-list-query
|
||||
"List files newest-first with keyset pagination.
|
||||
|
||||
Lookup is by exact `id` only; see `app.rpc.admin.list` for the
|
||||
shared list contract. The parent-project/team guards stay
|
||||
regardless."
|
||||
[{:keys [since since-id id team-id project-id deleted limit]
|
||||
:or {limit files-default-limit}}]
|
||||
(let [clauses (keep identity
|
||||
[(when id
|
||||
{:where "f.id = ?"
|
||||
:params [id]})
|
||||
(when team-id
|
||||
{:where "p.team_id = ?"
|
||||
:params [team-id]})
|
||||
(when project-id
|
||||
{:where "f.project_id = ?"
|
||||
:params [project-id]})
|
||||
(adml/deleted-clause "f.deleted_at" deleted)
|
||||
(adml/since-clause "f.created_at" "f.id" since since-id)])
|
||||
sql-parts (map :where clauses)
|
||||
sql-params (mapcat :params clauses)
|
||||
sql (str "SELECT f.id, f.name, f.project_id, "
|
||||
"p.name AS project_name, "
|
||||
"p.team_id AS team_id, "
|
||||
"t.name AS team_name, "
|
||||
"f.created_at, f.modified_at, f.deleted_at "
|
||||
"FROM file AS f "
|
||||
"JOIN project AS p ON (p.id = f.project_id) "
|
||||
"JOIN team AS t ON (t.id = p.team_id) "
|
||||
"WHERE p.deleted_at IS NULL "
|
||||
"AND t.deleted_at IS NULL "
|
||||
(when (seq sql-parts)
|
||||
(str "AND " (str/join " AND " sql-parts) " "))
|
||||
"ORDER BY f.created_at DESC, f.id DESC "
|
||||
"LIMIT ?")]
|
||||
(into [sql] (concat sql-params [limit]))))
|
||||
|
||||
(sv/defmethod ::get-files
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-files-params
|
||||
::sm/result schema:get-files-result}
|
||||
[cfg params]
|
||||
(let [[limit params] (adml/with-fetch-limit params files-default-limit files-max-limit)
|
||||
[sql & sql-args] (build-files-list-query params)
|
||||
rows (db/exec! cfg (into [sql] sql-args))]
|
||||
(adml/paginate rows limit)))
|
||||
|
||||
(def schema:get-file-params
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def ^:private sql:file
|
||||
(str "SELECT f.id, f.name, f.project_id, "
|
||||
"p.name AS project_name, "
|
||||
"p.team_id AS team_id, "
|
||||
"t.name AS team_name, "
|
||||
"f.created_at, f.modified_at, f.deleted_at "
|
||||
"FROM file AS f "
|
||||
"JOIN project AS p ON (p.id = f.project_id) "
|
||||
"JOIN team AS t ON (t.id = p.team_id) "
|
||||
"WHERE f.id = ?"))
|
||||
|
||||
(sv/defmethod ::get-file
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-file-params
|
||||
::sm/result schema:file-summary}
|
||||
[cfg {:keys [id]}]
|
||||
(or (some-> (db/exec-one! cfg [sql:file id])
|
||||
(d/without-nils))
|
||||
(ex/raise :type :not-found
|
||||
:code :file-not-found
|
||||
:hint (str "file " id " not found"))))
|
||||
|
||||
(def schema:restore-file-params
|
||||
[:map {:title "restore-file"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def schema:restore-file-result
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::restore-file
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:restore-file-params
|
||||
::sm/result schema:restore-file-result}
|
||||
[cfg {:keys [id]}]
|
||||
(let [row (db/get* cfg :file {:id id} {::db/remove-deleted false})]
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :file-not-found
|
||||
:hint (str "file " id " not found"))))
|
||||
;; NOTE: the file row and its project/team chain are restored
|
||||
;; inline so they read back instantly; the file rows cascade runs
|
||||
;; as a worker task. Refuses ownerless teams before writing
|
||||
;; anything. Profiles are never revived by a file restore.
|
||||
(cascade/restore-chain (::db/conn cfg) :file id)
|
||||
(db/update! cfg :file
|
||||
{:deleted-at nil
|
||||
:has-media-trimmed false}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(jobs/submit cfg
|
||||
{::jobs/name :restore-object
|
||||
::jobs/params {:object :file
|
||||
:id id}})
|
||||
{:id id})
|
||||
|
||||
(def schema:delete-file-params
|
||||
[:map {:title "delete-file"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def schema:delete-file-result
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::delete-file
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:delete-file-params
|
||||
::sm/result schema:delete-file-result}
|
||||
[cfg {:keys [id]}]
|
||||
(let [row (db/get* cfg :file {:id id} {::db/remove-deleted false})]
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :file-not-found
|
||||
:hint (str "file " id " not found")))
|
||||
(if (some? (:deleted-at row))
|
||||
;; NOTE: already deleted is a no-op success: the date stays
|
||||
;; untouched and no task is submitted.
|
||||
{:id id}
|
||||
(let [deleted-at (ct/minus (ct/now) (cf/get-deletion-delay))]
|
||||
;; NOTE: the file row is stamped inline so it reads back
|
||||
;; instantly; unsharing, the rows and the libraries cascade
|
||||
;; run as a worker task with the same date.
|
||||
(db/update! cfg :file
|
||||
{:deleted-at deleted-at}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(jobs/submit cfg
|
||||
{::jobs/name :delete-object
|
||||
::jobs/params {:object :file
|
||||
:deleted-at deleted-at
|
||||
:id id}})
|
||||
{:id id}))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; FILE TRANSFER (same behavior as the old `/dbg` handlers)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; NOTE: no `::sm/result` on these two on purpose: like the SSE
|
||||
;; commands, they return a raw response function instead of data
|
||||
;; (a download stream, a plain `"OK"`), so there is no transit/JSON
|
||||
;; shape to validate.
|
||||
|
||||
(def schema:export-files-params
|
||||
[:map {:title "export-files-params"}
|
||||
[:file-ids [:vector {:min 1} ::sm/uuid]]
|
||||
[:includelibs {:optional true} ::sm/boolean]
|
||||
[:clone {:optional true} ::sm/boolean]
|
||||
[:embedassets {:optional true} ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::export-files
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:export-files-params}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [file-ids includelibs clone embedassets
|
||||
::rpc/profile-id]}]
|
||||
(let [path (tmp/tempfile :prefix "penpot.export." :min-age "30m")]
|
||||
(with-open [output (io/output-stream path)]
|
||||
(-> cfg
|
||||
(assoc ::bfc/ids (set file-ids))
|
||||
(assoc ::bfc/embed-assets (boolean embedassets))
|
||||
(assoc ::bfc/include-libraries (boolean includelibs))
|
||||
(bf.v3/export-files! output)))
|
||||
|
||||
(if clone
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)
|
||||
team (teams/get-team pool
|
||||
:profile-id profile-id
|
||||
:project-id project-id)
|
||||
cfg (assoc cfg
|
||||
::bfc/overwrite false
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/team-id (:id team)
|
||||
::bfc/input path
|
||||
::bfc/import-max-binary-entry-size (cf/get :binfile-import-max-binary-entry-size)
|
||||
::bfc/import-max-text-entry-size (cf/get :binfile-import-max-text-entry-size)
|
||||
::bfc/import-max-text-total-size (cf/get :binfile-import-max-text-total-size)
|
||||
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
|
||||
(bf.v3/import-files! cfg)
|
||||
(fn [_request]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
::yres/body "OK CLONED"}))
|
||||
|
||||
(let [filename (str (first file-ids) ".penpot")]
|
||||
(fn [_request]
|
||||
{::yres/status 200
|
||||
::yres/body (io/input-stream path)
|
||||
::yres/headers {"content-type" "application/octet-stream"
|
||||
"content-disposition" (str "attachment; filename=" filename)}})))))
|
||||
|
||||
(def schema:import-files-params
|
||||
;; Mirrors what the multipart parser hands over: `:path` is a
|
||||
;; `java.nio.file.Path` object, not a string.
|
||||
[:map {:title "import-files-params"}
|
||||
[:file [:map
|
||||
[:path [:or ::sm/text [:fn #(instance? java.nio.file.Path %)]]]
|
||||
[:filename {:optional true} ::sm/text]
|
||||
[:size {:optional true} ::sm/int]]]])
|
||||
|
||||
(sv/defmethod ::import-files
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:import-files-params}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [file ::rpc/profile-id]}]
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)
|
||||
team (teams/get-team pool
|
||||
:profile-id profile-id
|
||||
:project-id project-id)]
|
||||
(when-not project-id
|
||||
(ex/raise :type :validation
|
||||
:code :missing-project
|
||||
:hint "project not found"))
|
||||
|
||||
(let [path (:path file)
|
||||
format (bfc/parse-file-format path)
|
||||
cfg (assoc cfg
|
||||
::bfc/profile-id profile-id
|
||||
::bfc/project-id project-id
|
||||
::bfc/input path
|
||||
::bfc/team-id (:id team)
|
||||
::bfc/features (cfeat/get-team-enabled-features cf/flags team)
|
||||
::bfc/import-max-binary-entry-size (cf/get :binfile-import-max-binary-entry-size)
|
||||
::bfc/import-max-text-entry-size (cf/get :binfile-import-max-text-entry-size)
|
||||
::bfc/import-max-text-total-size (cf/get :binfile-import-max-text-total-size)
|
||||
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
|
||||
|
||||
(if (= format :binfile-v3)
|
||||
(bf.v3/import-files! cfg)
|
||||
(bf.v1/import-files! cfg))
|
||||
|
||||
(fn [_request]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
::yres/body "OK"}))))
|
||||
258
backend/src/app/rpc/admin/job.clj
Normal file
258
backend/src/app/rpc/admin/job.clj
Normal file
@ -0,0 +1,258 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.rpc.admin.job
|
||||
"Admin job commands, served through `/api/admin/methods`.
|
||||
|
||||
Read-only inspection of the `job` table plus cancellation, for the
|
||||
Jobs section of the `/admin` panel. Access control lives in
|
||||
`wrap-authentication` (the `\"superuser\"` permission), not here,
|
||||
so a new command cannot forget it.
|
||||
|
||||
Cancellation reuses the substrate writer `app.jobs/cancel`, which
|
||||
moves pending and running jobs to `cancelled` with their `end`
|
||||
event in one transaction. A running job stops at its next
|
||||
heartbeat; terminal jobs are left untouched."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.schema :as-alias sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.jobs :as jobs]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.admin.list :as adml]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:private jobs-default-limit 50)
|
||||
(def ^:private jobs-max-limit 200)
|
||||
|
||||
(def schema:job-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:queue ::sm/text]
|
||||
[:label {:optional true} ::sm/text]
|
||||
[:status ::sm/text]
|
||||
[:kind ::sm/text]
|
||||
[:profile-id {:optional true} ::sm/uuid]
|
||||
[:retry-num ::sm/int]
|
||||
[:max-retries ::sm/int]
|
||||
[:scheduled-at ct/schema:inst]
|
||||
[:created-at ct/schema:inst]
|
||||
[:started-at {:optional true} ct/schema:inst]
|
||||
[:completed-at {:optional true} ct/schema:inst]])
|
||||
|
||||
(def schema:get-jobs-params
|
||||
[:map {:title "get-jobs-params"}
|
||||
[:since {:optional true} ct/schema:inst]
|
||||
[:since-id {:optional true} ::sm/uuid]
|
||||
[:limit {:optional true}
|
||||
[:and ::sm/int [:fn #(<= 1 % jobs-max-limit)]]]
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
[:name {:optional true} ::sm/text]
|
||||
[:status {:optional true} [:enum "new" "scheduled" "running" "retry"
|
||||
"completed" "failed" "cancelled" "aborted"]]])
|
||||
|
||||
(def schema:get-jobs-result
|
||||
[:map
|
||||
[:items [:vector schema:job-summary]]
|
||||
[:next-since {:optional true} ct/schema:inst]
|
||||
[:next-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(defn- summarize
|
||||
"Light list row: scalar columns only, never the business payload.
|
||||
`kind` is derived: a job without a profile is a system job."
|
||||
[row]
|
||||
(cond-> {:id (:id row)
|
||||
:name (:name row)
|
||||
:queue (:queue row)
|
||||
:status (:status row)
|
||||
:kind (if (some? (:profile-id row)) "user" "system")
|
||||
:retry-num (:retry-num row)
|
||||
:max-retries (:max-retries row)
|
||||
:scheduled-at (:scheduled-at row)
|
||||
:created-at (:created-at row)}
|
||||
(some? (:label row)) (assoc :label (:label row))
|
||||
(some? (:profile-id row)) (assoc :profile-id (:profile-id row))
|
||||
(some? (:started-at row)) (assoc :started-at (:started-at row))
|
||||
(some? (:completed-at row)) (assoc :completed-at (:completed-at row))))
|
||||
|
||||
(defn- build-jobs-list-query
|
||||
"List jobs of this instance newest-first with keyset pagination.
|
||||
|
||||
Lookup is by exact `id` only, filters by exact `name` and `status`;
|
||||
see `app.rpc.admin.list` for the shared list contract. Served by
|
||||
`job__admin_list__idx` (migration 0158)."
|
||||
[tenant {:keys [since since-id id name status limit]
|
||||
:or {limit jobs-default-limit}}]
|
||||
(let [clauses (keep identity
|
||||
[{:where "j.tenant = ?"
|
||||
:params [tenant]}
|
||||
(when id
|
||||
{:where "j.id = ?"
|
||||
:params [id]})
|
||||
(when name
|
||||
{:where "j.name = ?"
|
||||
:params [name]})
|
||||
(when status
|
||||
{:where "j.status = ?"
|
||||
:params [status]})
|
||||
(adml/since-clause "j.created_at" "j.id" since since-id)])
|
||||
sql-parts (map :where clauses)
|
||||
sql-params (mapcat :params clauses)
|
||||
sql (str "SELECT j.id, j.name, j.queue, j.label, j.status, "
|
||||
"j.profile_id, j.retry_num, j.max_retries, "
|
||||
"j.scheduled_at, j.created_at, "
|
||||
"j.started_at, j.completed_at "
|
||||
"FROM job AS j "
|
||||
"WHERE " (str/join " AND " sql-parts) " "
|
||||
"ORDER BY j.created_at DESC, j.id DESC "
|
||||
"LIMIT ?")]
|
||||
(into [sql] (concat sql-params [limit]))))
|
||||
|
||||
(sv/defmethod ::get-jobs
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-jobs-params
|
||||
::sm/result schema:get-jobs-result}
|
||||
[cfg params]
|
||||
(let [[limit params] (adml/with-fetch-limit params jobs-default-limit jobs-max-limit)
|
||||
[sql & sql-args] (build-jobs-list-query (cf/get :tenant) params)
|
||||
rows (db/exec! cfg (into [sql] sql-args))]
|
||||
(update (adml/paginate rows limit) :items #(mapv summarize %))))
|
||||
|
||||
(def schema:job-event
|
||||
[:map
|
||||
[:kind ::sm/text]
|
||||
[:payload :any]
|
||||
[:created-at ct/schema:inst]])
|
||||
|
||||
(def schema:job-detail
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:queue ::sm/text]
|
||||
[:label {:optional true} ::sm/text]
|
||||
[:status ::sm/text]
|
||||
[:kind ::sm/text]
|
||||
[:profile-id {:optional true} ::sm/uuid]
|
||||
[:owner-email {:optional true} ::sm/text]
|
||||
[:owner-fullname {:optional true} ::sm/text]
|
||||
[:tenant ::sm/text]
|
||||
[:priority ::sm/int]
|
||||
[:retry-num ::sm/int]
|
||||
[:max-retries ::sm/int]
|
||||
[:scheduled-at ct/schema:inst]
|
||||
[:created-at ct/schema:inst]
|
||||
[:modified-at ct/schema:inst]
|
||||
[:started-at {:optional true} ct/schema:inst]
|
||||
[:completed-at {:optional true} ct/schema:inst]
|
||||
[:expires-at {:optional true} ct/schema:inst]
|
||||
[:resource-id {:optional true} ::sm/uuid]
|
||||
[:error {:optional true} :any]
|
||||
[:result {:optional true} :any]
|
||||
[:params-pretty ::sm/text]
|
||||
[:events [:vector schema:job-event]]])
|
||||
|
||||
(def ^:private params-pretty-cap
|
||||
"Hard cap for the pretty-printed params: depth/length bounds keep
|
||||
most payloads small, and this stops a pathological one from
|
||||
flooding the detail view."
|
||||
4000)
|
||||
|
||||
(defn- pretty-params
|
||||
[params]
|
||||
(let [text (pp/pprint-str params {:width 100 :level 5 :length 20})]
|
||||
(if (> (count text) params-pretty-cap)
|
||||
(str (subs text 0 params-pretty-cap) "… (truncated)")
|
||||
text)))
|
||||
|
||||
(defn- get-live-job
|
||||
"Fetch a job by id. Missing rows are `:job-not-found`: the panel
|
||||
links by id, and a GC-swept row must read as gone, not empty."
|
||||
[cfg id]
|
||||
(or (db/get* cfg :job {:id id})
|
||||
(ex/raise :type :not-found
|
||||
:code :job-not-found
|
||||
:hint (str "job " id " not found"))))
|
||||
|
||||
(defn- get-job-events
|
||||
"Append-only history, oldest first: insertion order is the truth,
|
||||
`created_at` alone could tie on fast transitions."
|
||||
[cfg job-id]
|
||||
(->> (db/exec! cfg ["SELECT kind, payload, created_at
|
||||
FROM job_event WHERE job_id = ?
|
||||
ORDER BY id" job-id])
|
||||
(mapv (fn [row]
|
||||
{:kind (:kind row)
|
||||
:payload (db/decode-json-pgobject (:payload row))
|
||||
:created-at (:created-at row)}))))
|
||||
|
||||
(def schema:get-job-params
|
||||
[:map {:title "get-job-params"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def schema:cancel-job-params
|
||||
[:map {:title "cancel-job-params"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def schema:cancel-job-result
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:cancelled ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::cancel-job
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:cancel-job-params
|
||||
::sm/result schema:cancel-job-result}
|
||||
[cfg {:keys [id]}]
|
||||
;; existence first: a GC-swept id reads as gone, while a job the
|
||||
;; runner just finished reads as a no-op success, never an error
|
||||
(get-live-job cfg id)
|
||||
{:id id
|
||||
:cancelled (pos? (jobs/cancel cfg id))})
|
||||
|
||||
(sv/defmethod ::get-job
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-job-params
|
||||
::sm/result schema:job-detail}
|
||||
[cfg {:keys [id]}]
|
||||
(let [job (get-live-job cfg id)
|
||||
owner (when-let [profile-id (:profile-id job)]
|
||||
(db/get* cfg :profile {:id profile-id}))
|
||||
detail (cond-> {:id (:id job)
|
||||
:name (:name job)
|
||||
:queue (:queue job)
|
||||
:status (:status job)
|
||||
:kind (if (some? (:profile-id job)) "user" "system")
|
||||
:tenant (:tenant job)
|
||||
:priority (:priority job)
|
||||
:retry-num (:retry-num job)
|
||||
:max-retries (:max-retries job)
|
||||
:scheduled-at (:scheduled-at job)
|
||||
:created-at (:created-at job)
|
||||
:modified-at (:modified-at job)
|
||||
:params-pretty (pretty-params
|
||||
(db/decode-json-pgobject (:params job)))
|
||||
:events (get-job-events cfg id)}
|
||||
(some? (:label job)) (assoc :label (:label job))
|
||||
(some? (:profile-id job)) (assoc :profile-id (:profile-id job))
|
||||
(some? owner) (assoc :owner-email (:email owner)
|
||||
:owner-fullname (:fullname owner))
|
||||
(some? (:started-at job)) (assoc :started-at (:started-at job))
|
||||
(some? (:completed-at job)) (assoc :completed-at (:completed-at job))
|
||||
(some? (:expires-at job)) (assoc :expires-at (:expires-at job))
|
||||
(some? (:resource-id job)) (assoc :resource-id (:resource-id job))
|
||||
(some? (:error job)) (assoc :error (jobs/decode-job-error (:error job)))
|
||||
(some? (:result job)) (assoc :result (db/decode-json-pgobject (:result job))))]
|
||||
(d/without-nils detail)))
|
||||
62
backend/src/app/rpc/admin/list.clj
Normal file
62
backend/src/app/rpc/admin/list.clj
Normal file
@ -0,0 +1,62 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.rpc.admin.list
|
||||
"Shared building blocks for the admin list commands (`get-profiles`,
|
||||
`get-teams`, `get-projects`, `get-files`).
|
||||
|
||||
Every list shares one contract: newest-first order
|
||||
(`created_at DESC, id DESC`) with keyset pagination, lookup by exact
|
||||
indexed fields only (`id`, plus exact `email` for profiles), and a
|
||||
tri-state `:deleted` filter (absent lists everything, `true` only
|
||||
deleted rows, `false` only live rows). Substring search has no usable
|
||||
index at PRO scale, so it is not offered."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.uuid :as uuid]))
|
||||
|
||||
(defn deleted-clause
|
||||
"WHERE fragment for the tri-state `:deleted` filter on `col`
|
||||
(a qualified `deleted_at` column). Returns nil when the filter is
|
||||
absent, so it can sit inside a `(keep identity …)` clause vector."
|
||||
[col deleted]
|
||||
(when (some? deleted)
|
||||
(if deleted
|
||||
{:where (str col " IS NOT NULL")
|
||||
:params []}
|
||||
{:where (str col " IS NULL")
|
||||
:params []})))
|
||||
|
||||
(defn since-clause
|
||||
"Keyset cursor fragment: rows older than `(since, since-id)` on the
|
||||
`(created-col, id-col)` pair. Returns nil when `since` is absent, so
|
||||
it can sit inside a `(keep identity …)` clause vector."
|
||||
[created-col id-col since since-id]
|
||||
(when since
|
||||
{:where (str "(" created-col ", " id-col ") < (?::timestamptz, ?::uuid)")
|
||||
:params [since (or since-id uuid/zero)]}))
|
||||
|
||||
(defn with-fetch-limit
|
||||
"Clamp the requested `:limit` between the command default and max,
|
||||
and bump it by one so the caller can tell whether another page
|
||||
exists. Returns `[limit params]`."
|
||||
[params default-limit max-limit]
|
||||
(let [limit (min (or (:limit params) default-limit) max-limit)]
|
||||
[limit (assoc params :limit (inc limit))]))
|
||||
|
||||
(defn paginate
|
||||
"Slice a limit+1 `rows` vector into the list result shape. The cursor
|
||||
advances on `:created-at`, matching the shared newest-first order."
|
||||
[rows limit]
|
||||
(if (seq rows)
|
||||
(let [items (->> (take limit rows)
|
||||
(mapv d/without-nils))
|
||||
last-item (peek items)
|
||||
has-more? (> (count rows) limit)]
|
||||
{:items items
|
||||
:next-since (when has-more? (:created-at last-item))
|
||||
:next-id (when has-more? (:id last-item))})
|
||||
{:items []}))
|
||||
72
backend/src/app/rpc/admin/misc.clj
Normal file
72
backend/src/app/rpc/admin/misc.clj
Normal file
@ -0,0 +1,72 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.rpc.admin.misc
|
||||
"Admin miscellaneous commands, served through `/api/admin/methods`.
|
||||
|
||||
Small diagnostic tools that do not deserve their own namespace:
|
||||
currently just the virtual clock. Access control lives in
|
||||
`wrap-authentication` (the `\"superuser\"` permission), not here."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as-alias sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.setup.clock :as clock]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Virtual clock
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(def schema:get-virtual-clock-result
|
||||
[:map
|
||||
[:offset-millis {:optional true} ::sm/int]
|
||||
[:now ct/schema:inst]
|
||||
[:clock ::sm/text]])
|
||||
|
||||
(sv/defmethod ::get-virtual-clock
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params [:map]
|
||||
::sm/result schema:get-virtual-clock-result}
|
||||
[_ {:keys [::rpc/profile-id]}]
|
||||
(let [offset (clock/get-offset profile-id)]
|
||||
{:offset-millis (some-> offset (.toMillis))
|
||||
:now (ct/now)
|
||||
:clock (if offset "offset" "system")}))
|
||||
|
||||
(def schema:set-virtual-clock-params
|
||||
[:map {:title "set-virtual-clock-params"}
|
||||
[:offset {:optional true} ct/schema:duration]
|
||||
[:reset {:optional true} ::sm/boolean]])
|
||||
|
||||
(sv/defmethod ::set-virtual-clock
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:set-virtual-clock-params
|
||||
::sm/result schema:get-virtual-clock-result}
|
||||
[_ {:keys [::rpc/profile-id offset reset]}]
|
||||
(when (= "production" (cf/get :tenant))
|
||||
(ex/raise :type :restriction
|
||||
:code :operation-not-allowed
|
||||
:hint "virtual clock cannot be changed in production"))
|
||||
(when (and (nil? offset) (not reset))
|
||||
(ex/raise :type :validation
|
||||
:code :missing-arguments
|
||||
:hint "either offset or reset is required"))
|
||||
;; Mirror `/dbg`: a reset (or a zero duration) clears the offset.
|
||||
;; The raw millis go out untouched: the response encoder would
|
||||
;; re-format an already formatted string.
|
||||
(if (or reset (and (some? offset) (.isZero ^java.time.Duration offset)))
|
||||
(clock/assign-offset profile-id nil)
|
||||
(clock/assign-offset profile-id offset))
|
||||
(let [current (clock/get-offset profile-id)]
|
||||
{:offset-millis (some-> current (.toMillis))
|
||||
:now (ct/now)
|
||||
:clock (if current "offset" "system")}))
|
||||
331
backend/src/app/rpc/admin/profile.clj
Normal file
331
backend/src/app/rpc/admin/profile.clj
Normal file
@ -0,0 +1,331 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.rpc.admin.profile
|
||||
"Admin profile commands, served through `/api/admin/methods`.
|
||||
|
||||
Access control lives in `wrap-authentication` (the `\"superuser\"`
|
||||
permission), not here, so a new command cannot forget it."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as-alias sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.session :as session]
|
||||
[app.jobs :as jobs]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.admin.list :as adml]
|
||||
[app.rpc.commands.auth :as cmd.auth]
|
||||
[app.rpc.commands.profile :as cmd.profile]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.tasks.restore-object]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:private profiles-default-limit 50)
|
||||
(def ^:private profiles-max-limit 200)
|
||||
|
||||
(def schema:profile-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:email ::sm/text]
|
||||
[:fullname ::sm/text]
|
||||
[:created-at ct/schema:inst]
|
||||
[:modified-at ct/schema:inst]
|
||||
[:is-active ::sm/boolean]
|
||||
[:is-blocked ::sm/boolean]
|
||||
[:is-demo ::sm/boolean]
|
||||
[:deleted-at {:optional true} ct/schema:inst]
|
||||
[:auth-backend {:optional true} ::sm/text]])
|
||||
|
||||
(def schema:get-profiles-params
|
||||
[:map {:title "get-profiles-params"}
|
||||
[:since {:optional true} ct/schema:inst]
|
||||
[:since-id {:optional true} ::sm/uuid]
|
||||
[:limit {:optional true}
|
||||
[:and ::sm/int [:fn #(<= 1 % profiles-max-limit)]]]
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
[:email {:optional true} ::sm/text]
|
||||
[:deleted {:optional true} ::sm/boolean]])
|
||||
|
||||
(def schema:get-profiles-result
|
||||
[:map
|
||||
[:items [:vector schema:profile-summary]]
|
||||
[:next-since {:optional true} ct/schema:inst]
|
||||
[:next-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(defn- build-profiles-list-query
|
||||
"List profiles newest-first with keyset pagination.
|
||||
|
||||
Lookup is by exact indexed fields only (`id`, exact `email`); see
|
||||
`app.rpc.admin.list` for the shared list contract."
|
||||
[{:keys [since since-id id email deleted limit]
|
||||
:or {limit profiles-default-limit}}]
|
||||
(let [clauses (keep identity
|
||||
[(when id
|
||||
{:where "id = ?"
|
||||
:params [id]})
|
||||
(when (and (string? email) (not (str/blank? email)))
|
||||
{:where "email = ?"
|
||||
:params [(str/lower (str/trim email))]})
|
||||
(adml/deleted-clause "deleted_at" deleted)
|
||||
(adml/since-clause "created_at" "id" since since-id)])
|
||||
sql-parts (map :where clauses)
|
||||
sql-params (mapcat :params clauses)
|
||||
sql (str "SELECT id, email, fullname, created_at, modified_at, "
|
||||
"is_active, COALESCE(is_blocked, false) AS is_blocked, "
|
||||
"is_demo, auth_backend, deleted_at "
|
||||
"FROM profile "
|
||||
(when (seq sql-parts)
|
||||
(str "WHERE " (str/join " AND " sql-parts) " "))
|
||||
"ORDER BY created_at DESC, id DESC "
|
||||
"LIMIT ?")]
|
||||
(into [sql] (concat sql-params [limit]))))
|
||||
|
||||
(sv/defmethod ::get-profiles
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-profiles-params
|
||||
::sm/result schema:get-profiles-result}
|
||||
[cfg params]
|
||||
(let [[limit params] (adml/with-fetch-limit params profiles-default-limit profiles-max-limit)
|
||||
[sql & sql-args] (build-profiles-list-query params)
|
||||
rows (db/exec! cfg (into [sql] sql-args))]
|
||||
(adml/paginate rows limit)))
|
||||
|
||||
(def schema:owned-team
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:members ::sm/int]
|
||||
[:deleted-at {:optional true} ::sm/inst]])
|
||||
|
||||
(def schema:member-team
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:is-owner ::sm/boolean]
|
||||
[:is-admin ::sm/boolean]
|
||||
[:deleted-at {:optional true} ::sm/inst]])
|
||||
|
||||
(def schema:profile
|
||||
[:merge schema:profile-summary
|
||||
[:map
|
||||
[:is-muted ::sm/boolean]
|
||||
[:owned-teams [:vector schema:owned-team]]
|
||||
[:member-teams [:vector schema:member-team]]]])
|
||||
|
||||
(def schema:get-profile-params
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def ^:private sql:owned-teams
|
||||
(str "SELECT t.id, t.name, t.deleted_at, "
|
||||
"(SELECT count(*) FROM team_profile_rel WHERE team_id = t.id) AS members "
|
||||
"FROM team AS t "
|
||||
"JOIN team_profile_rel AS tp ON (tp.team_id = t.id) "
|
||||
"WHERE tp.profile_id = ? AND tp.is_owner IS true "
|
||||
"ORDER BY t.name ASC"))
|
||||
|
||||
(def ^:private sql:member-teams
|
||||
(str "SELECT t.id, t.name, t.deleted_at, "
|
||||
"COALESCE(tp.is_owner, false) AS is_owner, "
|
||||
"COALESCE(tp.is_admin, false) AS is_admin "
|
||||
"FROM team AS t "
|
||||
"JOIN team_profile_rel AS tp ON (tp.team_id = t.id) "
|
||||
"WHERE tp.profile_id = ? AND NOT COALESCE(tp.is_owner, false) "
|
||||
"ORDER BY t.name ASC"))
|
||||
|
||||
(sv/defmethod ::get-profile
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-profile-params
|
||||
::sm/result schema:profile}
|
||||
[cfg {:keys [id]}]
|
||||
(if-let [row (db/get-by-id cfg :profile id {::db/check-deleted false
|
||||
::db/remove-deleted false})]
|
||||
(d/without-nils
|
||||
{:id (:id row)
|
||||
:email (:email row)
|
||||
:fullname (:fullname row)
|
||||
:created-at (:created-at row)
|
||||
:modified-at (:modified-at row)
|
||||
:is-active (boolean (:is-active row))
|
||||
:is-blocked (boolean (:is-blocked row))
|
||||
:is-demo (boolean (:is-demo row))
|
||||
:is-muted (boolean (:is-muted row))
|
||||
:auth-backend (:auth-backend row)
|
||||
:deleted-at (:deleted-at row)
|
||||
:owned-teams (db/exec! cfg [sql:owned-teams id])
|
||||
:member-teams (db/exec! cfg [sql:member-teams id])})
|
||||
(ex/raise :type :not-found
|
||||
:code :profile-not-found
|
||||
:hint (str "profile " id " not found"))))
|
||||
|
||||
(def schema:block-result
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:is-blocked ::sm/boolean]])
|
||||
|
||||
(defn- get-live-profile
|
||||
"Fetch a profile that is neither missing nor marked for deletion.
|
||||
Reads use `get-profile` (which returns deleted rows with
|
||||
their stamp); writes go through here."
|
||||
[cfg id]
|
||||
(let [row (db/get-by-id cfg :profile id {::db/check-deleted false
|
||||
::db/remove-deleted false})]
|
||||
(if (and row (nil? (:deleted-at row)))
|
||||
row
|
||||
(ex/raise :type :not-found
|
||||
:code :profile-not-found
|
||||
:hint (str "profile " id " not found")))))
|
||||
|
||||
(defn- set-blocked-flag
|
||||
[cfg id blocked?]
|
||||
(get-live-profile cfg id)
|
||||
(db/update! cfg :profile {:is-blocked blocked?} {:id id})
|
||||
(when blocked?
|
||||
(session/invalidate-all cfg id))
|
||||
{:id id :is-blocked blocked?})
|
||||
|
||||
(sv/defmethod ::block-profile
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:get-profile-params
|
||||
::sm/result schema:block-result}
|
||||
[cfg {:keys [id ::rpc/profile-id]}]
|
||||
(when (= id profile-id)
|
||||
(ex/raise :type :validation
|
||||
:code :cannot-block-self
|
||||
:hint "a superuser cannot block its own profile"))
|
||||
(set-blocked-flag cfg id true))
|
||||
|
||||
(sv/defmethod ::unblock-profile
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:get-profile-params
|
||||
::sm/result schema:block-result}
|
||||
[cfg {:keys [id]}]
|
||||
(set-blocked-flag cfg id false))
|
||||
|
||||
(def schema:resend-result
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:email ::sm/text]])
|
||||
|
||||
(sv/defmethod ::resend-verification
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:get-profile-params
|
||||
::sm/result schema:resend-result}
|
||||
[cfg {:keys [id]}]
|
||||
(let [row (get-live-profile cfg id)]
|
||||
(if (:is-active row)
|
||||
(ex/raise :type :validation
|
||||
:code :already-active
|
||||
:hint "the profile is already active")
|
||||
(do
|
||||
(cmd.auth/send-email-verification! cfg row)
|
||||
{:id (:id row) :email (:email row)}))))
|
||||
|
||||
(def schema:delete-profiles-params
|
||||
[:map {:title "delete-profiles-params"}
|
||||
[:emails [:vector {:min 1 :max 100} ::sm/email]]])
|
||||
|
||||
(def schema:delete-profiles-result
|
||||
[:map
|
||||
[:total ::sm/int]
|
||||
[:deleted [:vector ::sm/uuid]]
|
||||
[:not-found [:vector ::sm/text]]
|
||||
[:skipped-self [:vector ::sm/text]]])
|
||||
|
||||
(defn- delete-profile-by-email
|
||||
[cfg email deleted-at cause operator-id]
|
||||
(let [email (str/lower (str/trim email))]
|
||||
(if-let [profile (some-> (db/get* cfg :profile {:email email})
|
||||
(cmd.profile/decode-row))]
|
||||
(if (= (:id profile) operator-id)
|
||||
:self
|
||||
(do
|
||||
(audit/insert cfg
|
||||
{:name "delete-profile"
|
||||
:type "action"
|
||||
:profile-id (:id profile)
|
||||
:tracked-at deleted-at
|
||||
:props (audit/profile->props profile)
|
||||
:context {:triggered-by "admin-panel"
|
||||
:cause cause
|
||||
:operator-id operator-id}})
|
||||
(db/update! cfg :profile
|
||||
{:deleted-at deleted-at}
|
||||
{:id (:id profile)})
|
||||
(session/invalidate-all cfg (:id profile))
|
||||
(jobs/submit cfg
|
||||
{::jobs/name :delete-object
|
||||
::jobs/params {:object :profile
|
||||
:deleted-at deleted-at
|
||||
:id (:id profile)}})
|
||||
(:id profile)))
|
||||
nil)))
|
||||
|
||||
(sv/defmethod ::delete-profiles
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:delete-profiles-params
|
||||
::sm/result schema:delete-profiles-result}
|
||||
[cfg {:keys [emails ::rpc/profile-id]}]
|
||||
(let [deleted-at (ct/minus (ct/now) (cf/get-deletion-delay))
|
||||
cause "explicit call to delete-profiles"]
|
||||
(reduce (fn [acc raw-email]
|
||||
(let [email (str/lower (str/trim raw-email))
|
||||
result (delete-profile-by-email
|
||||
cfg email deleted-at cause profile-id)]
|
||||
(cond
|
||||
(= :self result) (update acc :skipped-self conj email)
|
||||
(nil? result) (update acc :not-found conj email)
|
||||
:else (update acc :deleted conj result))))
|
||||
{:total (count emails) :deleted [] :not-found [] :skipped-self []}
|
||||
emails)))
|
||||
|
||||
(def schema:restore-profile-params
|
||||
[:map {:title "restore-profile"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def schema:restore-profile-result
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::restore-profile
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:restore-profile-params
|
||||
::sm/result schema:restore-profile-result}
|
||||
[cfg {:keys [id]}]
|
||||
(let [row (db/get-by-id cfg :profile id {::db/check-deleted false
|
||||
::db/remove-deleted false})]
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :profile-not-found
|
||||
:hint (str "profile " id " not found"))))
|
||||
;; NOTE: restoring the profile gives its owned teams a live owner
|
||||
;; back; the teams cascade runs as a worker task.
|
||||
(db/update! cfg :profile
|
||||
{:deleted-at nil}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(jobs/submit cfg
|
||||
{::jobs/name :restore-object
|
||||
::jobs/params {:object :profile
|
||||
:id id}})
|
||||
{:id id})
|
||||
208
backend/src/app/rpc/admin/project.clj
Normal file
208
backend/src/app/rpc/admin/project.clj
Normal file
@ -0,0 +1,208 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.rpc.admin.project
|
||||
"Admin project commands, served through `/api/admin/methods`.
|
||||
|
||||
Access control lives in `wrap-authentication` (the `\"superuser\"`
|
||||
permission), not here, so a new command cannot forget it."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as-alias sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.features.object-cascade :as cascade]
|
||||
[app.jobs :as jobs]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.admin.list :as adml]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.tasks.restore-object]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:private projects-default-limit 50)
|
||||
(def ^:private projects-max-limit 200)
|
||||
|
||||
(def schema:project-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:team-id ::sm/uuid]
|
||||
[:team-name ::sm/text]
|
||||
[:is-default ::sm/boolean]
|
||||
[:total-files ::sm/int]
|
||||
[:created-at ct/schema:inst]
|
||||
[:modified-at ct/schema:inst]
|
||||
[:deleted-at {:optional true} ct/schema:inst]])
|
||||
|
||||
(def schema:get-projects-params
|
||||
[:map {:title "get-projects-params"}
|
||||
[:since {:optional true} ct/schema:inst]
|
||||
[:since-id {:optional true} ::sm/uuid]
|
||||
[:limit {:optional true}
|
||||
[:and ::sm/int [:fn #(<= 1 % projects-max-limit)]]]
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
[:team-id {:optional true} ::sm/uuid]
|
||||
[:deleted {:optional true} ::sm/boolean]])
|
||||
|
||||
(def schema:get-projects-result
|
||||
[:map
|
||||
[:items [:vector schema:project-summary]]
|
||||
[:next-since {:optional true} ct/schema:inst]
|
||||
[:next-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(defn- build-projects-list-query
|
||||
"List projects newest-first with keyset pagination.
|
||||
|
||||
Lookup is by exact `id` only; see `app.rpc.admin.list` for the
|
||||
shared list contract. The parent-team guard stays regardless."
|
||||
[{:keys [since since-id id team-id deleted limit]
|
||||
:or {limit projects-default-limit}}]
|
||||
(let [clauses (keep identity
|
||||
[(when id
|
||||
{:where "p.id = ?"
|
||||
:params [id]})
|
||||
(when team-id
|
||||
{:where "p.team_id = ?"
|
||||
:params [team-id]})
|
||||
(adml/deleted-clause "p.deleted_at" deleted)
|
||||
(adml/since-clause "p.created_at" "p.id" since since-id)])
|
||||
sql-parts (map :where clauses)
|
||||
sql-params (mapcat :params clauses)
|
||||
sql (str "SELECT p.id, p.name, p.team_id, "
|
||||
"t.name AS team_name, "
|
||||
"p.is_default, "
|
||||
"(SELECT count(*) FROM file AS f "
|
||||
"WHERE f.project_id = p.id "
|
||||
"AND f.deleted_at IS NULL) AS total_files, "
|
||||
"p.created_at, p.modified_at, p.deleted_at "
|
||||
"FROM project AS p "
|
||||
"JOIN team AS t ON (t.id = p.team_id) "
|
||||
"WHERE t.deleted_at IS NULL "
|
||||
(when (seq sql-parts)
|
||||
(str "AND " (str/join " AND " sql-parts) " "))
|
||||
"ORDER BY p.created_at DESC, p.id DESC "
|
||||
"LIMIT ?")]
|
||||
(into [sql] (concat sql-params [limit]))))
|
||||
|
||||
(sv/defmethod ::get-projects
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-projects-params
|
||||
::sm/result schema:get-projects-result}
|
||||
[cfg params]
|
||||
(let [[limit params] (adml/with-fetch-limit params projects-default-limit projects-max-limit)
|
||||
[sql & sql-args] (build-projects-list-query params)
|
||||
rows (db/exec! cfg (into [sql] sql-args))]
|
||||
(adml/paginate rows limit)))
|
||||
|
||||
(def schema:get-project-params
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def ^:private sql:project
|
||||
(str "SELECT p.id, p.name, p.team_id, "
|
||||
"t.name AS team_name, "
|
||||
"p.is_default, "
|
||||
"(SELECT count(*) FROM file AS f "
|
||||
"WHERE f.project_id = p.id "
|
||||
"AND f.deleted_at IS NULL) AS total_files, "
|
||||
"p.created_at, p.modified_at, p.deleted_at "
|
||||
"FROM project AS p "
|
||||
"JOIN team AS t ON (t.id = p.team_id) "
|
||||
"WHERE p.id = ?"))
|
||||
|
||||
(sv/defmethod ::get-project
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-project-params
|
||||
::sm/result schema:project-summary}
|
||||
[cfg {:keys [id]}]
|
||||
(or (some-> (db/exec-one! cfg [sql:project id])
|
||||
(d/without-nils))
|
||||
(ex/raise :type :not-found
|
||||
:code :project-not-found
|
||||
:hint (str "project " id " not found"))))
|
||||
|
||||
(def schema:restore-project-params
|
||||
[:map {:title "restore-project"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def schema:restore-project-result
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::restore-project
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:restore-project-params
|
||||
::sm/result schema:restore-project-result}
|
||||
[cfg {:keys [id]}]
|
||||
(let [row (db/get* cfg :project {:id id} {::db/remove-deleted false})]
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :project-not-found
|
||||
:hint (str "project " id " not found"))))
|
||||
;; NOTE: the project row and its team chain are restored inline so
|
||||
;; they read back instantly; the files cascade runs as a worker
|
||||
;; task. Refuses ownerless teams before writing anything.
|
||||
(cascade/restore-chain (::db/conn cfg) :project id)
|
||||
(db/update! cfg :project
|
||||
{:deleted-at nil}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(jobs/submit cfg
|
||||
{::jobs/name :restore-object
|
||||
::jobs/params {:object :project
|
||||
:id id}})
|
||||
{:id id})
|
||||
|
||||
(def schema:delete-project-params
|
||||
[:map {:title "delete-project"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def schema:delete-project-result
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::delete-project
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:delete-project-params
|
||||
::sm/result schema:delete-project-result}
|
||||
[cfg {:keys [id]}]
|
||||
(let [row (db/get* cfg :project {:id id} {::db/remove-deleted false})]
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :project-not-found
|
||||
:hint (str "project " id " not found")))
|
||||
(if (some? (:deleted-at row))
|
||||
;; NOTE: already deleted is a no-op success: the date stays
|
||||
;; untouched and no task is submitted.
|
||||
{:id id}
|
||||
(do
|
||||
(when (:is-default row)
|
||||
(ex/raise :type :validation
|
||||
:code :non-deletable-project
|
||||
:hint "impossible to delete default project"))
|
||||
(let [deleted-at (ct/minus (ct/now) (cf/get-deletion-delay))]
|
||||
;; NOTE: the project row is stamped inline so it reads back
|
||||
;; instantly; the files cascade runs as a worker task with
|
||||
;; the same date.
|
||||
(db/update! cfg :project
|
||||
{:deleted-at deleted-at}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(jobs/submit cfg
|
||||
{::jobs/name :delete-object
|
||||
::jobs/params {:object :project
|
||||
:deleted-at deleted-at
|
||||
:id id}})
|
||||
{:id id})))))
|
||||
369
backend/src/app/rpc/admin/team.clj
Normal file
369
backend/src/app/rpc/admin/team.clj
Normal file
@ -0,0 +1,369 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.rpc.admin.team
|
||||
"Admin team commands, served through `/api/admin/methods`.
|
||||
|
||||
Access control lives in `wrap-authentication` (the `\"superuser\"`
|
||||
permission), not here, so a new command cannot forget it."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.schema :as-alias sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.team :as types.team]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.features.object-cascade :as cascade]
|
||||
[app.jobs :as jobs]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.admin.list :as adml]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.tasks.restore-object]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:private teams-default-limit 50)
|
||||
(def ^:private teams-max-limit 200)
|
||||
|
||||
(def schema:team-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name ::sm/text]
|
||||
[:created-at ct/schema:inst]
|
||||
[:modified-at ct/schema:inst]
|
||||
[:is-default ::sm/boolean]
|
||||
[:total-members ::sm/int]
|
||||
[:deleted-at {:optional true} ct/schema:inst]
|
||||
[:owner {:optional true} ::sm/text]])
|
||||
|
||||
(def schema:get-teams-params
|
||||
[:map {:title "get-teams-params"}
|
||||
[:since {:optional true} ct/schema:inst]
|
||||
[:since-id {:optional true} ::sm/uuid]
|
||||
[:limit {:optional true}
|
||||
[:and ::sm/int [:fn #(<= 1 % teams-max-limit)]]]
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
[:deleted {:optional true} ::sm/boolean]])
|
||||
|
||||
(def schema:get-teams-result
|
||||
[:map
|
||||
[:items [:vector schema:team-summary]]
|
||||
[:next-since {:optional true} ct/schema:inst]
|
||||
[:next-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(defn- build-teams-list-query
|
||||
"List teams newest-first with keyset pagination.
|
||||
|
||||
Lookup is by exact `id` only; see `app.rpc.admin.list` for the
|
||||
shared list contract."
|
||||
[{:keys [since since-id id deleted limit]
|
||||
:or {limit teams-default-limit}}]
|
||||
(let [clauses (keep identity
|
||||
[(when id
|
||||
{:where "t.id = ?"
|
||||
:params [id]})
|
||||
(adml/deleted-clause "t.deleted_at" deleted)
|
||||
(adml/since-clause "t.created_at" "t.id" since since-id)])
|
||||
sql-parts (map :where clauses)
|
||||
sql-params (mapcat :params clauses)
|
||||
sql (str "SELECT t.id, t.name, t.created_at, t.modified_at, t.is_default, t.deleted_at, "
|
||||
;; Two correlated subqueries per row (member count
|
||||
;; plus owner email). Fine at admin page sizes; if
|
||||
;; this list ever gets slow at PRO scale, measure
|
||||
;; first — the fix is a join here, not another
|
||||
;; created_at index.
|
||||
"(SELECT count(*) FROM team_profile_rel WHERE team_id = t.id) AS total_members, "
|
||||
"(SELECT p.email FROM team_profile_rel AS tpr "
|
||||
"JOIN profile AS p ON (p.id = tpr.profile_id) "
|
||||
"WHERE tpr.team_id = t.id AND tpr.is_owner IS true "
|
||||
"ORDER BY p.email ASC LIMIT 1) AS owner "
|
||||
"FROM team AS t "
|
||||
(when (seq sql-parts)
|
||||
(str "WHERE " (str/join " AND " sql-parts) " "))
|
||||
"ORDER BY t.created_at DESC, t.id DESC "
|
||||
"LIMIT ?")]
|
||||
(into [sql] (concat sql-params [limit]))))
|
||||
|
||||
(sv/defmethod ::get-teams
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-teams-params
|
||||
::sm/result schema:get-teams-result}
|
||||
[cfg params]
|
||||
(let [[limit params] (adml/with-fetch-limit params teams-default-limit teams-max-limit)
|
||||
[sql & sql-args] (build-teams-list-query params)
|
||||
rows (db/exec! cfg (into [sql] sql-args))]
|
||||
(adml/paginate rows limit)))
|
||||
|
||||
(def schema:team
|
||||
[:merge schema:team-summary
|
||||
[:map
|
||||
[:features [:vector ::sm/text]]
|
||||
[:effective-features [:vector ::sm/text]]
|
||||
[:total-projects ::sm/int]]])
|
||||
|
||||
(def schema:get-team-params
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(defn- get-live-team
|
||||
"Fetch a team that is not missing. Deleted teams are returned
|
||||
with their stamp: the panel shows them with a restore option."
|
||||
[cfg id]
|
||||
(let [row (db/get-by-id cfg :team id {::db/check-deleted false
|
||||
::db/remove-deleted false})]
|
||||
(or row
|
||||
(ex/raise :type :not-found
|
||||
:code :team-not-found
|
||||
:hint (str "team " id " not found")))))
|
||||
|
||||
(defn- team-feature-sets
|
||||
[row]
|
||||
(let [own (if (some? (:features row))
|
||||
(db/decode-pgarray (:features row) #{})
|
||||
#{})]
|
||||
{:own own
|
||||
:effective (cfeat/get-team-enabled-features cf/flags {:features own})}))
|
||||
|
||||
(sv/defmethod ::get-team
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-team-params
|
||||
::sm/result schema:team}
|
||||
[cfg {:keys [id]}]
|
||||
(let [team (get-live-team cfg id)
|
||||
sets (team-feature-sets team)
|
||||
members (:count (db/exec-one! cfg ["SELECT count(*) AS count FROM team_profile_rel WHERE team_id = ?" id]))
|
||||
projects (:count (db/exec-one! cfg ["SELECT count(*) AS count FROM project WHERE team_id = ?" id]))]
|
||||
(d/without-nils
|
||||
{:id (:id team)
|
||||
:name (:name team)
|
||||
:created-at (:created-at team)
|
||||
:modified-at (:modified-at team)
|
||||
:is-default (boolean (:is-default team))
|
||||
:total-members members
|
||||
:total-projects projects
|
||||
:deleted-at (:deleted-at team)
|
||||
:features (vec (sort (:own sets)))
|
||||
:effective-features (vec (sort (:effective sets)))})))
|
||||
|
||||
(def schema:team-member
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:email ::sm/text]
|
||||
[:fullname ::sm/text]
|
||||
[:is-owner ::sm/boolean]
|
||||
[:is-admin ::sm/boolean]
|
||||
[:can-edit ::sm/boolean]
|
||||
[:deleted-at {:optional true} ::sm/inst]
|
||||
[:is-blocked ::sm/boolean]
|
||||
[:is-active ::sm/boolean]
|
||||
[:is-demo ::sm/boolean]])
|
||||
|
||||
(def schema:get-team-members-params
|
||||
[:map
|
||||
[:team-id ::sm/uuid]])
|
||||
|
||||
(def ^:private sql:team-members
|
||||
(str "SELECT p.id, p.email, p.fullname, "
|
||||
"COALESCE(tp.is_owner, false) AS is_owner, "
|
||||
"COALESCE(tp.is_admin, false) AS is_admin, "
|
||||
"COALESCE(tp.can_edit, false) AS can_edit, "
|
||||
"p.deleted_at AS deleted_at, "
|
||||
"COALESCE(p.is_blocked, false) AS is_blocked, "
|
||||
"COALESCE(p.is_active, false) AS is_active, "
|
||||
"COALESCE(p.is_demo, false) AS is_demo "
|
||||
"FROM team_profile_rel AS tp "
|
||||
"JOIN profile AS p ON (p.id = tp.profile_id) "
|
||||
"WHERE tp.team_id = ? "
|
||||
"ORDER BY p.email ASC"))
|
||||
|
||||
(sv/defmethod ::get-team-members
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::sm/params schema:get-team-members-params
|
||||
::sm/result [:vector schema:team-member]}
|
||||
[cfg {:keys [team-id]}]
|
||||
(get-live-team cfg team-id)
|
||||
(mapv d/without-nils
|
||||
(db/exec! cfg [sql:team-members team-id])))
|
||||
|
||||
(def schema:team-feature-params
|
||||
[:map
|
||||
[:team-id ::sm/uuid]
|
||||
[:feature ::sm/text]])
|
||||
|
||||
(def schema:team-feature-result
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:features [:vector ::sm/text]]])
|
||||
|
||||
(defn- set-team-feature
|
||||
[cfg team-id feature enable?]
|
||||
(when-not (contains? cfeat/supported-features feature)
|
||||
(ex/raise :type :validation
|
||||
:code :feature-not-supported
|
||||
:hint (str "feature '" feature "' not supported")))
|
||||
(let [team (get-live-team cfg team-id)
|
||||
current (if (some? (:features team))
|
||||
(db/decode-pgarray (:features team) #{})
|
||||
#{})
|
||||
updated ((if enable? conj disj) current feature)]
|
||||
(when (not= updated current)
|
||||
(let [conn (::db/conn cfg)]
|
||||
(db/update! conn :team
|
||||
{:features (db/create-array conn "text" (vec updated))}
|
||||
{:id team-id})))
|
||||
{:id team-id :features (vec (sort updated))}))
|
||||
|
||||
(sv/defmethod ::enable-team-feature
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:team-feature-params
|
||||
::sm/result schema:team-feature-result}
|
||||
[cfg {:keys [team-id feature]}]
|
||||
(set-team-feature cfg team-id feature true))
|
||||
|
||||
(sv/defmethod ::disable-team-feature
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:team-feature-params
|
||||
::sm/result schema:team-feature-result}
|
||||
[cfg {:keys [team-id feature]}]
|
||||
(set-team-feature cfg team-id feature false))
|
||||
|
||||
;; --- Mutation: Team Member Role
|
||||
|
||||
(def schema:update-team-member-role-params
|
||||
[:map {:title "update-team-member-role"}
|
||||
[:team-id ::sm/uuid]
|
||||
[:member-id ::sm/uuid]
|
||||
[:role types.team/schema:role]])
|
||||
|
||||
(sv/defmethod ::update-team-member-role
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:update-team-member-role-params}
|
||||
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [team-id member-id role]}]
|
||||
(let [role (keyword role)
|
||||
_ (get-live-team cfg team-id)
|
||||
members (db/exec! cfg [sql:team-members team-id])
|
||||
member (d/seek #(= member-id (:id %)) members)]
|
||||
(when-not member
|
||||
(ex/raise :type :not-found
|
||||
:code :member-does-not-exist))
|
||||
|
||||
;; Ownership moves by promoting someone else: the current
|
||||
;; owner keeps the seat until then, so it is never vacant.
|
||||
(when (:is-owner member)
|
||||
(ex/raise :type :validation
|
||||
:code :cant-change-role-to-owner))
|
||||
|
||||
(mbus/pub! msgbus
|
||||
:topic member-id
|
||||
:message {:type :team-role-change
|
||||
:topic member-id
|
||||
:team-id team-id
|
||||
:role role})
|
||||
|
||||
;; Only allow single owner on team.
|
||||
(when (= role :owner)
|
||||
(db/update! conn :team-profile-rel
|
||||
{:is-owner false}
|
||||
{:team-id team-id
|
||||
:is-owner true}))
|
||||
|
||||
(db/update! conn :team-profile-rel
|
||||
(get types.team/permissions-for-role role)
|
||||
{:team-id team-id
|
||||
:profile-id member-id})
|
||||
nil))
|
||||
|
||||
(def schema:restore-team-params
|
||||
[:map {:title "restore-team"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def schema:restore-team-result
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::restore-team
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:restore-team-params
|
||||
::sm/result schema:restore-team-result}
|
||||
[cfg {:keys [id]}]
|
||||
(let [row (db/get-by-id cfg :team id {::db/check-deleted false
|
||||
::db/remove-deleted false})]
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :team-not-found
|
||||
:hint (str "team " id " not found"))))
|
||||
;; NOTE: the team rows are restored inline so the team reads back
|
||||
;; instantly; the children cascade runs as a worker task. Refuses
|
||||
;; ownerless teams before writing anything.
|
||||
(cascade/restore-chain (::db/conn cfg) :team id)
|
||||
(jobs/submit cfg
|
||||
{::jobs/name :restore-object
|
||||
::jobs/params {:object :team
|
||||
:id id}})
|
||||
{:id id})
|
||||
|
||||
(def schema:delete-team-params
|
||||
[:map {:title "delete-team"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(def schema:delete-team-result
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::delete-team
|
||||
{::doc/added "2.20"
|
||||
::rpc/perms #{"superuser"}
|
||||
::db/transaction true
|
||||
::sm/params schema:delete-team-params
|
||||
::sm/result schema:delete-team-result}
|
||||
[cfg {:keys [id]}]
|
||||
(let [row (db/get-by-id cfg :team id {::db/check-deleted false
|
||||
::db/remove-deleted false})]
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :team-not-found
|
||||
:hint (str "team " id " not found")))
|
||||
(if (some? (:deleted-at row))
|
||||
;; NOTE: already deleted is a no-op success: the date stays
|
||||
;; untouched and no task is submitted.
|
||||
{:id id}
|
||||
(do
|
||||
(when (:is-default row)
|
||||
(ex/raise :type :validation
|
||||
:code :non-deletable-team
|
||||
:hint "impossible to delete default team"))
|
||||
(let [deleted-at (ct/minus (ct/now) (cf/get-deletion-delay))]
|
||||
;; NOTE: the team rows are stamped inline so the team reads
|
||||
;; back instantly; the children cascade runs as a worker
|
||||
;; task with the same date.
|
||||
(db/update! cfg :team
|
||||
{:deleted-at deleted-at}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
(db/update! cfg :team-font-variant
|
||||
{:deleted-at deleted-at}
|
||||
{:team-id id}
|
||||
{::db/return-keys false})
|
||||
(jobs/submit cfg
|
||||
{::jobs/name :delete-object
|
||||
::jobs/params {:object :team
|
||||
:deleted-at deleted-at
|
||||
:id id}})
|
||||
{:id id})))))
|
||||
@ -123,8 +123,9 @@
|
||||
;; accept invitation with other email
|
||||
response (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
|
||||
{:invitation-token (:invitation-token params)}
|
||||
(assoc profile :is-admin (let [admins (cf/get :admins)]
|
||||
(contains? admins (:email profile)))))]
|
||||
(assoc profile :is-superuser
|
||||
(contains? (::auth/superusers cfg)
|
||||
(:id profile))))]
|
||||
(login-lockout/clear-attempts! cfg (:id profile))
|
||||
(-> response
|
||||
(rph/with-transform (session/create-fn cfg profile))
|
||||
|
||||
@ -28,17 +28,17 @@
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(def ^:private max-limit 200)
|
||||
(def ^:private default-limit 50)
|
||||
(def max-limit 200)
|
||||
(def default-limit 50)
|
||||
|
||||
(def ^:private source-names
|
||||
(def source-names
|
||||
{1 "legacy-v1"
|
||||
2 "legacy-v2"
|
||||
3 "logging"
|
||||
4 "audit-log"
|
||||
5 "rlimit"})
|
||||
|
||||
(defn- source->name
|
||||
(defn source->name
|
||||
[source]
|
||||
(get source-names source (str "unknown-" source)))
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
[name]
|
||||
(some (fn [[k v]] (when (= v name) k)) source-names))
|
||||
|
||||
(def ^:private schema:error-report-summary
|
||||
(def schema:error-report-summary
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:created-at ct/schema:inst]
|
||||
@ -57,7 +57,7 @@
|
||||
[:version {:optional true} ::sm/text]
|
||||
[:hint {:optional true} ::sm/text]])
|
||||
|
||||
(def ^:private schema:get-error-reports-params
|
||||
(def schema:get-error-reports-params
|
||||
[:map {:title "get-error-reports-params"}
|
||||
[:since {:optional true} ct/schema:inst]
|
||||
[:since-id {:optional true} ::sm/uuid]
|
||||
@ -71,13 +71,13 @@
|
||||
[:hint {:optional true} ::sm/text]
|
||||
[:until {:optional true} ct/schema:inst]])
|
||||
|
||||
(def ^:private schema:get-error-reports-result
|
||||
(def schema:get-error-reports-result
|
||||
[:map
|
||||
[:items [:vector schema:error-report-summary]]
|
||||
[:next-since {:optional true} ct/schema:inst]
|
||||
[:next-id {:optional true} ::sm/uuid]])
|
||||
|
||||
(def ^:private schema:error-report
|
||||
(def schema:error-report
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:created-at ct/schema:inst]
|
||||
@ -91,7 +91,7 @@
|
||||
[:href {:optional true} ::sm/text]
|
||||
[:context {:optional true} ::sm/text]])
|
||||
|
||||
(def ^:private schema:get-error-report-params
|
||||
(def schema:get-error-report-params
|
||||
[:map
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
@ -104,7 +104,7 @@
|
||||
"content->>'~:profile-id' AS profile_id "
|
||||
"FROM server_error_report"))
|
||||
|
||||
(defn- build-list-query
|
||||
(defn build-list-query
|
||||
[{:keys [since since-id source profile-id kind tenant version hint until limit]
|
||||
:or {limit default-limit}}]
|
||||
(let [source-id (when source (name->source source))
|
||||
|
||||
@ -90,6 +90,7 @@
|
||||
[:email ::sm/email]
|
||||
[:theme {:optional true} :string]
|
||||
[:is-admin {:optional true} ::sm/boolean]
|
||||
[:is-superuser {:optional true} ::sm/boolean]
|
||||
[:is-active {:optional true} ::sm/boolean]
|
||||
[:is-blocked {:optional true} ::sm/boolean]
|
||||
[:is-demo {:optional true} ::sm/boolean]
|
||||
@ -135,7 +136,14 @@
|
||||
(try
|
||||
(let [profile (-> (get-profile pool profile-id)
|
||||
(strip-private-attrs)
|
||||
(update :props filter-props))]
|
||||
(update :props filter-props))
|
||||
;; Same answer the guard gives: devenv passes any authenticated
|
||||
;; profile, otherwise the registry decides.
|
||||
profile (assoc profile :is-superuser
|
||||
(boolean (or (and (= "devenv" (cf/get :host))
|
||||
(some? (:id profile)))
|
||||
(contains? (::auth/superusers cfg)
|
||||
(:id profile)))))]
|
||||
(with-nitrate-licence profile cfg))
|
||||
|
||||
(catch Throwable cause
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
:total (::total cfg)
|
||||
:incr (::inc cfg 1))
|
||||
|
||||
(when-let [admins (seq (cf/get :admins))]
|
||||
(when-let [admins (seq (cf/get :superusers))]
|
||||
(let [subject (str/istr "[quotes:notification]: max quote reached ~(::target cfg)")
|
||||
content (str/istr "- Param: profile-id '~(::profile-id cfg)}'\n"
|
||||
"- Param: team-id '~(::team-id cfg)'\n"
|
||||
@ -105,6 +105,7 @@
|
||||
:subject subject
|
||||
:body content}}))))
|
||||
|
||||
|
||||
(defn- generic-check!
|
||||
[{:keys [::db/conn ::incr ::quote-sql ::count-sql ::default ::target] :or {incr 1} :as cfg}]
|
||||
(let [quote (->> (db/exec! conn quote-sql)
|
||||
|
||||
@ -25,13 +25,13 @@
|
||||
[app.db.sql :as-alias sql]
|
||||
[app.features.fdata :as fdata]
|
||||
[app.features.file-snapshots :as fsnap]
|
||||
[app.features.object-cascade :as cascade]
|
||||
[app.health :as health]
|
||||
[app.http.session :as session]
|
||||
[app.jobs :as jobs]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc.commands.auth :as auth]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.management :as mgmt]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.commands.projects :as projects]
|
||||
@ -170,6 +170,33 @@
|
||||
(db/get-update-count)
|
||||
(pos?)))))))
|
||||
|
||||
(defn ensure-test-user
|
||||
"Ensure an active profile with a known password exists (dev only).
|
||||
|
||||
Returns {:id :email}, creating the profile with its default team
|
||||
and project when the email is not taken. Intended for manual
|
||||
browser passes and agent-driven checks against devenv."
|
||||
[& {:keys [email password fullname]
|
||||
:or {password "Test123!" fullname "Test User"}}]
|
||||
(assert (= "devenv" (cf/get :host)) "dev only")
|
||||
(assert (string? email) "expected email")
|
||||
(assert (string? password) "expected password")
|
||||
|
||||
(some-> sys/system
|
||||
(db/tx-run!
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(let [email (str/lower email)]
|
||||
(if-let [profile (db/get* conn :profile {:email email}
|
||||
{:columns [:id :email]})]
|
||||
(select-keys profile [:id :email])
|
||||
(let [profile (auth/create-profile
|
||||
system {:email email
|
||||
:fullname fullname
|
||||
:is-active true
|
||||
:password (derive-password password)})]
|
||||
(auth/create-profile-rels system profile)
|
||||
(select-keys profile [:id :email]))))))))
|
||||
|
||||
(defn parse-emails
|
||||
"Parse the emails into a seq of cleaned emails. Accepts a single
|
||||
email, a comma separated list of emails or a coll of emails.
|
||||
@ -705,7 +732,7 @@
|
||||
[file-id]
|
||||
(let [file-id (h/parse-uuid file-id)]
|
||||
(db/tx-run! sys/system
|
||||
(fn [{:keys [::db/conn] :as system}]
|
||||
(fn [system]
|
||||
(when-let [file (db/get* system :file
|
||||
{:id file-id}
|
||||
{::db/remove-deleted false
|
||||
@ -717,7 +744,7 @@
|
||||
:context {:triggered-by "srepl"
|
||||
:cause "explicit call to restore-file!"}})
|
||||
|
||||
(#'files/restore-files conn [file-id]))
|
||||
(cascade/update-cascade system :file file-id {:deleted-at nil}))
|
||||
:restored))))
|
||||
|
||||
(defn delete-project!
|
||||
@ -741,19 +768,6 @@
|
||||
:id project-id})))
|
||||
:deleted))
|
||||
|
||||
(defn- restore-project*
|
||||
[{:keys [::db/conn] :as cfg} project-id]
|
||||
(db/update! conn :project
|
||||
{:deleted-at nil}
|
||||
{:id project-id})
|
||||
|
||||
(doseq [{:keys [id]} (db/query conn :file
|
||||
{:project-id project-id}
|
||||
{::sql/columns [:id]})]
|
||||
(#'files/restore-files conn [id]))
|
||||
|
||||
:restored)
|
||||
|
||||
(defn restore-project!
|
||||
"Mark a project and all related objects as not deleted"
|
||||
[project-id]
|
||||
@ -770,7 +784,7 @@
|
||||
:context {:triggered-by "srepl"
|
||||
:cause "explicit call to restore-team!"}})
|
||||
|
||||
(restore-project* system project-id))))))
|
||||
(cascade/update-cascade system :project project-id {:deleted-at nil}))))))
|
||||
|
||||
(defn delete-team!
|
||||
"Mark a team for deletion"
|
||||
@ -793,37 +807,6 @@
|
||||
:id team-id})))
|
||||
:deleted))
|
||||
|
||||
(def ^:private sql:restore-font-file-objects
|
||||
"UPDATE storage_object
|
||||
SET deleted_at = null
|
||||
WHERE deleted_at IS NOT NULL
|
||||
AND id IN (SELECT unnest(array[woff1_file_id, woff2_file_id, otf_file_id, ttf_file_id])
|
||||
FROM team_font_variant
|
||||
WHERE team_id = ?::uuid
|
||||
AND deleted_at IS NULL)")
|
||||
|
||||
(defn- restore-team*
|
||||
[{:keys [::db/conn] :as cfg} team-id]
|
||||
(db/update! conn :team
|
||||
{:deleted-at nil}
|
||||
{:id team-id})
|
||||
|
||||
(db/update! conn :team-font-variant
|
||||
{:deleted-at nil}
|
||||
{:team-id team-id})
|
||||
|
||||
;; Same as in `restore-files`: the font objects of the variants we just
|
||||
;; brought back must stop being marked for deletion, or the storage GC will
|
||||
;; skip them forever (those foreign keys are `NO ACTION`).
|
||||
(db/exec-one! conn [sql:restore-font-file-objects team-id])
|
||||
|
||||
(doseq [{:keys [id]} (db/query conn :project
|
||||
{:team-id team-id}
|
||||
{::sql/columns [:id]})]
|
||||
(restore-project* cfg id))
|
||||
|
||||
:restored)
|
||||
|
||||
(defn restore-team!
|
||||
"Mark a team and all related objects as not deleted"
|
||||
[team-id]
|
||||
@ -841,7 +824,7 @@
|
||||
:context {:triggered-by "srepl"
|
||||
:cause "explicit call to restore-team!"}})
|
||||
|
||||
(restore-team* system team-id))))))
|
||||
(cascade/update-cascade system :team team-id {:deleted-at nil}))))))
|
||||
|
||||
(defn delete-profile!
|
||||
"Mark a profile for deletion."
|
||||
@ -881,13 +864,7 @@
|
||||
:context {:triggered-by "srepl"
|
||||
:cause "explicit call to restore-profile!"}})
|
||||
|
||||
(db/update! system :profile
|
||||
{:deleted-at nil}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(doseq [{:keys [id]} (profile/get-owned-teams system profile-id)]
|
||||
(restore-team* system id))
|
||||
(cascade/update-cascade system :profile profile-id {:deleted-at nil})
|
||||
|
||||
:restored)))))
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as-alias sql]
|
||||
[app.features.object-cascade :as cascade]
|
||||
[app.jobs :as jobs]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc.commands.files :as files]
|
||||
@ -19,8 +20,6 @@
|
||||
[app.storage :as sto]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(def ^:dynamic *team-deletion* false)
|
||||
|
||||
(def ^:dynamic *cascade*
|
||||
"Accumulates the ids marked deleted by the current cascade execution,
|
||||
grouped by entity. Bound by the task entry point; nil on direct calls
|
||||
@ -63,107 +62,22 @@
|
||||
{::db/return-keys false}))
|
||||
|
||||
(defmethod delete-object :file
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at file-ids]}]
|
||||
(when-let [file (db/get* conn :file {:id id}
|
||||
{::db/remove-deleted false
|
||||
::sql/columns [:id :is-shared]})]
|
||||
|
||||
(l/trc :obj "file" :id (str id)
|
||||
:deleted-at (ct/format-inst deleted-at))
|
||||
|
||||
(db/update! conn :file
|
||||
{:deleted-at deleted-at
|
||||
:is-shared false}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(when (and (:is-shared file)
|
||||
(not *team-deletion*))
|
||||
;; NOTE: we don't prevent file deletion on absorb operation failure
|
||||
(try
|
||||
(db/tx-run! cfg files/absorb-library id :file-ids file-ids)
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "error on absorbing library"
|
||||
:file-id id
|
||||
:cause cause))))
|
||||
|
||||
;; Mark file change to be deleted, but only the rows that do not carry a
|
||||
;; date yet. A revision is born already scheduled for deletion one hour
|
||||
;; later, and rewriting its date here would push it out to the trash
|
||||
;; lifetime; the collector would still take it, just much later than it
|
||||
;; should.
|
||||
(db/update! conn :file-change
|
||||
{:deleted-at deleted-at}
|
||||
{:file-id id :deleted-at nil}
|
||||
{::db/return-keys false})
|
||||
|
||||
;; Mark file data fragment to be deleted
|
||||
(db/update! conn :file-data
|
||||
{:deleted-at deleted-at}
|
||||
{:file-id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
;; Mark file media objects to be deleted
|
||||
(db/update! conn :file-media-object
|
||||
{:deleted-at deleted-at}
|
||||
{:file-id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
;; Mark thumbnails to be deleted
|
||||
(db/update! conn :file-thumbnail
|
||||
{:deleted-at deleted-at}
|
||||
{:file-id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(db/update! conn :file-tagged-object-thumbnail
|
||||
{:deleted-at deleted-at}
|
||||
{:file-id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(record :files id)))
|
||||
[cfg {:keys [id deleted-at file-ids]}]
|
||||
(cascade/update-cascade cfg :file id
|
||||
{:deleted-at deleted-at
|
||||
:absorb-file! (fn [cfg id]
|
||||
(db/tx-run! cfg files/absorb-library id :file-ids file-ids))
|
||||
:report! record}))
|
||||
|
||||
(defmethod delete-object :project
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}]
|
||||
(l/trc :obj "project" :id (str id)
|
||||
:deleted-at (ct/format-inst deleted-at))
|
||||
|
||||
(db/update! conn :project
|
||||
{:deleted-at deleted-at}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(doseq [file (db/query conn :file
|
||||
{:project-id id}
|
||||
{::db/columns [:id :deleted-at]})]
|
||||
(delete-object cfg (assoc file
|
||||
:object :file
|
||||
:deleted-at deleted-at)))
|
||||
|
||||
(record :projects id))
|
||||
[cfg {:keys [id deleted-at]}]
|
||||
(cascade/update-cascade cfg :project id {:deleted-at deleted-at
|
||||
:report! record}))
|
||||
|
||||
(defmethod delete-object :team
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}]
|
||||
(l/trc :obj "team" :id (str id)
|
||||
:deleted-at (ct/format-inst deleted-at))
|
||||
(db/update! conn :team
|
||||
{:deleted-at deleted-at}
|
||||
{:id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(db/update! conn :team-font-variant
|
||||
{:deleted-at deleted-at}
|
||||
{:team-id id}
|
||||
{::db/return-keys false})
|
||||
|
||||
(binding [*team-deletion* true]
|
||||
(doseq [project (db/query conn :project
|
||||
{:team-id id}
|
||||
{::db/columns [:id :deleted-at]})]
|
||||
(delete-object cfg (assoc project
|
||||
:object :project
|
||||
:deleted-at deleted-at))))
|
||||
|
||||
(record :teams id))
|
||||
[cfg {:keys [id deleted-at]}]
|
||||
(cascade/update-cascade cfg :team id {:deleted-at deleted-at
|
||||
:report! record}))
|
||||
|
||||
(defmethod delete-object :profile
|
||||
[{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}]
|
||||
@ -177,9 +91,8 @@
|
||||
|
||||
(doseq [team (profile/get-owned-teams conn id)]
|
||||
(jobs/heartbeat cfg)
|
||||
(delete-object cfg (assoc team
|
||||
:object :team
|
||||
:deleted-at deleted-at))))
|
||||
(cascade/update-cascade cfg :team (:id team) {:deleted-at deleted-at
|
||||
:report! record})))
|
||||
|
||||
(defmethod delete-object :default
|
||||
[_cfg props]
|
||||
|
||||
69
backend/src/app/tasks/restore_object.clj
Normal file
69
backend/src/app/tasks/restore_object.clj
Normal file
@ -0,0 +1,69 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns app.tasks.restore-object
|
||||
"A generic task for object restore cascade handling.
|
||||
|
||||
Mirrors `:delete-object`: the command stamps the object's own row
|
||||
(plus its ancestor chain up to the team) synchronously so it reads
|
||||
back instantly, and this task walks the rest of the tree with
|
||||
`deleted-at` nil. Always recursive: there is no single-level
|
||||
restore."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.db :as db]
|
||||
[app.features.object-cascade :as cascade]
|
||||
[app.jobs :as jobs]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(defmulti restore-object
|
||||
(fn [_ props] (:object props)))
|
||||
|
||||
(defmethod restore-object :file
|
||||
[cfg {:keys [id]}]
|
||||
(cascade/update-cascade cfg :file id {:deleted-at nil}))
|
||||
|
||||
(defmethod restore-object :project
|
||||
[cfg {:keys [id]}]
|
||||
(cascade/update-cascade cfg :project id {:deleted-at nil}))
|
||||
|
||||
(defmethod restore-object :team
|
||||
[cfg {:keys [id]}]
|
||||
(cascade/update-cascade cfg :team id {:deleted-at nil}))
|
||||
|
||||
(defmethod restore-object :profile
|
||||
[cfg {:keys [id]}]
|
||||
(cascade/update-cascade cfg :profile id {:deleted-at nil}))
|
||||
|
||||
(defmethod restore-object :default
|
||||
[_cfg props]
|
||||
(l/wrn :obj (:object props) :hint "not implementation found"))
|
||||
|
||||
(def schema:restore-object-params
|
||||
[:map
|
||||
[:object [:enum :team :project :profile :file]]
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(defn execute-restore-object
|
||||
"Plain job handler: run the restore-object multimethod on the provided
|
||||
params inside a single transaction."
|
||||
[cfg params]
|
||||
(db/tx-run! cfg restore-object params))
|
||||
|
||||
(defmethod ig/assert-key ::job-def
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
|
||||
|
||||
(defmethod ig/init-key ::job-def
|
||||
[_ cfg]
|
||||
{::jobs/name :restore-object
|
||||
::jobs/schema schema:restore-object-params
|
||||
::jobs/handler
|
||||
(fn [_context params]
|
||||
(execute-restore-object cfg params))
|
||||
::jobs/decoder (sm/decoder schema:restore-object-params sm/json-transformer)
|
||||
::jobs/validator (sm/validator schema:restore-object-params)})
|
||||
@ -207,3 +207,31 @@
|
||||
|
||||
(t/testing "so the collector takes it"
|
||||
(t/is (nil? (find-change revision-id)))))))
|
||||
|
||||
(t/deftest restoring-a-file-through-the-task-leaves-the-revision-schedule-alone
|
||||
;; Same invariant as `restoring-a-file-leaves-the-revision-schedule-alone`,
|
||||
;; through the worker path the admin panel uses: the `:restore-object`
|
||||
;; task walks the cascade with a nil date, and that must not unmark a
|
||||
;; revision either.
|
||||
(binding [ct/*clock* (ct/fixed-clock test-now)]
|
||||
(let [[_ file] (create-file 1)
|
||||
file-id (:id file)
|
||||
trash-at (ct/in-future {:days 7})
|
||||
old-at (ct/in-past {:hours 2})
|
||||
|
||||
revision-id (insert-revision file-id old-at)
|
||||
revision-at (:deleted-at (find-change revision-id))
|
||||
|
||||
snapshot-id (insert-change file-id {:created-at old-at
|
||||
:created-by "user"
|
||||
:label "a snapshot"
|
||||
:deleted-at trash-at})]
|
||||
|
||||
(th/run-task! :restore-object {:object :file
|
||||
:id file-id})
|
||||
|
||||
(t/testing "the revision keeps the schedule it was born with"
|
||||
(t/is (= revision-at (:deleted-at (find-change revision-id)))))
|
||||
|
||||
(t/testing "the snapshot comes back"
|
||||
(t/is (nil? (:deleted-at (find-change snapshot-id))))))))
|
||||
|
||||
@ -141,6 +141,8 @@
|
||||
:app.setup/templates templates
|
||||
:app.loggers.mattermost/reporter nil
|
||||
:app.loggers.database/reporter nil)
|
||||
(update :app.rpc/admin-methods assoc
|
||||
:app.setup/templates templates)
|
||||
(dissoc :app.srepl/server
|
||||
:app.http/server
|
||||
:app.http/route
|
||||
|
||||
61
backend/test/backend_tests/http_admin_test.clj
Normal file
61
backend/test/backend_tests/http_admin_test.clj
Normal file
@ -0,0 +1,61 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.http-admin-test
|
||||
"Tests for the `/admin` static file serving."
|
||||
(:require
|
||||
[app.http.admin :as admin]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(defn- run-handler
|
||||
[path]
|
||||
(admin/admin-handler {} {:path-params (when (some? path) {:path path})}))
|
||||
|
||||
(defn- content-type
|
||||
[response]
|
||||
(get (::yres/headers response) "content-type"))
|
||||
|
||||
(t/deftest root-serves-index
|
||||
;; NOTE: the `""` route answers with a redirect to `/admin/` (see
|
||||
;; below); this is the handler behind `"/"` and `/*path`.
|
||||
(let [response (run-handler nil)]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (= "text/html" (content-type response)))
|
||||
(t/is (= "no-store" (get (::yres/headers response) "cache-control")))
|
||||
(t/is (str/includes? (::yres/body response) "id=\"app\""))))
|
||||
|
||||
(t/deftest bare-path-redirects-to-trailing-slash
|
||||
;; Without the slash, relative assets in index.html resolve against
|
||||
;; `/` and the panel loads unstyled. The relative target keeps
|
||||
;; subpath deployments working.
|
||||
(let [response (admin/admin-redirect-handler {} {})]
|
||||
(t/is (= 302 (::yres/status response)))
|
||||
(t/is (= "admin/" (get (::yres/headers response) "location")))))
|
||||
|
||||
(t/deftest unknown-subpath-is-not-found
|
||||
;; SPA state travels in the query string: no subpath page exists.
|
||||
(let [response (run-handler "error-reports/some-id")]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest traversal-attempt-is-not-found
|
||||
(let [response (run-handler "../../config")]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest missing-file-is-not-found
|
||||
(let [response (run-handler "js/does-not-exist.js")]
|
||||
(t/is (= 404 (::yres/status response)))))
|
||||
|
||||
(t/deftest content-type-mapping
|
||||
(t/is (= "text/html" (#'admin/content-type-for "index.html")))
|
||||
(t/is (= "text/javascript" (#'admin/content-type-for "js/main.js")))
|
||||
(t/is (= "text/css" (#'admin/content-type-for "css/admin.css")))
|
||||
(t/is (nil? (#'admin/content-type-for "no-extension"))))
|
||||
194
backend/test/backend_tests/rpc_admin_delete_test.clj
Normal file
194
backend/test/backend_tests/rpc_admin_delete_test.clj
Normal file
@ -0,0 +1,194 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-delete-test
|
||||
"Tests for the superuser-guarded delete commands."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- raw-method
|
||||
"The raw [f mdata] behind `cmd-name` in the admin namespace."
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.file
|
||||
'app.rpc.admin.project
|
||||
'app.rpc.admin.team
|
||||
'app.rpc.admin.profile)))
|
||||
|
||||
(defn- call
|
||||
"Invoke an admin command through the real wrapper chain with a
|
||||
hand-built config.
|
||||
|
||||
The chain closes over its cfg at startup, so the superuser set is
|
||||
injected per call here instead of going through `th/command!`.
|
||||
Qualified keys stay in `params` as server context; the rest goes
|
||||
into the request, where params validation reads it from."
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- as-superuser
|
||||
[profile]
|
||||
(as-session #{(:id profile)} profile))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
(defn- create-file
|
||||
[i]
|
||||
(let [profile (th/create-profile* i)
|
||||
team (th/create-team* i {:profile-id (:id profile)})
|
||||
project (th/create-project* i {:profile-id (:id profile)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* i {:profile-id (:id profile)
|
||||
:project-id (:id project)})]
|
||||
{:profile profile :team team :project project :file file}))
|
||||
|
||||
(defn- deleted-at
|
||||
"Raw `deleted_at`, bypassing the soft-delete filter."
|
||||
[table match]
|
||||
(:deleted-at (db/get* th/*system* table match {::db/remove-deleted false})))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Guard
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest unlisted-session-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)
|
||||
missing (uuid/next)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "delete-team" {:id missing}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "delete-project" {:id missing}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "delete-file" {:id missing}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Not found
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest delete-unknown-ids-give-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)
|
||||
missing (uuid/next)]
|
||||
(t/is (= :team-not-found
|
||||
(caught-code #(run "delete-team" {:id missing}))))
|
||||
(t/is (= :project-not-found
|
||||
(caught-code #(run "delete-project" {:id missing}))))
|
||||
(t/is (= :file-not-found
|
||||
(caught-code #(run "delete-file" {:id missing}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Delete cascades
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest delete-team-deletes-cascade
|
||||
(let [{:keys [profile team project file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
out (run "delete-team" {:id (:id team)})]
|
||||
(t/is (= (:id team) (:id out)))
|
||||
(t/is (not (contains? out :recursive)))
|
||||
(t/is (some? (deleted-at :team {:id (:id team)})))
|
||||
(t/is (nil? (deleted-at :project {:id (:id project)})))
|
||||
(th/run-pending-jobs)
|
||||
(t/is (some? (deleted-at :project {:id (:id project)})))
|
||||
(t/is (some? (deleted-at :file {:id (:id file)})))
|
||||
(t/is (some? (deleted-at :file-data {:file-id (:id file)})))))
|
||||
|
||||
(t/deftest delete-project-deletes-files
|
||||
(let [{:keys [profile project file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
out (run "delete-project" {:id (:id project)})]
|
||||
(t/is (= (:id project) (:id out)))
|
||||
(t/is (some? (deleted-at :project {:id (:id project)})))
|
||||
(t/is (nil? (deleted-at :file {:id (:id file)})))
|
||||
(th/run-pending-jobs)
|
||||
(t/is (some? (deleted-at :file {:id (:id file)})))
|
||||
(t/is (some? (deleted-at :file-data {:file-id (:id file)})))))
|
||||
|
||||
(t/deftest delete-file-deletes-rows
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
out (run "delete-file" {:id (:id file)})]
|
||||
(t/is (= (:id file) (:id out)))
|
||||
(t/is (some? (deleted-at :file {:id (:id file)})))
|
||||
(t/is (nil? (deleted-at :file-data {:file-id (:id file)})))
|
||||
(th/run-pending-jobs)
|
||||
(t/is (some? (deleted-at :file-data {:file-id (:id file)})))
|
||||
(t/is (false? (:is-shared (db/get* th/*system* :file {:id (:id file)}
|
||||
{::db/remove-deleted false}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Defaults are protected
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest delete-default-team-refused
|
||||
(let [{:keys [profile team]} (create-file 1)
|
||||
run (as-superuser profile)]
|
||||
(db/update! th/*system* :team {:is-default true} {:id (:id team)})
|
||||
(t/is (= :non-deletable-team
|
||||
(caught-code #(run "delete-team" {:id (:id team)}))))
|
||||
(t/is (nil? (deleted-at :team {:id (:id team)})))))
|
||||
|
||||
(t/deftest delete-default-project-refused
|
||||
(let [{:keys [profile project]} (create-file 1)
|
||||
run (as-superuser profile)]
|
||||
(db/update! th/*system* :project {:is-default true} {:id (:id project)})
|
||||
(t/is (= :non-deletable-project
|
||||
(caught-code #(run "delete-project" {:id (:id project)}))))
|
||||
(t/is (nil? (deleted-at :project {:id (:id project)})))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Already deleted is a no-op
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest delete-already-deleted-team-is-noop
|
||||
(let [{:keys [profile team]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
_ (run "delete-team" {:id (:id team)})
|
||||
first (deleted-at :team {:id (:id team)})
|
||||
out (run "delete-team" {:id (:id team)})]
|
||||
(t/is (= (:id team) (:id out)))
|
||||
(t/is (= first (deleted-at :team {:id (:id team)})))))
|
||||
182
backend/test/backend_tests/rpc_admin_error_reports_test.clj
Normal file
182
backend/test/backend_tests/rpc_admin_error_reports_test.clj
Normal file
@ -0,0 +1,182 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-error-reports-test
|
||||
"Tests for the superuser-guarded error-reports commands."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- insert-report
|
||||
"Insert a raw error report row. content is a plain map (will be tjson-encoded)."
|
||||
[{:keys [id source content created-at]
|
||||
:or {created-at (ct/now)}}]
|
||||
(th/db-insert! :server-error-report
|
||||
{:id (or id (uuid/next))
|
||||
:source source
|
||||
:created-at created-at
|
||||
:content (db/tjson content)}))
|
||||
|
||||
(defn- raw-method
|
||||
"The raw [f mdata] behind `cmd-name` in the admin namespace."
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.errors)))
|
||||
|
||||
(defn- call
|
||||
"Invoke an admin command through the real wrapper chain with a
|
||||
hand-built config.
|
||||
|
||||
The chain closes over its cfg at startup, so the superuser set is
|
||||
injected per call here instead of going through `th/command!`.
|
||||
Qualified keys stay in `params` as server context; the rest goes
|
||||
into the request, where params validation reads it from."
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Guard
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest listed-session-lists-and-reads
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(t/is (= {:items []} (run "get-error-reports" {})))))
|
||||
|
||||
(t/deftest unlisted-session-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-error-reports" {}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-error-report" {:id (uuid/next)}))))))
|
||||
|
||||
(t/deftest unlisted-token-without-scope-rejected
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(call #{} :token (:id profile) #{}
|
||||
"get-error-reports" {}))))))
|
||||
|
||||
(t/deftest token-with-granted-superuser-passes
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= {:items []}
|
||||
(call #{} :token (:id profile) #{"superuser"}
|
||||
"get-error-reports" {})))))
|
||||
|
||||
(t/deftest anonymous-rejected
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"get-error-reports" {})))))
|
||||
|
||||
(t/deftest invalid-params-still-validated-for-listed-caller
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "get-error-reports" {:limit 500}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Listing
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest list-returns-summaries
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(insert-report {:source 3 :content {:hint "boom" :tenant "devenv" :version "2.20"}})
|
||||
(let [out (run "get-error-reports" {})]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (= "logging" (:source (first (:items out)))))
|
||||
(t/is (= "boom" (:hint (first (:items out))))))))
|
||||
|
||||
(t/deftest list-filters-by-source
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(insert-report {:source 3 :content {:hint "backend"}})
|
||||
(insert-report {:source 4 :content {:hint "audit"}})
|
||||
(let [out (run "get-error-reports" {:source "audit-log"})]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (= "audit-log" (:source (first (:items out))))))))
|
||||
|
||||
(t/deftest list-paginates
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(insert-report {:source 3 :content {:hint "one"}})
|
||||
(insert-report {:source 3 :content {:hint "two"}})
|
||||
(insert-report {:source 3 :content {:hint "three"}})
|
||||
(let [page1 (run "get-error-reports" {:limit 2})]
|
||||
(t/is (= 2 (count (:items page1))))
|
||||
(t/is (some? (:next-since page1)))
|
||||
(t/is (some? (:next-id page1)))
|
||||
(let [page2 (run "get-error-reports" {:limit 2
|
||||
:since (:next-since page1)
|
||||
:since-id (:next-id page1)})]
|
||||
(t/is (= 1 (count (:items page2))))
|
||||
(t/is (nil? (:next-since page2)))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Detail
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest detail-returns-full-report
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)
|
||||
id (uuid/next)]
|
||||
(insert-report {:id id :source 5
|
||||
:content {:hint "rlimit hit"
|
||||
:report "stacktrace here"
|
||||
:context "extra context"}})
|
||||
(let [out (run "get-error-report" {:id id})]
|
||||
(t/is (= id (:id out)))
|
||||
(t/is (= "rlimit" (:source out)))
|
||||
(t/is (= "rlimit hit" (:hint out)))
|
||||
(t/is (= "stacktrace here" (:report out)))
|
||||
(t/is (= "extra context" (:context out))))))
|
||||
|
||||
(t/deftest detail-unknown-id-gives-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(t/is (= :report-not-found
|
||||
(caught-code #(run "get-error-report" {:id (uuid/next)}))))))
|
||||
232
backend/test/backend_tests/rpc_admin_error_share_test.clj
Normal file
232
backend/test/backend_tests/rpc_admin_error_share_test.clj
Normal file
@ -0,0 +1,232 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-error-share-test
|
||||
"Tests for stateless error-report share links: the plain renderer,
|
||||
the minting command and the anonymous reading command."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.rpc.admin.errors :as admin-errors]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers (same wrapper pattern as rpc-admin-error-reports-test)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- insert-report
|
||||
[{:keys [id source content created-at]
|
||||
:or {created-at (ct/now)}}]
|
||||
(th/db-insert! :server-error-report
|
||||
{:id (or id (uuid/next))
|
||||
:source source
|
||||
:created-at created-at
|
||||
:content (db/tjson content)}))
|
||||
|
||||
(defn- raw-method
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.errors)))
|
||||
|
||||
(defn- call
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- as-anonymous
|
||||
[cmd params]
|
||||
(call #{} nil nil #{} cmd params))
|
||||
|
||||
(defn- caught-code
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
(defn- mint-token
|
||||
"Mint a share token directly, bypassing the command."
|
||||
[claims]
|
||||
(tokens/generate th/*system* claims))
|
||||
|
||||
(defn- token-from-url
|
||||
[url]
|
||||
(second (str/split url "?token=")))
|
||||
|
||||
(defn- run-shared
|
||||
"Run `get-shared-error-report` and invoke the returned response
|
||||
function, like the RPC dispatcher does."
|
||||
[token]
|
||||
((call #{} nil nil #{} "get-shared-error-report" {:token token}) {}))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Plain renderer (same shape as the old /dbg/error templates)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(def ^:private sample-report
|
||||
{:id (uuid/next)
|
||||
:created-at (ct/now)
|
||||
:source "logging"
|
||||
:profile-id "profile-1"
|
||||
:kind "wrong-input"
|
||||
:tenant "devenv"
|
||||
:version "2.20.1"
|
||||
:hint "boom"
|
||||
:trace "line1\nline2"
|
||||
:context "ctx-line1\nctx-line2"})
|
||||
|
||||
(t/deftest render-titles-with-hint-then-identifies
|
||||
(let [out (admin-errors/render-shared-report sample-report "tomorrow")
|
||||
lines (str/split out "\n")]
|
||||
;; The hint is the title (like the web detail page) and never a row.
|
||||
(t/is (= "# Report: boom" (first lines)))
|
||||
(t/is (= "" (second lines)))
|
||||
(t/is (= "Id: " (subs (nth lines 2) 0 4)))
|
||||
(t/is (str/starts-with? (nth lines 3) "Date: "))
|
||||
(t/is (re-find #"^Date: \d{4}-\d{2}-\d{2}T" (nth lines 3)))
|
||||
(t/is (= "Source: logging" (nth lines 4)))
|
||||
(t/is (nil? (some #(str/starts-with? % "Hint: ") lines)))
|
||||
;; One-liners stay compact rows; long bodies go last, trace first.
|
||||
(t/is (some #(= "kind: wrong-input" %) lines))
|
||||
(t/is (some #(= "tenant: devenv" %) lines))
|
||||
(t/is (some #(= "version: 2.20.1" %) lines))
|
||||
(t/is (< (.indexOf lines "## Trace") (.indexOf lines "## Context")))
|
||||
(t/is (str/includes? out "## Trace\n\n```\nline1\nline2\n```"))
|
||||
(t/is (str/includes? out "## Context\n\n```\nctx-line1\nctx-line2\n```"))
|
||||
(t/is (str/ends-with? out "Shared link, expires tomorrow. Single report, no account access."))))
|
||||
|
||||
(t/deftest render-uses-first-hint-line-only
|
||||
(let [out (admin-errors/render-shared-report {:id 1 :hint "one\ntwo"} "soon")]
|
||||
(t/is (str/starts-with? out "# Report: one\n"))))
|
||||
|
||||
(t/deftest render-falls-back-without-hint
|
||||
(let [out (admin-errors/render-shared-report {:id 1} "soon")]
|
||||
(t/is (str/starts-with? out "# Error report\n"))
|
||||
(t/is (str/includes? out "Id: 1"))
|
||||
(t/is (not (str/includes? out "null")))))
|
||||
|
||||
(t/deftest render-steps-fence-up-when-body-has-one
|
||||
(let [out (admin-errors/render-shared-report {:id 1 :trace "a\n```\nb"} "soon")]
|
||||
(t/is (str/includes? out "## Trace\n\n````\na\n```\nb\n````"))))
|
||||
|
||||
(t/deftest render-drops-separator-lines
|
||||
(let [out (admin-errors/render-shared-report {:id 1 :trace "a\n====================\nb"} "soon")]
|
||||
(t/is (not (str/includes? out "====================")))
|
||||
(t/is (str/includes? out "```\na\nb\n```"))))
|
||||
|
||||
(t/deftest render-prints-unknown-fields-verbatim
|
||||
(let [multi (admin-errors/render-shared-report {:id 1 :weird-key "x\ny"} "soon")
|
||||
flat (admin-errors/render-shared-report {:id 1 :weird-key "x"} "soon")]
|
||||
(t/is (str/includes? multi "## Weird-key\n\n```\nx\ny\n```"))
|
||||
(t/is (str/includes? flat "weird-key: x"))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Minting command
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest create-share-returns-url-and-expiry
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)
|
||||
id (uuid/next)]
|
||||
(insert-report {:id id :source 3 :content {:hint "boom"}})
|
||||
(let [out (run "create-error-report-share" {:id id})
|
||||
token (token-from-url (:url out))]
|
||||
(t/is (str/includes? (:url out) "/api/admin/methods/get-shared-error-report?token="))
|
||||
(t/is (some? (:expires-at out)))
|
||||
(t/is (some? token))
|
||||
(let [claims (tokens/verify th/*system* {:token token :iss "error-report-share"})]
|
||||
(t/is (= id (:report-id claims)))
|
||||
(t/is (= (:id profile) (:created-by claims)))))))
|
||||
|
||||
(t/deftest create-share-rejects-ttl-outside-1-to-24
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)
|
||||
id (uuid/next)]
|
||||
(insert-report {:id id :source 3 :content {:hint "boom"}})
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "create-error-report-share" {:id id :ttl-hours 0}))))
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "create-error-report-share" {:id id :ttl-hours 25}))))))
|
||||
|
||||
(t/deftest create-share-rejects-non-superuser
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "create-error-report-share" {:id (uuid/next)}))))))
|
||||
|
||||
(t/deftest create-share-rejects-unknown-id
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(t/is (= :report-not-found
|
||||
(caught-code #(run "create-error-report-share" {:id (uuid/next)}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Anonymous reading command
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest shared-command-serves-report-without-session
|
||||
(let [id (uuid/next)
|
||||
_ (insert-report {:id id :source 3
|
||||
:content {:hint "boom" :trace "line1\nline2"}})
|
||||
token (mint-token {:iss "error-report-share" :report-id id
|
||||
:exp (ct/plus (ct/now) (ct/duration {:hours 4}))})
|
||||
res (run-shared token)]
|
||||
(t/is (= 200 (::yres/status res)))
|
||||
(t/is (= "text/markdown; charset=utf-8"
|
||||
(get (::yres/headers res) "content-type")))
|
||||
(t/is (= "no-store" (get (::yres/headers res) "cache-control")))
|
||||
(t/is (str/starts-with? (::yres/body res) "# Report: boom"))
|
||||
(t/is (str/includes? (::yres/body res) "## Trace\n\n```\nline1\nline2\n```"))))
|
||||
|
||||
(t/deftest shared-command-missing-token-param-is-404
|
||||
(let [res ((call #{} nil nil #{} "get-shared-error-report" {}) {})]
|
||||
(t/is (= 404 (::yres/status res)))
|
||||
(t/is (= "not found" (::yres/body res)))))
|
||||
|
||||
(t/deftest shared-command-404s-are-indistinguishable
|
||||
(let [id (uuid/next)
|
||||
_ (insert-report {:id id :source 3 :content {:hint "boom"}})
|
||||
good (mint-token {:iss "error-report-share" :report-id id
|
||||
:exp (ct/plus (ct/now) (ct/duration {:hours 4}))})
|
||||
expired (mint-token {:iss "error-report-share" :report-id id
|
||||
:exp (ct/plus (ct/now) (ct/duration -3600000))})
|
||||
foreign (mint-token {:iss "authentication" :report-id id
|
||||
:exp (ct/plus (ct/now) (ct/duration {:hours 4}))})
|
||||
missing (mint-token {:iss "error-report-share" :report-id (uuid/next)
|
||||
:exp (ct/plus (ct/now) (ct/duration {:hours 4}))})
|
||||
results (mapv run-shared [(str good "tampered") expired foreign missing ""])]
|
||||
(t/is (every? #(= 404 (::yres/status %)) results))
|
||||
(t/is (every? #(= "not found" (::yres/body %)) results))))
|
||||
473
backend/test/backend_tests/rpc_admin_file_test.clj
Normal file
473
backend/test/backend_tests/rpc_admin_file_test.clj
Normal file
@ -0,0 +1,473 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-file-test
|
||||
"Tests for the superuser-guarded file validate/repair commands."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.binfile.common :as bfc]
|
||||
[app.binfile.v3 :as bf.v3]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.features.file-snapshots :as fsnap]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[datoteka.io :as io]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- raw-method
|
||||
"The raw [f mdata] behind `cmd-name` in the admin namespace."
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.file)))
|
||||
|
||||
(defn- call
|
||||
"Invoke an admin command through the real wrapper chain with a
|
||||
hand-built config.
|
||||
|
||||
The chain closes over its cfg at startup, so the superuser set is
|
||||
injected per call here instead of going through `th/command!`.
|
||||
Qualified keys stay in `params` as server context; the rest goes
|
||||
into the request, where params validation reads it from."
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- as-superuser
|
||||
[profile]
|
||||
(as-session #{(:id profile)} profile))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
(defn- create-file
|
||||
[i]
|
||||
(let [profile (th/create-profile* i)
|
||||
team (th/create-team* i {:profile-id (:id profile)})
|
||||
project (th/create-project* i {:profile-id (:id profile)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* i {:profile-id (:id profile)
|
||||
:project-id (:id project)})]
|
||||
{:profile profile :file file}))
|
||||
|
||||
(defn- corrupt-file
|
||||
"Hang one shape off a nonexistent parent so validation reports
|
||||
errors. Persists straight through the binfile write path, which
|
||||
performs no referential checks."
|
||||
[file-id]
|
||||
(db/tx-run! th/*system*
|
||||
(fn [cfg]
|
||||
(let [file (bfc/get-file cfg file-id :realize? true)
|
||||
page-id (first (keys (-> file :data :pages-index)))
|
||||
orphan (cts/setup-shape {:id (uuid/next)
|
||||
:name "orphan"
|
||||
:type :rect
|
||||
:parent-id (uuid/next)
|
||||
:frame-id uuid/zero})]
|
||||
(bfc/update-file! cfg (assoc-in file [:data :pages-index page-id :objects (:id orphan)]
|
||||
orphan))))))
|
||||
|
||||
(defn- snapshot-labels
|
||||
[file-id]
|
||||
(->> (fsnap/get-visible-snapshots th/*system* file-id)
|
||||
(mapv :label)))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Guard
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest unlisted-session-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-files" {}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "validate-file" {:file-id (uuid/next)}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "repair-file" {:file-id (uuid/next)}))))))
|
||||
|
||||
(t/deftest unlisted-token-without-scope-rejected
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(call #{} :token (:id profile) #{}
|
||||
"validate-file" {:file-id (uuid/next)}))))))
|
||||
|
||||
(t/deftest token-with-granted-superuser-passes
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
out (call #{} :token (:id profile) #{"superuser"}
|
||||
"validate-file" {:file-id (:id file)})]
|
||||
(t/is (= (:id file) (:file-id out)))
|
||||
(t/is (= [] (:errors out)))))
|
||||
|
||||
(t/deftest anonymous-rejected
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"validate-file" {:file-id (uuid/next)})))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Validate
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest validate-healthy-file-returns-no-errors
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
out (run "validate-file" {:file-id (:id file)})]
|
||||
(t/is (= (:id file) (:file-id out)))
|
||||
(t/is (= [] (:errors out)))))
|
||||
|
||||
(t/deftest validate-unknown-id-gives-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :file-not-found
|
||||
(caught-code #(run "validate-file" {:file-id (uuid/next)}))))))
|
||||
|
||||
(t/deftest validate-corrupt-file-returns-scalar-errors
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
_ (corrupt-file (:id file))
|
||||
run (as-superuser profile)
|
||||
out (run "validate-file" {:file-id (:id file)})
|
||||
codes (set (map :code (:errors out)))]
|
||||
(t/is (seq (:errors out)))
|
||||
(t/is (contains? codes "parent-not-found"))
|
||||
(doseq [error (:errors out)]
|
||||
(t/is (string? (:code error)))
|
||||
(t/is (string? (:hint error)))
|
||||
(t/is (= (:id file) (:file-id error)))
|
||||
(t/is (not (contains? error :shape)))
|
||||
(t/is (not (contains? error :args))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Repair
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest repair-unknown-id-gives-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :file-not-found
|
||||
(caught-code #(run "repair-file" {:file-id (uuid/next)}))))))
|
||||
|
||||
(t/deftest repair-corrupt-file-clears-errors-and-snapshots
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
_ (corrupt-file (:id file))
|
||||
run (as-superuser profile)
|
||||
out (run "repair-file" {:file-id (:id file)})]
|
||||
(t/is (= (:id file) (:file-id out)))
|
||||
(t/is (= [] (:errors out)))
|
||||
(t/is (pos? (:changes out)))
|
||||
(t/is (true? (:snapshot-taken out)))
|
||||
(t/is (some #{"repair"} (snapshot-labels (:id file))))))
|
||||
|
||||
(t/deftest repair-without-snapshot-skips-it
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
_ (corrupt-file (:id file))
|
||||
run (as-superuser profile)
|
||||
out (run "repair-file" {:file-id (:id file) :skip-snapshot true})]
|
||||
(t/is (= [] (:errors out)))
|
||||
(t/is (pos? (:changes out)))
|
||||
(t/is (false? (:snapshot-taken out)))
|
||||
(t/is (not (some #{"repair"} (snapshot-labels (:id file)))))))
|
||||
|
||||
(t/deftest repair-healthy-file-is-noop
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
out (run "repair-file" {:file-id (:id file)})]
|
||||
(t/is (= [] (:errors out)))
|
||||
(t/is (= 0 (:changes out)))
|
||||
(t/is (false? (:snapshot-taken out)))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Listing
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest list-returns-files-with-project-and-team
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
out (run "get-files" {:id (:id file)})
|
||||
item (first (:items out))]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (= (:id file) (:id item)))
|
||||
(t/is (= (:name file) (:name item)))
|
||||
(t/is (uuid? (:project-id item)))
|
||||
(t/is (string? (:project-name item)))
|
||||
(t/is (uuid? (:team-id item)))
|
||||
(t/is (string? (:team-name item)))
|
||||
(t/is (some? (:created-at item)))
|
||||
(t/is (some? (:modified-at item)))))
|
||||
|
||||
(t/deftest list-finds-by-exact-id
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
_ (create-file 2)
|
||||
run (as-superuser profile)
|
||||
out (run "get-files" {:id (:id file)})]
|
||||
(t/is (= [(:id file)] (mapv :id (:items out))))))
|
||||
|
||||
(t/deftest list-finds-by-string-id
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
out (run "get-files" {:id (str (:id file))})]
|
||||
(t/is (= [(:id file)] (mapv :id (:items out)))
|
||||
"the browser sends ids as strings")))
|
||||
|
||||
(t/deftest list-combined-id-and-deleted-filters
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
_ (th/mark-file-deleted* {:id (:id file)})
|
||||
run (as-superuser profile)]
|
||||
(let [out (run "get-files" {:id (:id file) :deleted false})]
|
||||
(t/is (= {:items []} out)
|
||||
"a deleted file never lists as live"))
|
||||
(let [team-id (:team-id (first (:items (run "get-files" {:id (:id file)}))))
|
||||
out (run "get-files" {:team-id team-id :deleted true})]
|
||||
(t/is (= [(:id file)] (mapv :id (:items out)))
|
||||
"team scope composes with the deleted filter"))))
|
||||
|
||||
(t/deftest list-hides-files-under-deleted-projects
|
||||
(let [profile (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
project (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
run (as-superuser profile)]
|
||||
(db/update! th/*system* :project {:deleted-at (ct/now)} {:id (:id project)})
|
||||
(let [out (run "get-files" {:project-id (:id project)})]
|
||||
(t/is (= {:items []} out)
|
||||
"the list hides children of deleted parents; get-file still resolves them"))))
|
||||
|
||||
(t/deftest list-filters-by-deleted
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
_ (th/mark-file-deleted* {:id (:id file)})
|
||||
run (as-superuser profile)]
|
||||
(let [out (run "get-files" {:deleted true})]
|
||||
(t/is (= [(:id file)] (mapv :id (:items out)))))
|
||||
(let [out (run "get-files" {:deleted false})]
|
||||
(t/is (not (contains? (set (map :id (:items out))) (:id file)))
|
||||
"the deleted file is excluded"))
|
||||
(let [out (run "get-files" {})]
|
||||
(t/is (= 1 (count (:items out)))
|
||||
"without the flag, deleted rows still list"))))
|
||||
|
||||
(t/deftest list-filters-by-team
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
other (create-file 2)
|
||||
run (as-superuser profile)
|
||||
team-id (:team-id (first (:items (run "get-files" {:id (:id file)}))))
|
||||
out (run "get-files" {:team-id team-id})
|
||||
ids (set (map :id (:items out)))]
|
||||
(t/is (contains? ids (:id file)))
|
||||
(t/is (not (contains? ids (:id (:file other)))))))
|
||||
|
||||
(t/deftest list-filters-by-project
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
other (create-file 2)
|
||||
run (as-superuser profile)
|
||||
project-id (:project-id (first (:items (run "get-files" {:id (:id file)}))))
|
||||
out (run "get-files" {:project-id project-id})
|
||||
ids (set (map :id (:items out)))]
|
||||
(t/is (contains? ids (:id file)))
|
||||
(t/is (not (contains? ids (:id (:file other)))))))
|
||||
|
||||
(t/deftest list-shows-deleted-files
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
_ (th/mark-file-deleted* {:id (:id file)})
|
||||
run (as-superuser profile)
|
||||
out (run "get-files" {:id (:id file)})
|
||||
item (first (:items out))]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (= (:id file) (:id item)))
|
||||
(t/is (some? (:deleted-at item)))))
|
||||
|
||||
(t/deftest list-paginates
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
_ (create-file 2)
|
||||
run (as-superuser profile)
|
||||
page1 (run "get-files" {:limit 1})]
|
||||
(t/is (= 1 (count (:items page1))))
|
||||
(t/is (some? (:next-since page1)))
|
||||
(t/is (some? (:next-id page1)))
|
||||
(let [page2 (run "get-files" {:limit 1
|
||||
:since (:next-since page1)
|
||||
:since-id (:next-id page1)})]
|
||||
(t/is (= 1 (count (:items page2))))
|
||||
(t/is (not= (-> page1 :items first :id)
|
||||
(-> page2 :items first :id))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Detail
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest detail-unlisted-session-rejected
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-file" {:id (:id file)}))))))
|
||||
|
||||
(t/deftest detail-resolves-regardless-of-deleted-state
|
||||
(let [profile (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
project (th/create-project* 1 {:profile-id (:id profile)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
run (as-superuser profile)]
|
||||
(let [out (run "get-file" {:id (:id file)})]
|
||||
(t/is (= (:id file) (:id out)))
|
||||
(t/is (= (:name file) (:name out)))
|
||||
(t/is (= (:id project) (:project-id out))))
|
||||
;; A stray live file under a deleted project still resolves:
|
||||
;; the admin must reach objects the list hides.
|
||||
(db/update! th/*system* :project {:deleted-at (ct/now)} {:id (:id project)})
|
||||
(let [out (run "get-file" {:id (:id file)})]
|
||||
(t/is (= (:id file) (:id out))))
|
||||
(th/mark-file-deleted* {:id (:id file)})
|
||||
(let [out (run "get-file" {:id (:id file)})]
|
||||
(t/is (some? (:deleted-at out))))))
|
||||
|
||||
(t/deftest detail-unknown-id-gives-not-found
|
||||
(let [{:keys [profile]} (create-file 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :file-not-found
|
||||
(caught-code #(run "get-file" {:id (uuid/next)}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Transfer (export-files / import-files)
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- run-response
|
||||
"Run a transfer command and invoke the returned response function,
|
||||
like the RPC dispatcher does."
|
||||
[run cmd params]
|
||||
((run cmd params) {}))
|
||||
|
||||
(defn- body-size
|
||||
[response]
|
||||
(alength (.readAllBytes ^java.io.InputStream (::yres/body response))))
|
||||
|
||||
(t/deftest export-returns-download
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
response (run-response run "export-files" {:file-ids [(:id file)]})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (= "application/octet-stream"
|
||||
(get (::yres/headers response) "content-type")))
|
||||
(t/is (.endsWith (str (get (::yres/headers response) "content-disposition"))
|
||||
".penpot"))
|
||||
(t/is (pos? (body-size response)))))
|
||||
|
||||
(t/deftest export-without-ids-gives-params-validation
|
||||
(let [{:keys [profile]} (create-file 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "export-files" {:file-ids []}))))))
|
||||
|
||||
(t/deftest export-unlisted-session-rejected
|
||||
(let [{:keys [file]} (create-file 1)
|
||||
profile (th/create-profile* 9)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "export-files" {:file-ids [(:id file)]}))))))
|
||||
|
||||
(t/deftest export-clone-creates-file-in-operator-project
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
project-id (:default-project-id profile)
|
||||
count-files (fn []
|
||||
(:count (db/exec-one! th/*system*
|
||||
["SELECT count(*) AS count FROM file WHERE project_id = ? AND deleted_at IS NULL"
|
||||
project-id])))
|
||||
before (count-files)
|
||||
response (run-response run "export-files" {:file-ids [(:id file)]
|
||||
:clone true})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (= "OK CLONED" (::yres/body response)))
|
||||
(t/is (= (inc before) (count-files)))))
|
||||
|
||||
(defn- export-to-tmp
|
||||
[file-id]
|
||||
(let [path (tmp/tempfile :prefix "penpot.import-test." :min-age "30m")]
|
||||
(with-open [output (io/output-stream path)]
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{file-id})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false)
|
||||
(bf.v3/export-files! output)))
|
||||
path))
|
||||
|
||||
(t/deftest export-anonymous-rejected
|
||||
(let [{:keys [file]} (create-file 1)]
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{} "export-files" {:file-ids [(:id file)]}))))))
|
||||
|
||||
(t/deftest import-anonymous-rejected
|
||||
(let [{:keys [file]} (create-file 1)
|
||||
path (export-to-tmp (:id file))]
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{} "import-files" {:file {:path path}}))))))
|
||||
|
||||
(t/deftest import-roundtrip-creates-file-in-operator-project
|
||||
(let [{:keys [profile file]} (create-file 1)
|
||||
run (as-superuser profile)
|
||||
project-id (:default-project-id profile)
|
||||
count-files (fn []
|
||||
(:count (db/exec-one! th/*system*
|
||||
["SELECT count(*) AS count FROM file WHERE project_id = ? AND deleted_at IS NULL"
|
||||
project-id])))
|
||||
before (count-files)
|
||||
path (export-to-tmp (:id file))
|
||||
response (run-response run "import-files" {:file {:path path
|
||||
:filename "roundtrip.penpot"}})]
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (= "OK" (::yres/body response)))
|
||||
(t/is (= (inc before) (count-files)))))
|
||||
|
||||
(t/deftest import-without-file-gives-params-validation
|
||||
(let [{:keys [profile]} (create-file 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "import-files" {}))))))
|
||||
|
||||
(t/deftest import-unlisted-session-rejected
|
||||
(let [{:keys [file]} (create-file 1)
|
||||
profile (th/create-profile* 9)
|
||||
run (as-session #{} profile)
|
||||
path (export-to-tmp (:id file))]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "import-files" {:file {:path path}}))))))
|
||||
309
backend/test/backend_tests/rpc_admin_jobs_test.clj
Normal file
309
backend/test/backend_tests/rpc_admin_jobs_test.clj
Normal file
@ -0,0 +1,309 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-jobs-test
|
||||
"Tests for the superuser-guarded job inspection commands."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- raw-method
|
||||
"The raw [f mdata] behind `cmd-name` in the admin namespace."
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.job)))
|
||||
|
||||
(defn- call
|
||||
"Invoke an admin command through the real wrapper chain with a
|
||||
hand-built config.
|
||||
|
||||
The chain closes over its cfg at startup, so the superuser set is
|
||||
injected per call here instead of going through `th/command!`.
|
||||
Qualified keys stay in `params` as server context; the rest goes
|
||||
into the request, where params validation reads it from."
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- as-superuser
|
||||
[profile]
|
||||
(as-session #{(:id profile)} profile))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
(defn- mk-job
|
||||
"Insert a `job` row straight into the table, newest-first friendly:
|
||||
`created-offset` shifts `created-at` back by that many seconds so
|
||||
pagination tests own their order without sleeping."
|
||||
[{:keys [name queue status profile-id tenant created-offset]
|
||||
:or {name "sendmail"
|
||||
queue "default"
|
||||
status "new"
|
||||
tenant (cf/get :tenant)
|
||||
created-offset 0}}]
|
||||
(let [id (uuid/next)
|
||||
now (ct/plus (ct/now) (ct/duration {:seconds (- created-offset)}))]
|
||||
(th/db-insert! :job {:id id
|
||||
:name name
|
||||
:tenant tenant
|
||||
:queue queue
|
||||
:params (db/json {:marker (str id)})
|
||||
:priority 100
|
||||
:max-retries 3
|
||||
:retry-num 0
|
||||
:status status
|
||||
:profile-id profile-id
|
||||
:scheduled-at now
|
||||
:created-at now
|
||||
:modified-at now})
|
||||
id))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Detail
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest detail-system-job-has-no-owner
|
||||
(let [admin (th/create-profile* 1)
|
||||
id (mk-job {:name "objects-gc" :status "completed"})
|
||||
run (as-superuser admin)
|
||||
out (run "get-job" {:id id})]
|
||||
(t/is (= id (:id out)))
|
||||
(t/is (= "objects-gc" (:name out)))
|
||||
(t/is (= "system" (:kind out)))
|
||||
(t/is (not (contains? out :owner-email)))
|
||||
(t/is (string? (:params-pretty out)))
|
||||
(t/is (not (contains? out :params))
|
||||
"the raw payload stays out: only the bounded pretty text travels")))
|
||||
|
||||
(t/deftest detail-user-job-resolves-owner
|
||||
(let [admin (th/create-profile* 1)
|
||||
user (th/create-profile* 2)
|
||||
id (mk-job {:profile-id (:id user)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-job" {:id id})]
|
||||
(t/is (= "user" (:kind out)))
|
||||
(t/is (= (:id user) (:profile-id out)))
|
||||
(t/is (= (:email user) (:owner-email out)))
|
||||
(t/is (= (:fullname user) (:owner-fullname out)))))
|
||||
|
||||
(t/deftest detail-returns-events-oldest-first
|
||||
(let [admin (th/create-profile* 1)
|
||||
id (mk-job {:status "completed"})
|
||||
_ (doseq [[kind payload] [["start" {}]
|
||||
["progress" {:current 1 :total 2}]
|
||||
["retry" {:attempt 2 :reason "backoff"}]
|
||||
["end" {:outcome "completed"}]]]
|
||||
(th/db-insert! :job-event {:job-id id
|
||||
:kind kind
|
||||
:payload (db/json payload)}))
|
||||
run (as-superuser admin)
|
||||
out (run "get-job" {:id id})]
|
||||
(t/is (= ["start" "progress" "retry" "end"]
|
||||
(mapv :kind (:events out))))
|
||||
(t/is (= {:outcome "completed"} (:payload (last (:events out)))))
|
||||
(t/is (every? some? (map :created-at (:events out))))))
|
||||
|
||||
(t/deftest detail-bounds-params-pretty
|
||||
(let [admin (th/create-profile* 1)
|
||||
deep (reduce (fn [acc _] {:nest acc}) {:leaf (range 100)} (range 30))
|
||||
id (mk-job {})
|
||||
_ (th/db-update! :job {:params (db/json deep)} {:id id})
|
||||
run (as-superuser admin)
|
||||
out (run "get-job" {:id id})]
|
||||
(t/is (<= (count (:params-pretty out)) 4100))
|
||||
(t/is (not (str/includes? (:params-pretty out) "99"))
|
||||
"depth and length bounds cut the payload before the leaf")))
|
||||
|
||||
(t/deftest detail-unknown-id-gives-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :job-not-found
|
||||
(caught-code #(run "get-job" {:id (uuid/next)}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Cancel
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- job-status
|
||||
"Raw status plus the count of `end` events, to prove the
|
||||
transition wrote exactly its own history."
|
||||
[id]
|
||||
{:status (:status (th/db-get :job {:id id}))
|
||||
:ends (:count (th/db-exec-one!
|
||||
["SELECT count(*) AS count FROM job_event
|
||||
WHERE job_id = ? AND kind = 'end'" id]))})
|
||||
|
||||
(t/deftest cancel-moves-live-jobs-to-cancelled
|
||||
(let [admin (th/create-profile* 1)
|
||||
run (as-superuser admin)]
|
||||
(doseq [status ["new" "scheduled" "retry" "running"]]
|
||||
(let [id (mk-job {:status status})
|
||||
out (run "cancel-job" {:id id})]
|
||||
(t/is (= true (:cancelled out)) (str "cancels " status))
|
||||
(t/is (= "cancelled" (:status (job-status id))))
|
||||
(t/is (= 1 (:ends (job-status id))) "exactly one end event")))))
|
||||
|
||||
(t/deftest cancel-leaves-terminal-jobs-untouched
|
||||
(let [admin (th/create-profile* 1)
|
||||
run (as-superuser admin)]
|
||||
(doseq [status ["completed" "failed" "cancelled" "aborted"]]
|
||||
(let [id (mk-job {:status status})
|
||||
out (run "cancel-job" {:id id})]
|
||||
(t/is (= false (:cancelled out)) (str "refuses " status))
|
||||
(t/is (= status (:status (job-status id))))
|
||||
(t/is (= 0 (:ends (job-status id))) "writes no event")))))
|
||||
|
||||
(t/deftest cancel-unknown-id-gives-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :job-not-found
|
||||
(caught-code #(run "cancel-job" {:id (uuid/next)}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Guard
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest unlisted-session-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)
|
||||
missing (uuid/next)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-jobs" {}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-job" {:id missing}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "cancel-job" {:id missing}))))))
|
||||
|
||||
(t/deftest anonymous-rejected
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"get-jobs" {})))))
|
||||
|
||||
(t/deftest invalid-params-rejected-for-listed-caller
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "get-jobs" {:limit 500}))))
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "get-jobs" {:status "exploding"}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Listing
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest list-returns-newest-first
|
||||
(let [admin (th/create-profile* 1)
|
||||
old (mk-job {:created-offset 30 :status "completed"})
|
||||
new (mk-job {:created-offset 0 :status "completed"})
|
||||
run (as-superuser admin)
|
||||
out (run "get-jobs" {})
|
||||
item (first (:items out))]
|
||||
(t/is (= [new old] (mapv :id (:items out))))
|
||||
(t/is (= "sendmail" (:name item)))
|
||||
(t/is (= "default" (:queue item)))
|
||||
(t/is (= "completed" (:status item)))
|
||||
(t/is (= "system" (:kind item)))
|
||||
(t/is (= 0 (:retry-num item)))
|
||||
(t/is (= 3 (:max-retries item)))
|
||||
(t/is (some? (:created-at item)))
|
||||
(t/is (some? (:scheduled-at item)))
|
||||
(t/is (not (contains? item :params))
|
||||
"the list stays light: no business payload")))
|
||||
|
||||
(t/deftest list-marks-user-jobs
|
||||
(let [admin (th/create-profile* 1)
|
||||
user (th/create-profile* 2)
|
||||
owned (mk-job {:profile-id (:id user)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-jobs" {:id owned})
|
||||
item (first (:items out))]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (= "user" (:kind item)))
|
||||
(t/is (= (:id user) (:profile-id item)))))
|
||||
|
||||
(t/deftest list-filters-by-name-and-status
|
||||
(let [admin (th/create-profile* 1)
|
||||
failed-mail (mk-job {:name "sendmail" :status "failed"})
|
||||
_ok-mail (mk-job {:name "sendmail" :status "completed"})
|
||||
_failed-gc (mk-job {:name "objects-gc" :status "failed"})
|
||||
run (as-superuser admin)]
|
||||
(let [out (run "get-jobs" {:name "sendmail"})]
|
||||
(t/is (= 2 (count (:items out))))
|
||||
(t/is (every? #(= "sendmail" (:name %)) (:items out))))
|
||||
(let [out (run "get-jobs" {:status "failed"})]
|
||||
(t/is (= 2 (count (:items out))))
|
||||
(t/is (every? #(= "failed" (:status %)) (:items out))))
|
||||
(let [out (run "get-jobs" {:name "sendmail" :status "failed"})]
|
||||
(t/is (= [failed-mail] (mapv :id (:items out)))))))
|
||||
|
||||
(t/deftest list-looks-up-by-id
|
||||
(let [admin (th/create-profile* 1)
|
||||
one (mk-job {})
|
||||
_two (mk-job {})
|
||||
run (as-superuser admin)
|
||||
out (run "get-jobs" {:id one})]
|
||||
(t/is (= [one] (mapv :id (:items out))))))
|
||||
|
||||
(t/deftest list-hides-other-tenants
|
||||
(let [admin (th/create-profile* 1)
|
||||
foreign (mk-job {:tenant "other-tenant"})
|
||||
_mine (mk-job {})
|
||||
run (as-superuser admin)
|
||||
out (run "get-jobs" {})]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (not (contains? (set (mapv :id (:items out))) foreign)))))
|
||||
|
||||
(t/deftest list-paginates
|
||||
(let [admin (th/create-profile* 1)
|
||||
ids (mapv #(mk-job {:created-offset (* 10 %)}) (range 5))
|
||||
run (as-superuser admin)
|
||||
first-page (run "get-jobs" {:limit 2})
|
||||
_ (t/is (= (take 2 ids) (mapv :id (:items first-page))))
|
||||
second-page (run "get-jobs" {:limit 2
|
||||
:since (:next-since first-page)
|
||||
:since-id (:next-id first-page)})]
|
||||
(t/is (= (take 2 (drop 2 ids)) (mapv :id (:items second-page))))
|
||||
(t/is (some? (:next-since second-page)))))
|
||||
151
backend/test/backend_tests/rpc_admin_misc_test.clj
Normal file
151
backend/test/backend_tests/rpc_admin_misc_test.clj
Normal file
@ -0,0 +1,151 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-misc-test
|
||||
"Tests for the superuser-guarded misc commands (virtual clock)."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.setup.clock :as clock]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- raw-method
|
||||
"The raw [f mdata] behind `cmd-name` in the admin misc namespace."
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.misc)))
|
||||
|
||||
(defn- call
|
||||
"Invoke an admin command through the real wrapper chain with a
|
||||
hand-built config. See rpc-admin-error-reports-test for why the
|
||||
superuser set is injected per call."
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; get-virtual-clock
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest listed-session-reads-clock
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)
|
||||
result (run "get-virtual-clock" {})]
|
||||
(t/is (nil? (:offset-millis result)))
|
||||
(t/is (= "system" (:clock result)))
|
||||
(t/is (some? (:now result)))))
|
||||
|
||||
(t/deftest unlisted-session-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-virtual-clock" {}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "set-virtual-clock" {:offset "1h"}))))))
|
||||
|
||||
(t/deftest unlisted-token-without-scope-rejected
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(call #{} :token (:id profile) #{}
|
||||
"get-virtual-clock" {}))))))
|
||||
|
||||
(t/deftest token-with-granted-superuser-passes
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= "system"
|
||||
(:clock (call #{} :token (:id profile) #{"superuser"}
|
||||
"get-virtual-clock" {}))))))
|
||||
|
||||
(t/deftest anonymous-rejected
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"get-virtual-clock" {})))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; set-virtual-clock
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest listed-session-sets-and-reads-back
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(try
|
||||
(let [result (run "set-virtual-clock" {:offset "1h"})]
|
||||
(t/is (= 3600000 (:offset-millis result)))
|
||||
(t/is (= "offset" (:clock result)))
|
||||
(t/is (= 3600000 (:offset-millis (run "get-virtual-clock" {})))))
|
||||
(finally
|
||||
(clock/assign-offset (:id profile) nil)))))
|
||||
|
||||
(t/deftest reset-clears-offset
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(try
|
||||
(run "set-virtual-clock" {:offset "1h"})
|
||||
(let [result (run "set-virtual-clock" {:reset true})]
|
||||
(t/is (nil? (:offset-millis result)))
|
||||
(t/is (= "system" (:clock result))))
|
||||
(finally
|
||||
(clock/assign-offset (:id profile) nil)))))
|
||||
|
||||
(t/deftest zero-duration-clears-offset
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(try
|
||||
(run "set-virtual-clock" {:offset "1h"})
|
||||
(let [result (run "set-virtual-clock" {:offset "0s"})]
|
||||
(t/is (nil? (:offset-millis result)))
|
||||
(t/is (= "system" (:clock result))))
|
||||
(finally
|
||||
(clock/assign-offset (:id profile) nil)))))
|
||||
|
||||
(t/deftest invalid-offset-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "set-virtual-clock"
|
||||
{:offset "not-a-duration"}))))))
|
||||
|
||||
(t/deftest missing-offset-and-reset-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{(:id profile)} profile)]
|
||||
(t/is (= :missing-arguments
|
||||
(caught-code #(run "set-virtual-clock" {}))))))
|
||||
265
backend/test/backend_tests/rpc_admin_project_test.clj
Normal file
265
backend/test/backend_tests/rpc_admin_project_test.clj
Normal file
@ -0,0 +1,265 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-project-test
|
||||
"Tests for the superuser-guarded project listing command."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- raw-method
|
||||
"The raw [f mdata] behind `cmd-name` in the admin namespace."
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.project)))
|
||||
|
||||
(defn- call
|
||||
"Invoke an admin command through the real wrapper chain with a
|
||||
hand-built config.
|
||||
|
||||
The chain closes over its cfg at startup, so the superuser set is
|
||||
injected per call here instead of going through `th/command!`.
|
||||
Qualified keys stay in `params` as server context; the rest goes
|
||||
into the request, where params validation reads it from."
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- as-superuser
|
||||
[profile]
|
||||
(as-session #{(:id profile)} profile))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
(defn- create-project
|
||||
"Create a profile, a team and a project with a distinct name.
|
||||
Returns the ids plus the profile."
|
||||
[i]
|
||||
(let [profile (th/create-profile* i)
|
||||
team (th/create-team* i {:profile-id (:id profile)})
|
||||
project (th/create-project* i {:profile-id (:id profile)
|
||||
:team-id (:id team)
|
||||
:name (str "searchable-project-" i)})]
|
||||
{:profile profile :team team :project project}))
|
||||
|
||||
(defn- mark-deleted
|
||||
[table id]
|
||||
(db/update! th/*system* table {:deleted-at (ct/now)} {:id id}))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Guard
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest unlisted-session-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-projects" {}))))))
|
||||
|
||||
(t/deftest unlisted-token-without-scope-rejected
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(call #{} :token (:id profile) #{}
|
||||
"get-projects" {}))))))
|
||||
|
||||
(t/deftest token-with-granted-superuser-passes
|
||||
(let [{:keys [profile project]} (create-project 1)
|
||||
out (call #{} :token (:id profile) #{"superuser"}
|
||||
"get-projects" {:id (:id project)})]
|
||||
(t/is (= 1 (count (:items out))))))
|
||||
|
||||
(t/deftest anonymous-rejected
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"get-projects" {})))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Listing
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest list-returns-projects-with-team-and-counts
|
||||
(let [{:keys [profile team project]} (create-project 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
_ (:id file)
|
||||
run (as-superuser profile)
|
||||
out (run "get-projects" {:id (:id project)})
|
||||
item (first (:items out))]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (= (:id project) (:id item)))
|
||||
(t/is (= (:name project) (:name item)))
|
||||
(t/is (= (:id team) (:team-id item)))
|
||||
(t/is (string? (:team-name item)))
|
||||
(t/is (boolean? (:is-default item)))
|
||||
(t/is (= 1 (:total-files item)))
|
||||
(t/is (some? (:created-at item)))
|
||||
(t/is (some? (:modified-at item)))))
|
||||
|
||||
(t/deftest list-finds-by-exact-id
|
||||
(let [{:keys [profile project]} (create-project 1)
|
||||
_ (create-project 2)
|
||||
run (as-superuser profile)
|
||||
out (run "get-projects" {:id (:id project)})]
|
||||
(t/is (= [(:id project)] (mapv :id (:items out))))))
|
||||
|
||||
(t/deftest list-finds-by-string-id
|
||||
(let [{:keys [profile project]} (create-project 1)
|
||||
run (as-superuser profile)
|
||||
out (run "get-projects" {:id (str (:id project))})]
|
||||
(t/is (= [(:id project)] (mapv :id (:items out)))
|
||||
"the browser sends ids as strings")))
|
||||
|
||||
(t/deftest list-returns-newest-first-without-gaps
|
||||
(let [{:keys [profile]} (create-project 1)
|
||||
_ (create-project 2)
|
||||
_ (create-project 3)
|
||||
run (as-superuser profile)
|
||||
out (run "get-projects" {:limit 50})
|
||||
items (:items out)
|
||||
keys (mapv (juxt :created-at :id) items)]
|
||||
(t/is (= keys (sort #(compare %2 %1) keys))
|
||||
"pages follow created_at DESC, id DESC")
|
||||
(t/is (= (count keys) (count (distinct (map :id items))))
|
||||
"no duplicates across the order")))
|
||||
|
||||
(t/deftest list-filters-by-deleted
|
||||
(let [{:keys [profile project]} (create-project 1)
|
||||
_ (mark-deleted :project (:id project))
|
||||
run (as-superuser profile)]
|
||||
(let [out (run "get-projects" {:deleted true})]
|
||||
(t/is (= [(:id project)] (mapv :id (:items out)))))
|
||||
(let [out (run "get-projects" {:deleted false})
|
||||
ids (set (map :id (:items out)))]
|
||||
(t/is (contains? ids (:default-project-id profile))
|
||||
"the live default project lists")
|
||||
(t/is (not (contains? ids (:id project)))
|
||||
"the deleted project is excluded"))
|
||||
(let [out (run "get-projects" {})
|
||||
ids (set (map :id (:items out)))]
|
||||
(t/is (contains? ids (:id project))
|
||||
"without the flag, deleted rows still list"))))
|
||||
|
||||
(t/deftest list-filters-by-team
|
||||
(let [{:keys [profile team project]} (create-project 1)
|
||||
other (create-project 2)
|
||||
run (as-superuser profile)
|
||||
out (run "get-projects" {:team-id (:id team)})
|
||||
ids (set (map :id (:items out)))]
|
||||
(t/is (contains? ids (:id project)))
|
||||
(t/is (not (contains? ids (:id (:project other)))))))
|
||||
|
||||
(t/deftest list-shows-deleted-projects
|
||||
(let [{:keys [profile project]} (create-project 1)
|
||||
_ (mark-deleted :project (:id project))
|
||||
run (as-superuser profile)
|
||||
out (run "get-projects" {:id (:id project)})
|
||||
item (first (:items out))]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (= (:id project) (:id item)))
|
||||
(t/is (some? (:deleted-at item)))))
|
||||
|
||||
(t/deftest list-hides-projects-of-deleted-teams
|
||||
(let [{:keys [profile team project]} (create-project 1)
|
||||
_ (mark-deleted :team (:id team))
|
||||
run (as-superuser profile)
|
||||
out (run "get-projects" {:id (:id project)})]
|
||||
(t/is (= [] (:items out)))))
|
||||
|
||||
(t/deftest list-counts-only-live-files
|
||||
(let [{:keys [profile project]} (create-project 1)
|
||||
file (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:id project)})
|
||||
run (as-superuser profile)
|
||||
before (run "get-projects" {:id (:id project)})]
|
||||
(t/is (= 1 (:total-files (first (:items before)))))
|
||||
(th/mark-file-deleted* {:id (:id file)})
|
||||
(let [after (run "get-projects" {:id (:id project)})]
|
||||
(t/is (= 0 (:total-files (first (:items after))))))))
|
||||
|
||||
(t/deftest list-paginates
|
||||
(let [{:keys [profile]} (create-project 1)
|
||||
_ (create-project 2)
|
||||
run (as-superuser profile)
|
||||
page1 (run "get-projects" {:limit 1})]
|
||||
(t/is (= 1 (count (:items page1))))
|
||||
(t/is (some? (:next-since page1)))
|
||||
(t/is (some? (:next-id page1)))
|
||||
(let [page2 (run "get-projects" {:limit 1
|
||||
:since (:next-since page1)
|
||||
:since-id (:next-id page1)})]
|
||||
(t/is (= 1 (count (:items page2))))
|
||||
(t/is (not= (-> page1 :items first :id)
|
||||
(-> page2 :items first :id))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Detail
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest detail-unlisted-session-rejected
|
||||
(let [{:keys [profile project]} (create-project 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-project" {:id (:id project)}))))))
|
||||
|
||||
(t/deftest detail-resolves-regardless-of-deleted-state
|
||||
(let [{:keys [profile team project]} (create-project 1)
|
||||
run (as-superuser profile)]
|
||||
(let [out (run "get-project" {:id (:id project)})]
|
||||
(t/is (= (:id project) (:id out)))
|
||||
(t/is (= (:name project) (:name out)))
|
||||
(t/is (= (:id team) (:team-id out))))
|
||||
;; A stray live project under a deleted team still resolves:
|
||||
;; the admin must reach objects the list hides.
|
||||
(mark-deleted :team (:id team))
|
||||
(let [out (run "get-project" {:id (:id project)})]
|
||||
(t/is (= (:id project) (:id out))))
|
||||
(mark-deleted :project (:id project))
|
||||
(let [out (run "get-project" {:id (:id project)})]
|
||||
(t/is (some? (:deleted-at out))))))
|
||||
|
||||
(t/deftest detail-unknown-id-gives-not-found
|
||||
(let [{:keys [profile]} (create-project 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :project-not-found
|
||||
(caught-code #(run "get-project" {:id (uuid/next)}))))))
|
||||
292
backend/test/backend_tests/rpc_admin_restore_test.clj
Normal file
292
backend/test/backend_tests/rpc_admin_restore_test.clj
Normal file
@ -0,0 +1,292 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-restore-test
|
||||
"Tests for the superuser-guarded restore commands."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.features.object-cascade :as cascade]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- raw-method
|
||||
"The raw [f mdata] behind `cmd-name` in the admin namespace."
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.file
|
||||
'app.rpc.admin.project
|
||||
'app.rpc.admin.team
|
||||
'app.rpc.admin.profile)))
|
||||
|
||||
(defn- call
|
||||
"Invoke an admin command through the real wrapper chain with a
|
||||
hand-built config.
|
||||
|
||||
The chain closes over its cfg at startup, so the superuser set is
|
||||
injected per call here instead of going through `th/command!`.
|
||||
Qualified keys stay in `params` as server context; the rest goes
|
||||
into the request, where params validation reads it from."
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- as-superuser
|
||||
[profile]
|
||||
(as-session #{(:id profile)} profile))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
(defn- create-file
|
||||
[i]
|
||||
(let [profile (th/create-profile* i)
|
||||
team (th/create-team* i {:profile-id (:id profile)})
|
||||
project (th/create-project* i {:profile-id (:id profile)
|
||||
:team-id (:id team)})
|
||||
file (th/create-file* i {:profile-id (:id profile)
|
||||
:project-id (:id project)})]
|
||||
{:profile profile :team team :project project :file file}))
|
||||
|
||||
(defn- deleted-at
|
||||
"Raw `deleted_at`, bypassing the soft-delete filter."
|
||||
[table match]
|
||||
(:deleted-at (db/get* th/*system* table match {::db/remove-deleted false})))
|
||||
|
||||
(defn- delete-object
|
||||
"Delete through the shared cascade, exercising the same path as
|
||||
the worker."
|
||||
[object id]
|
||||
(db/tx-run! th/*system* cascade/update-cascade object id {:deleted-at (ct/now)}))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Guard
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest unlisted-session-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)
|
||||
missing (uuid/next)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "restore-file" {:id missing}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "restore-project" {:id missing}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "restore-team" {:id missing}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "restore-profile" {:id missing}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Not found
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest restore-unknown-ids-give-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)
|
||||
missing (uuid/next)]
|
||||
(t/is (= :file-not-found
|
||||
(caught-code #(run "restore-file" {:id missing}))))
|
||||
(t/is (= :project-not-found
|
||||
(caught-code #(run "restore-project" {:id missing}))))
|
||||
(t/is (= :team-not-found
|
||||
(caught-code #(run "restore-team" {:id missing}))))
|
||||
(t/is (= :profile-not-found
|
||||
(caught-code #(run "restore-profile" {:id missing}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Delete keeps the earliest date
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest delete-team-keeps-earlier-child-timestamp
|
||||
;; Rows deleted earlier keep their date so a later parent delete
|
||||
;; never postpones their purge.
|
||||
(let [{:keys [team project file]} (create-file 1)
|
||||
past (-> (ct/now) (ct/minus {:hours 1}) (ct/truncate :millisecond))]
|
||||
(db/update! th/*system* :project {:deleted-at past} {:id (:id project)})
|
||||
(db/update! th/*system* :file {:deleted-at past} {:id (:id file)})
|
||||
(db/update! th/*system* :file-data {:deleted-at past} {:file-id (:id file)})
|
||||
(delete-object :team (:id team))
|
||||
(t/is (= past (deleted-at :project {:id (:id project)})))
|
||||
(t/is (= past (deleted-at :file {:id (:id file)})))
|
||||
(t/is (= past (deleted-at :file-data {:file-id (:id file)})))
|
||||
(t/is (some? (deleted-at :team {:id (:id team)})))))
|
||||
|
||||
(t/deftest delete-team-brings-forward-later-child-timestamp
|
||||
;; Rows with a later date take the parent operation date so no
|
||||
;; child outlives its parent's purge.
|
||||
(let [{:keys [team file]} (create-file 1)
|
||||
future (-> (ct/now) (ct/plus {:hours 1}) (ct/truncate :millisecond))]
|
||||
(db/update! th/*system* :file {:deleted-at future} {:id (:id file)})
|
||||
(delete-object :team (:id team))
|
||||
(let [team-at (deleted-at :team {:id (:id team)})
|
||||
file-at (deleted-at :file {:id (:id file)})]
|
||||
(t/is (= team-at file-at))
|
||||
(t/is (ct/is-before? file-at future)))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; File restores its chain up to the team
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest restore-file-restores-project-and-team-chain
|
||||
(let [{:keys [profile team project file]} (create-file 1)
|
||||
_ (delete-object :team (:id team))
|
||||
run (as-superuser profile)
|
||||
out (run "restore-file" {:id (:id file)})]
|
||||
(t/is (= (:id file) (:id out)))
|
||||
(t/is (not (contains? out :recursive)))
|
||||
(t/is (nil? (deleted-at :file {:id (:id file)})))
|
||||
(t/is (nil? (deleted-at :project {:id (:id project)})))
|
||||
(t/is (nil? (deleted-at :team {:id (:id team)})))
|
||||
(t/is (some? (deleted-at :file-data {:file-id (:id file)})))
|
||||
(th/run-pending-jobs)
|
||||
(t/is (nil? (deleted-at :file-data {:file-id (:id file)})))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Project restores its team
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest restore-project-restores-team-and-files
|
||||
(let [{:keys [profile team project file]} (create-file 1)
|
||||
_ (delete-object :team (:id team))
|
||||
run (as-superuser profile)
|
||||
out (run "restore-project" {:id (:id project)})]
|
||||
(t/is (= (:id project) (:id out)))
|
||||
(t/is (not (contains? out :recursive)))
|
||||
(t/is (nil? (deleted-at :project {:id (:id project)})))
|
||||
(t/is (nil? (deleted-at :team {:id (:id team)})))
|
||||
(t/is (some? (deleted-at :file {:id (:id file)})))
|
||||
(th/run-pending-jobs)
|
||||
(t/is (nil? (deleted-at :file {:id (:id file)})))
|
||||
(t/is (nil? (deleted-at :file-data {:file-id (:id file)})))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Team restores its cascade
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest restore-team-restores-cascade
|
||||
(let [{:keys [profile team project file]} (create-file 1)
|
||||
_ (delete-object :team (:id team))
|
||||
run (as-superuser profile)
|
||||
out (run "restore-team" {:id (:id team)})]
|
||||
(t/is (= (:id team) (:id out)))
|
||||
(t/is (not (contains? out :recursive)))
|
||||
(t/is (nil? (deleted-at :team {:id (:id team)})))
|
||||
(t/is (some? (deleted-at :project {:id (:id project)})))
|
||||
(th/run-pending-jobs)
|
||||
(t/is (nil? (deleted-at :project {:id (:id project)})))
|
||||
(t/is (nil? (deleted-at :file {:id (:id file)})))
|
||||
(t/is (nil? (deleted-at :file-data {:file-id (:id file)})))))
|
||||
|
||||
(t/deftest restore-team-row-sync-cascade-async
|
||||
;; The team's own row reads back instantly; the children cascade
|
||||
;; runs as a worker task afterwards. Same split as delete.
|
||||
(let [{:keys [profile team project file]} (create-file 1)
|
||||
_ (delete-object :team (:id team))
|
||||
run (as-superuser profile)
|
||||
out (run "restore-team" {:id (:id team)})]
|
||||
(t/is (= (:id team) (:id out)))
|
||||
(t/is (nil? (deleted-at :team {:id (:id team)})))
|
||||
(t/is (some? (deleted-at :project {:id (:id project)})))
|
||||
(th/run-pending-jobs)
|
||||
(t/is (nil? (deleted-at :project {:id (:id project)})))
|
||||
(t/is (nil? (deleted-at :file {:id (:id file)})))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Profile restores its teams, never the other way round
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest restore-profile-restores-owned-teams
|
||||
(let [profile (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})
|
||||
_ (delete-object :profile (:id profile))
|
||||
run (as-superuser profile)
|
||||
out (run "restore-profile" {:id (:id profile)})]
|
||||
(t/is (= (:id profile) (:id out)))
|
||||
(t/is (not (contains? out :recursive)))
|
||||
(t/is (nil? (deleted-at :profile {:id (:id profile)})))
|
||||
(t/is (some? (deleted-at :team {:id (:id team)})))
|
||||
(th/run-pending-jobs)
|
||||
(t/is (nil? (deleted-at :team {:id (:id team)})))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; No live owner, no restore
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest restore-file-refuses-team-without-live-owner
|
||||
;; The owner profile is deleted, so restoring the file would leave
|
||||
;; it in an ownerless team: fail before writing anything.
|
||||
(let [{:keys [profile team project file]} (create-file 1)
|
||||
_ (delete-object :profile (:id profile))
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :team-has-no-live-owner
|
||||
(caught-code #(run "restore-file" {:id (:id file)}))))
|
||||
(t/is (some? (deleted-at :file {:id (:id file)})))
|
||||
(t/is (some? (deleted-at :project {:id (:id project)})))
|
||||
(t/is (some? (deleted-at :team {:id (:id team)})))))
|
||||
|
||||
(t/deftest restore-project-refuses-team-without-live-owner
|
||||
(let [{:keys [profile team project]} (create-file 1)
|
||||
_ (delete-object :profile (:id profile))
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :team-has-no-live-owner
|
||||
(caught-code #(run "restore-project" {:id (:id project)}))))
|
||||
(t/is (some? (deleted-at :project {:id (:id project)})))
|
||||
(t/is (some? (deleted-at :team {:id (:id team)})))))
|
||||
|
||||
(t/deftest restore-team-refuses-without-live-owner
|
||||
(let [{:keys [profile team]} (create-file 1)
|
||||
_ (delete-object :profile (:id profile))
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :team-has-no-live-owner
|
||||
(caught-code #(run "restore-team" {:id (:id team)}))))
|
||||
(t/is (some? (deleted-at :team {:id (:id team)})))))
|
||||
|
||||
(t/deftest restore-profile-first-unblocks-team-restore
|
||||
;; Restoring the owner profile first gives the team a live owner
|
||||
;; back, so the team restore is allowed afterwards.
|
||||
(let [{:keys [profile team]} (create-file 1)
|
||||
_ (delete-object :profile (:id profile))
|
||||
run (as-superuser profile)
|
||||
_ (run "restore-profile" {:id (:id profile)})
|
||||
_ (th/run-pending-jobs)
|
||||
out (run "restore-team" {:id (:id team)})]
|
||||
(t/is (= (:id team) (:id out)))
|
||||
(t/is (nil? (deleted-at :team {:id (:id team)})))))
|
||||
420
backend/test/backend_tests/rpc_admin_teams_test.clj
Normal file
420
backend/test/backend_tests/rpc_admin_teams_test.clj
Normal file
@ -0,0 +1,420 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-teams-test
|
||||
"Tests for the superuser-guarded team management commands."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- raw-method
|
||||
"The raw [f mdata] behind `cmd-name` in the admin namespace."
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.team)))
|
||||
|
||||
(defn- call
|
||||
"Invoke an admin command through the real wrapper chain with a
|
||||
hand-built config.
|
||||
|
||||
The chain closes over its cfg at startup, so the superuser set is
|
||||
injected per call here instead of going through `th/command!`.
|
||||
Qualified keys stay in `params` as server context; the rest goes
|
||||
into the request, where params validation reads it from."
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- as-superuser
|
||||
[profile]
|
||||
(as-session #{(:id profile)} profile))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Guard
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest unlisted-session-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-teams" {}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-team" {:id (uuid/next)}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-team-members" {:team-id (uuid/next)}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "enable-team-feature"
|
||||
{:team-id (uuid/next) :feature "components/v2"}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "disable-team-feature"
|
||||
{:team-id (uuid/next) :feature "components/v2"}))))))
|
||||
|
||||
(t/deftest unlisted-token-without-scope-rejected
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(call #{} :token (:id profile) #{}
|
||||
"get-teams" {}))))))
|
||||
|
||||
(t/deftest token-with-granted-superuser-passes
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
out (call #{} :token (:id admin) #{"superuser"}
|
||||
"get-teams" {:id (:id team)})]
|
||||
(t/is (= [(:id team)] (mapv :id (:items out))))))
|
||||
|
||||
(t/deftest anonymous-rejected
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"get-teams" {})))))
|
||||
|
||||
(t/deftest invalid-params-still-validated-for-listed-caller
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "get-teams" {:limit 500}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Listing
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest list-returns-teams-with-member-counts
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-teams" {:id (:id team)})
|
||||
item (first (:items out))]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (= (:id team) (:id item)))
|
||||
(t/is (= (:name team) (:name item)))
|
||||
(t/is (= 1 (:total-members item)))
|
||||
(t/is (= (:email admin) (:owner item)))
|
||||
(t/is (some? (:created-at item)))
|
||||
(t/is (some? (:modified-at item)))
|
||||
(t/is (false? (:is-default item)))
|
||||
(t/is (not (contains? item :features)))))
|
||||
|
||||
(t/deftest list-shows-deleted-teams
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
_ (db/update! th/*system* :team {:deleted-at (ct/now)} {:id (:id team)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-teams" {:id (:id team)})
|
||||
item (first (:items out))]
|
||||
(t/is (= 1 (count (:items out))))
|
||||
(t/is (= (:id team) (:id item)))
|
||||
(t/is (some? (:deleted-at item)))))
|
||||
|
||||
(t/deftest list-filters-by-deleted
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
_ (db/update! th/*system* :team {:deleted-at (ct/now)} {:id (:id team)})
|
||||
run (as-superuser admin)]
|
||||
(let [out (run "get-teams" {:deleted true})]
|
||||
(t/is (= [(:id team)] (mapv :id (:items out)))))
|
||||
(let [out (run "get-teams" {:deleted false})]
|
||||
(t/is (= [(:default-team-id admin)] (mapv :id (:items out)))
|
||||
"only the live default team lists"))
|
||||
(let [out (run "get-teams" {})]
|
||||
(t/is (= 2 (count (:items out)))
|
||||
"without the flag, deleted rows still list"))))
|
||||
|
||||
(t/deftest list-paginates
|
||||
(let [admin (th/create-profile* 1)
|
||||
_t1 (th/create-team* 1 {:profile-id (:id admin)})
|
||||
_t2 (th/create-team* 2 {:profile-id (:id admin)})
|
||||
run (as-superuser admin)
|
||||
page1 (run "get-teams" {:limit 1})]
|
||||
(t/is (= 1 (count (:items page1))))
|
||||
(t/is (some? (:next-since page1)))
|
||||
(t/is (some? (:next-id page1)))
|
||||
(let [page2 (run "get-teams" {:limit 1
|
||||
:since (:next-since page1)
|
||||
:since-id (:next-id page1)})]
|
||||
(t/is (= 1 (count (:items page2))))
|
||||
(t/is (not= (-> page1 :items first :id)
|
||||
(-> page2 :items first :id))))))
|
||||
|
||||
(t/deftest list-finds-by-exact-id
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
_ (th/create-team* 2 {:profile-id (:id admin)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-teams" {:id (:id team)})]
|
||||
(t/is (= [(:id team)] (mapv :id (:items out))))))
|
||||
|
||||
(t/deftest list-finds-by-string-id
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-teams" {:id (str (:id team))})]
|
||||
(t/is (= [(:id team)] (mapv :id (:items out)))
|
||||
"the browser sends ids as strings")))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Detail
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest detail-returns-team-with-features-and-counts
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-team" {:id (:id team)})]
|
||||
(t/is (= (:id team) (:id out)))
|
||||
(t/is (= (:name team) (:name out)))
|
||||
(t/is (= 1 (:total-members out)))
|
||||
(t/is (= 1 (:total-projects out)))
|
||||
(t/is (vector? (:features out)))
|
||||
(t/is (vector? (:effective-features out)))
|
||||
(t/is (<= (count (:features out))
|
||||
(count (:effective-features out))))))
|
||||
|
||||
(t/deftest detail-unknown-id-gives-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :team-not-found
|
||||
(caught-code #(run "get-team" {:id (uuid/next)}))))))
|
||||
|
||||
(t/deftest detail-deleted-returns-with-stamp
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
_ (db/update! th/*system* :team {:deleted-at (ct/now)} {:id (:id team)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-team" {:id (:id team)})]
|
||||
(t/is (= (:id team) (:id out)))
|
||||
(t/is (some? (:deleted-at out)))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Members
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest members-returns-roles
|
||||
(let [admin (th/create-profile* 1)
|
||||
editor (th/create-profile* 2)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
_ (th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id editor)
|
||||
:role :editor})
|
||||
run (as-superuser admin)
|
||||
out (run "get-team-members" {:team-id (:id team)})
|
||||
by-id (into {} (map (juxt :id identity) out))]
|
||||
(t/is (= 2 (count out)))
|
||||
(t/is (true? (:is-owner (get by-id (:id admin)))))
|
||||
(t/is (false? (:is-owner (get by-id (:id editor)))))
|
||||
(t/is (= (:email editor) (:email (get by-id (:id editor)))))))
|
||||
|
||||
(t/deftest members-unknown-team-gives-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :team-not-found
|
||||
(caught-code #(run "get-team-members"
|
||||
{:team-id (uuid/next)}))))))
|
||||
|
||||
(t/deftest members-return-profile-status
|
||||
;; A deleted member stays listed (the rel row survives) but
|
||||
;; carries its profile status, so the panel can badge it.
|
||||
(let [admin (th/create-profile* 1)
|
||||
editor (th/create-profile* 2)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
_ (th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id editor)
|
||||
:role :editor})
|
||||
_ (db/update! th/*system* :profile
|
||||
{:deleted-at (ct/now)}
|
||||
{:id (:id editor)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-team-members" {:team-id (:id team)})
|
||||
by-id (into {} (map (juxt :id identity) out))]
|
||||
(t/is (= 2 (count out)))
|
||||
(t/is (nil? (:deleted-at (get by-id (:id admin)))))
|
||||
(t/is (some? (:deleted-at (get by-id (:id editor)))))
|
||||
(t/is (false? (:is-blocked (get by-id (:id editor)))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Feature toggles
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest enable-adds-feature-and-is-idempotent
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
run (as-superuser admin)
|
||||
out (run "enable-team-feature"
|
||||
{:team-id (:id team) :feature "text-editor/v2"})
|
||||
again (run "enable-team-feature"
|
||||
{:team-id (:id team) :feature "text-editor/v2"})]
|
||||
(t/is (= (:id team) (:id out)))
|
||||
(t/is (some #{"text-editor/v2"} (:features out)))
|
||||
(t/is (= (:features out) (:features again)))))
|
||||
|
||||
(t/deftest disable-removes-feature-and-is-idempotent
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
run (as-superuser admin)
|
||||
_ (run "enable-team-feature"
|
||||
{:team-id (:id team) :feature "text-editor/v2"})
|
||||
out (run "disable-team-feature"
|
||||
{:team-id (:id team) :feature "text-editor/v2"})
|
||||
again (run "disable-team-feature"
|
||||
{:team-id (:id team) :feature "text-editor/v2"})]
|
||||
(t/is (not (some #{"text-editor/v2"} (:features out))))
|
||||
(t/is (= (:features out) (:features again)))))
|
||||
|
||||
(t/deftest unsupported-feature-rejected-without-changes
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
run (as-superuser admin)
|
||||
before (run "get-team" {:id (:id team)})]
|
||||
(t/is (= :feature-not-supported
|
||||
(caught-code #(run "enable-team-feature"
|
||||
{:team-id (:id team) :feature "nope/missing"}))))
|
||||
(t/is (= :feature-not-supported
|
||||
(caught-code #(run "disable-team-feature"
|
||||
{:team-id (:id team) :feature "nope/missing"}))))
|
||||
(let [after (run "get-team" {:id (:id team)})]
|
||||
(t/is (= (:features before) (:features after))))))
|
||||
|
||||
(t/deftest toggles-on-unknown-team-give-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)
|
||||
missing (uuid/next)]
|
||||
(t/is (= :team-not-found
|
||||
(caught-code #(run "enable-team-feature"
|
||||
{:team-id missing :feature "text-editor/v2"}))))
|
||||
(t/is (= :team-not-found
|
||||
(caught-code #(run "disable-team-feature"
|
||||
{:team-id missing :feature "text-editor/v2"}))))))
|
||||
|
||||
(t/deftest writes-work-on-deleted-team
|
||||
(let [admin (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
_ (db/update! th/*system* :team {:deleted-at (ct/now)} {:id (:id team)})
|
||||
run (as-superuser admin)]
|
||||
(t/is (= 1 (count (run "get-team-members" {:team-id (:id team)}))))
|
||||
(let [out (run "enable-team-feature"
|
||||
{:team-id (:id team) :feature "text-editor/v2"})]
|
||||
(t/is (some #{"text-editor/v2"} (:features out))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Member roles
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- role-of
|
||||
[member]
|
||||
(cond (:is-owner member) :owner
|
||||
(:is-admin member) :admin
|
||||
(:can-edit member) :editor
|
||||
:else :viewer))
|
||||
|
||||
(defn- roles-by-id
|
||||
[run team-id]
|
||||
(into {}
|
||||
(map (juxt :id role-of))
|
||||
(run "get-team-members" {:team-id team-id})))
|
||||
|
||||
(defn- create-team-with-editor
|
||||
[]
|
||||
(let [admin (th/create-profile* 1)
|
||||
editor (th/create-profile* 2)
|
||||
team (th/create-team* 1 {:profile-id (:id admin)})
|
||||
_ (th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id editor)
|
||||
:role :editor})]
|
||||
{:admin admin :editor editor :team team}))
|
||||
|
||||
(t/deftest update-role-guard
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "update-team-member-role"
|
||||
{:team-id (uuid/next)
|
||||
:member-id (uuid/next)
|
||||
:role "admin"}))))))
|
||||
|
||||
(t/deftest update-role-promotes-and-demotes
|
||||
(let [{:keys [admin editor team]} (create-team-with-editor)
|
||||
run (as-superuser admin)]
|
||||
(t/is (= :editor (get (roles-by-id run (:id team)) (:id editor))))
|
||||
(run "update-team-member-role"
|
||||
{:team-id (:id team) :member-id (:id editor) :role "admin"})
|
||||
(t/is (= :admin (get (roles-by-id run (:id team)) (:id editor))))
|
||||
(run "update-team-member-role"
|
||||
{:team-id (:id team) :member-id (:id editor) :role "viewer"})
|
||||
(t/is (= :viewer (get (roles-by-id run (:id team)) (:id editor))))))
|
||||
|
||||
(t/deftest update-role-promote-to-owner-transfers-seat
|
||||
(let [{:keys [admin editor team]} (create-team-with-editor)
|
||||
run (as-superuser admin)
|
||||
_ (run "update-team-member-role"
|
||||
{:team-id (:id team) :member-id (:id editor) :role "owner"})
|
||||
roles (roles-by-id run (:id team))]
|
||||
(t/is (= :owner (get roles (:id editor))))
|
||||
(t/is (= :admin (get roles (:id admin))))))
|
||||
|
||||
(t/deftest update-role-cannot-demote-owner-directly
|
||||
(let [{:keys [admin team]} (create-team-with-editor)
|
||||
run (as-superuser admin)]
|
||||
(t/is (= :cant-change-role-to-owner
|
||||
(caught-code #(run "update-team-member-role"
|
||||
{:team-id (:id team)
|
||||
:member-id (:id admin)
|
||||
:role "admin"}))))))
|
||||
|
||||
(t/deftest update-role-unknown-member-gives-not-found
|
||||
(let [{:keys [admin team]} (create-team-with-editor)
|
||||
run (as-superuser admin)]
|
||||
(t/is (= :member-does-not-exist
|
||||
(caught-code #(run "update-team-member-role"
|
||||
{:team-id (:id team)
|
||||
:member-id (uuid/next)
|
||||
:role "admin"}))))))
|
||||
|
||||
(t/deftest update-role-unknown-team-gives-not-found
|
||||
(let [{:keys [admin editor]} (create-team-with-editor)
|
||||
run (as-superuser admin)]
|
||||
(t/is (= :team-not-found
|
||||
(caught-code #(run "update-team-member-role"
|
||||
{:team-id (uuid/next)
|
||||
:member-id (:id editor)
|
||||
:role "admin"}))))))
|
||||
469
backend/test/backend_tests/rpc_admin_users_test.clj
Normal file
469
backend/test/backend_tests/rpc_admin_users_test.clj
Normal file
@ -0,0 +1,469 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-admin-users-test
|
||||
"Tests for the superuser-guarded user management commands."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.jobs :as jobs]
|
||||
[app.rpc :as rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- raw-method
|
||||
"The raw [f mdata] behind `cmd-name` in the admin namespace."
|
||||
[cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
[f mdata]))
|
||||
(sv/scan-ns 'app.rpc.admin.profile)))
|
||||
|
||||
(defn- call
|
||||
"Invoke an admin command through the real wrapper chain with a
|
||||
hand-built config.
|
||||
|
||||
The chain closes over its cfg at startup, so the superuser set is
|
||||
injected per call here instead of going through `th/command!`.
|
||||
Qualified keys stay in `params` as server context; the rest goes
|
||||
into the request, where params validation reads it from."
|
||||
[superusers auth-type profile-id token-perms cmd params]
|
||||
(let [[f mdata] (raw-method cmd)
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg f mdata)]
|
||||
(wrapped cfg (assoc server
|
||||
::rpc/profile-id profile-id
|
||||
::rpc/auth-type auth-type
|
||||
::rpc/token-perms token-perms))))
|
||||
|
||||
(defn- as-session
|
||||
[superusers profile]
|
||||
(fn [cmd params]
|
||||
(call superusers :session (:id profile) #{} cmd params)))
|
||||
|
||||
(defn- as-superuser
|
||||
[profile]
|
||||
(as-session #{(:id profile)} profile))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
(defn- set-flags
|
||||
[profile-id flags]
|
||||
(db/update! th/*system* :profile flags {:id profile-id}))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Guard
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest listed-session-lists-itself
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)
|
||||
out (run "get-profiles" {})]
|
||||
(t/is (= [(:id profile)] (mapv :id (:items out)))
|
||||
"the listing includes the caller itself")))
|
||||
|
||||
(t/deftest unlisted-session-rejected
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-session #{} profile)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(run "get-profiles" {}))))))
|
||||
|
||||
(t/deftest unlisted-token-without-scope-rejected
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #(call #{} :token (:id profile) #{}
|
||||
"get-profiles" {}))))))
|
||||
|
||||
(t/deftest token-with-granted-superuser-passes
|
||||
(let [profile (th/create-profile* 1)
|
||||
out (call #{} :token (:id profile) #{"superuser"}
|
||||
"get-profiles" {})]
|
||||
(t/is (= [(:id profile)] (mapv :id (:items out))))))
|
||||
|
||||
(t/deftest anonymous-rejected
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"get-profiles" {})))))
|
||||
|
||||
(t/deftest invalid-params-still-validated-for-listed-caller
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "get-profiles" {:limit 500}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Listing
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest list-returns-profiles-without-private-attrs
|
||||
(let [admin (th/create-profile* 1)
|
||||
alice (th/create-profile* 2 {:fullname "Alice Admin-Seen"})
|
||||
run (as-superuser admin)
|
||||
out (run "get-profiles" {})
|
||||
by-id (into {} (map (juxt :id identity)) (:items out))]
|
||||
(t/is (= 2 (count (:items out))))
|
||||
(t/is (= "Alice Admin-Seen" (:fullname (get by-id (:id alice)))))
|
||||
(doseq [item (:items out)]
|
||||
(t/is (contains? item :is-active))
|
||||
(t/is (contains? item :is-blocked))
|
||||
(t/is (contains? item :is-demo))
|
||||
(t/is (not (contains? item :password)))
|
||||
(t/is (not (contains? item :props)))
|
||||
(t/is (not (contains? item :deleted-at))))))
|
||||
|
||||
(t/deftest list-finds-by-id-and-exact-email
|
||||
(let [admin (th/create-profile* 1)
|
||||
alice (th/create-profile* 2 {:fullname "Alice Admin-Seen"})
|
||||
run (as-superuser admin)]
|
||||
(let [out (run "get-profiles" {:id (:id alice)})]
|
||||
(t/is (= [(:id alice)] (mapv :id (:items out))))
|
||||
(t/is (some? (:modified-at (first (:items out))))))
|
||||
(let [out (run "get-profiles" {:id (uuid/next)})]
|
||||
(t/is (= {:items []} out)))
|
||||
(let [out (run "get-profiles" {:email (:email alice)})]
|
||||
(t/is (= [(:id alice)] (mapv :id (:items out)))
|
||||
"exact email hits the unique index"))
|
||||
(let [out (run "get-profiles" {:email (str/upper-case (:email alice))})]
|
||||
(t/is (= [(:id alice)] (mapv :id (:items out)))
|
||||
"email lookup is case-insensitive"))
|
||||
(let [out (run "get-profiles" {:email (str " " (str/upper-case (:email alice)) " ")})]
|
||||
(t/is (= [(:id alice)] (mapv :id (:items out)))
|
||||
"email lookup trims surrounding spaces"))
|
||||
(let [out (run "get-profiles" {:email "no-such-user@nodomain.com"})]
|
||||
(t/is (= {:items []} out)))))
|
||||
|
||||
(t/deftest list-filters-by-deleted
|
||||
(let [admin (th/create-profile* 1)
|
||||
gone (th/create-profile* 2)
|
||||
_ (set-flags (:id gone) {:deleted-at (ct/now)})
|
||||
run (as-superuser admin)]
|
||||
(let [out (run "get-profiles" {:deleted true})]
|
||||
(t/is (= [(:id gone)] (mapv :id (:items out)))))
|
||||
(let [out (run "get-profiles" {:deleted false})]
|
||||
(t/is (= [(:id admin)] (mapv :id (:items out)))))
|
||||
(let [out (run "get-profiles" {})]
|
||||
(t/is (= 2 (count (:items out)))
|
||||
"without the flag, deleted rows still list"))))
|
||||
|
||||
(t/deftest list-paginates
|
||||
(let [admin (th/create-profile* 1)
|
||||
_ (th/create-profile* 2)
|
||||
_ (th/create-profile* 3)
|
||||
_ (th/create-profile* 4)
|
||||
run (as-superuser admin)
|
||||
page1 (run "get-profiles" {:limit 2})]
|
||||
(t/is (= 2 (count (:items page1))))
|
||||
(t/is (some? (:next-since page1)))
|
||||
(t/is (some? (:next-id page1)))
|
||||
(let [page2 (run "get-profiles" {:limit 2
|
||||
:since (:next-since page1)
|
||||
:since-id (:next-id page1)})
|
||||
ids1 (set (map :id (:items page1)))
|
||||
ids2 (set (map :id (:items page2)))]
|
||||
(t/is (= 2 (count (:items page2))))
|
||||
(t/is (empty? (set/intersection ids1 ids2))
|
||||
"pages do not overlap")
|
||||
(t/is (nil? (:next-since page2)))
|
||||
(t/is (nil? (:next-id page2))))))
|
||||
|
||||
(t/deftest list-includes-deleted-with-stamp
|
||||
(let [admin (th/create-profile* 1)
|
||||
gone (th/create-profile* 2)
|
||||
_ (set-flags (:id gone) {:deleted-at (ct/now)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-profiles" {})
|
||||
by-id (into {} (map (juxt :id identity)) (:items out))]
|
||||
(t/is (= 2 (count (:items out))))
|
||||
(t/is (some? (:deleted-at (get by-id (:id gone)))))
|
||||
(t/is (nil? (:deleted-at (get by-id (:id admin)))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Detail
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest detail-guard
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #((as-session #{} profile)
|
||||
"get-profile" {:id (:id profile)}))))
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"get-profile" {:id (:id profile)}))))))
|
||||
|
||||
(t/deftest detail-returns-profile-with-teams
|
||||
(let [admin (th/create-profile* 1)
|
||||
user (th/create-profile* 2 {:fullname "Detail User"})
|
||||
_ (th/create-team-role* {:team-id (:default-team-id admin)
|
||||
:profile-id (:id user)
|
||||
:role :editor})
|
||||
run (as-superuser admin)
|
||||
out (run "get-profile" {:id (:id user)})]
|
||||
(t/is (= (:id user) (:id out)))
|
||||
(t/is (= "Detail User" (:fullname out)))
|
||||
(t/is (false? (:is-blocked out)))
|
||||
(t/is (not (contains? out :password)))
|
||||
(t/is (not (contains? out :props)))
|
||||
(t/is (= 1 (count (:owned-teams out))))
|
||||
(t/is (= "Default" (:name (first (:owned-teams out)))))
|
||||
(t/is (= 1 (:members (first (:owned-teams out)))))
|
||||
(t/is (= 1 (count (:member-teams out))))
|
||||
(t/is (false? (:is-owner (first (:member-teams out)))))))
|
||||
|
||||
(t/deftest detail-unknown-id-gives-not-found
|
||||
(let [profile (th/create-profile* 1)
|
||||
run (as-superuser profile)]
|
||||
(t/is (= :profile-not-found
|
||||
(caught-code #(run "get-profile" {:id (uuid/next)}))))))
|
||||
|
||||
(t/deftest detail-deleted-returns-with-stamp
|
||||
(let [admin (th/create-profile* 1)
|
||||
gone (th/create-profile* 2)
|
||||
_ (set-flags (:id gone) {:deleted-at (ct/now)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-profile" {:id (:id gone)})]
|
||||
(t/is (= (:id gone) (:id out)))
|
||||
(t/is (some? (:deleted-at out)))))
|
||||
|
||||
(t/deftest detail-deleted-keeps-owned-teams
|
||||
;; Deleting a profile deletes its teams too, but the detail must
|
||||
;; still list them (marked as deleted), not pretend there are none.
|
||||
(let [admin (th/create-profile* 1)
|
||||
gone (th/create-profile* 2)
|
||||
team (th/create-team* 2 {:profile-id (:id gone)})
|
||||
_ (set-flags (:id gone) {:deleted-at (ct/now)})
|
||||
_ (db/update! th/*system* :team
|
||||
{:deleted-at (ct/now)}
|
||||
{:id (:id team)})
|
||||
run (as-superuser admin)
|
||||
out (run "get-profile" {:id (:id gone)})
|
||||
by-id (into {} (map (juxt :id identity) (:owned-teams out)))]
|
||||
(t/is (= 2 (count (:owned-teams out))))
|
||||
(t/is (some? (:deleted-at (get by-id (:id team)))))
|
||||
(t/is (nil? (:deleted-at (get by-id (:default-team-id gone)))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Block / unblock
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- open-session
|
||||
[profile-id]
|
||||
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id) VALUES (?, ?)"
|
||||
(uuid/next) profile-id]))
|
||||
|
||||
(defn- session-count
|
||||
[profile-id]
|
||||
(:count (th/db-exec-one! ["SELECT count(*) AS count FROM http_session_v2 WHERE profile_id = ?"
|
||||
profile-id])))
|
||||
|
||||
(t/deftest block-guard
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #((as-session #{} profile)
|
||||
"block-profile" {:id (:id profile)}))))
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #((as-session #{} profile)
|
||||
"unblock-profile" {:id (:id profile)}))))))
|
||||
|
||||
(t/deftest block-sets-flag-and-closes-sessions
|
||||
(let [admin (th/create-profile* 1)
|
||||
user (th/create-profile* 2)
|
||||
_ (open-session (:id user))
|
||||
_ (open-session (:id user))
|
||||
run (as-superuser admin)
|
||||
out (run "block-profile" {:id (:id user)})]
|
||||
(t/is (= {:id (:id user) :is-blocked true} out))
|
||||
(t/is (zero? (session-count (:id user))))))
|
||||
|
||||
(t/deftest block-is-idempotent
|
||||
(let [admin (th/create-profile* 1)
|
||||
user (th/create-profile* 2)
|
||||
run (as-superuser admin)]
|
||||
(run "block-profile" {:id (:id user)})
|
||||
(t/is (= {:id (:id user) :is-blocked true}
|
||||
(run "block-profile" {:id (:id user)})))))
|
||||
|
||||
(t/deftest block-self-refused
|
||||
(let [admin (th/create-profile* 1)
|
||||
run (as-superuser admin)]
|
||||
(t/is (= :cannot-block-self
|
||||
(caught-code #(run "block-profile" {:id (:id admin)}))))))
|
||||
|
||||
(t/deftest block-unknown-id-gives-not-found
|
||||
(let [admin (th/create-profile* 1)
|
||||
run (as-superuser admin)]
|
||||
(t/is (= :profile-not-found
|
||||
(caught-code #(run "block-profile" {:id (uuid/next)}))))
|
||||
(t/is (= :profile-not-found
|
||||
(caught-code #(run "unblock-profile" {:id (uuid/next)}))))))
|
||||
|
||||
(t/deftest writes-refuse-deleted
|
||||
(let [admin (th/create-profile* 1)
|
||||
gone (th/create-profile* 2)
|
||||
_ (set-flags (:id gone) {:deleted-at (ct/now)})
|
||||
run (as-superuser admin)]
|
||||
(t/is (= :profile-not-found
|
||||
(caught-code #(run "block-profile" {:id (:id gone)}))))
|
||||
(t/is (= :profile-not-found
|
||||
(caught-code #(run "unblock-profile" {:id (:id gone)}))))
|
||||
(t/is (= :profile-not-found
|
||||
(caught-code #(run "resend-verification" {:id (:id gone)}))))
|
||||
(let [out (run "delete-profiles" {:emails [(:email gone)]})]
|
||||
(t/is (= [] (:deleted out)))
|
||||
(t/is (= [(:email gone)] (:not-found out))))))
|
||||
|
||||
(t/deftest unblock-clears-flag
|
||||
(let [admin (th/create-profile* 1)
|
||||
user (th/create-profile* 2)
|
||||
run (as-superuser admin)]
|
||||
(run "block-profile" {:id (:id user)})
|
||||
(t/is (= {:id (:id user) :is-blocked false}
|
||||
(run "unblock-profile" {:id (:id user)})))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Resend verification
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest resend-guard
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #((as-session #{} profile)
|
||||
"resend-verification" {:id (:id profile)}))))
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"resend-verification"
|
||||
{:id (:id profile)}))))))
|
||||
|
||||
(t/deftest resend-schedules-email-for-inactive
|
||||
(let [admin (th/create-profile* 1)
|
||||
user (th/create-profile* 2)
|
||||
run (as-superuser admin)
|
||||
sent (atom nil)]
|
||||
(with-redefs [jobs/submit (fn [_cfg {:keys [::jobs/name ::jobs/params]}]
|
||||
(reset! sent {:task name :params params})
|
||||
nil)]
|
||||
(let [out (run "resend-verification" {:id (:id user)})]
|
||||
(t/is (= {:id (:id user) :email (:email user)} out))))
|
||||
(t/is (= :sendmail (:task @sent)))
|
||||
(t/is (= (:email user) (:to (:params @sent))))))
|
||||
|
||||
(t/deftest resend-active-refused
|
||||
(let [admin (th/create-profile* 1)
|
||||
active (th/create-profile* 2 {:is-active true})
|
||||
run (as-superuser admin)
|
||||
sent (atom nil)]
|
||||
(with-redefs [jobs/submit (fn [_cfg {:keys [::jobs/name ::jobs/params]}]
|
||||
(reset! sent {:task name :params params})
|
||||
nil)]
|
||||
(t/is (= :already-active
|
||||
(caught-code #(run "resend-verification"
|
||||
{:id (:id active)})))))
|
||||
(t/is (nil? @sent) "no email is scheduled")))
|
||||
|
||||
(t/deftest resend-unknown-id-gives-not-found
|
||||
(let [admin (th/create-profile* 1)
|
||||
run (as-superuser admin)]
|
||||
(t/is (= :profile-not-found
|
||||
(caught-code #(run "resend-verification"
|
||||
{:id (uuid/next)}))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Delete in bulk by email
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest delete-guard
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(t/is (= :superuser-required
|
||||
(caught-code #((as-session #{} profile)
|
||||
"delete-profiles"
|
||||
{:emails ["a@test.com"]}))))
|
||||
(t/is (= :authentication-required
|
||||
(caught-code #(call #{} nil nil #{}
|
||||
"delete-profiles"
|
||||
{:emails ["a@test.com"]}))))))
|
||||
(t/deftest delete-marks-profiles-and-closes-sessions
|
||||
(let [admin (th/create-profile* 1)
|
||||
user2 (th/create-profile* 2)
|
||||
user3 (th/create-profile* 3)
|
||||
_ (open-session (:id user2))
|
||||
run (as-superuser admin)
|
||||
jobs (atom [])]
|
||||
(with-redefs [jobs/submit (fn [_cfg {:keys [::jobs/name ::jobs/params]}]
|
||||
(swap! jobs conj {:task name :params params})
|
||||
nil)]
|
||||
(let [out (run "delete-profiles"
|
||||
{:emails [(:email user2)
|
||||
"ghost@test.com"
|
||||
(:email user3)]})]
|
||||
(t/is (= 3 (:total out)))
|
||||
(t/is (= (set [(:id user2) (:id user3)]) (set (:deleted out))))
|
||||
(t/is (= ["ghost@test.com"] (:not-found out)))
|
||||
(t/is (= [] (:skipped-self out)))))
|
||||
(t/is (some? (:deleted-at (db/get* th/*system* :profile {:id (:id user2)}
|
||||
{::db/remove-deleted false})))
|
||||
"the row stays until the collector purges it")
|
||||
(t/is (zero? (session-count (:id user2))))
|
||||
(t/is (= 2 (count @jobs)))
|
||||
(t/is (every? #(= :delete-object (:task %)) @jobs))
|
||||
(let [out (run "get-profiles" {:email (:email user2)})]
|
||||
(t/is (= 1 (count (:items out)))
|
||||
"a marked profile still lists under its own email")
|
||||
(t/is (some? (:deleted-at (first (:items out))))))))
|
||||
|
||||
(t/deftest delete-skips-self
|
||||
(let [admin (th/create-profile* 1)
|
||||
run (as-superuser admin)]
|
||||
(let [out (run "delete-profiles" {:emails [(:email admin)]})]
|
||||
(t/is (= [(:email admin)] (:skipped-self out)))
|
||||
(t/is (= [] (:deleted out))))
|
||||
(t/is (nil? (:deleted-at (db/get* th/*system* :profile {:id (:id admin)})))
|
||||
"the operator row is untouched")))
|
||||
|
||||
(t/deftest delete-normalizes-email-case
|
||||
(let [admin (th/create-profile* 1)
|
||||
user (th/create-profile* 2)
|
||||
run (as-superuser admin)
|
||||
jobs (atom [])]
|
||||
(with-redefs [jobs/submit (fn [_cfg {:keys [::jobs/name ::jobs/params]}]
|
||||
(swap! jobs conj {:task name :params params})
|
||||
nil)]
|
||||
(let [out (run "delete-profiles"
|
||||
{:emails [(str/upper-case (:email user))]})]
|
||||
(t/is (= [(:id user)] (:deleted out)))
|
||||
(t/is (= 1 (count @jobs)))))))
|
||||
|
||||
(t/deftest delete-validates-batch-size
|
||||
(let [admin (th/create-profile* 1)
|
||||
run (as-superuser admin)]
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "delete-profiles" {:emails []}))))
|
||||
(t/is (= :params-validation
|
||||
(caught-code #(run "delete-profiles"
|
||||
{:emails (mapv str (range 101))}))))))
|
||||
336
backend/test/backend_tests/rpc_superuser_test.clj
Normal file
336
backend/test/backend_tests/rpc_superuser_test.clj
Normal file
@ -0,0 +1,336 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
||||
|
||||
(ns backend-tests.rpc-superuser-test
|
||||
"Tests for the `superuser` permission in the RPC APIs."
|
||||
(:require
|
||||
[app.auth :as-alias auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.rpc :as rpc]
|
||||
[app.util.services :as sv]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- with-host
|
||||
"Run `f` with `cf/get :host` answering `host`."
|
||||
[host f]
|
||||
(with-redefs [cf/get (th/config-get-mock {:host host})]
|
||||
(f)))
|
||||
|
||||
(defn- as-session
|
||||
[profile-id]
|
||||
{::rpc/profile-id profile-id
|
||||
::rpc/auth-type :session})
|
||||
|
||||
(defn- as-token
|
||||
[profile-id]
|
||||
{::rpc/profile-id profile-id
|
||||
::rpc/auth-type :token
|
||||
::rpc/token-perms #{}})
|
||||
|
||||
(defn- as-token-with-perms
|
||||
[profile-id perms]
|
||||
(assoc (as-token profile-id) ::rpc/token-perms (set perms)))
|
||||
|
||||
(def ^:private schema:needs-id
|
||||
"A params schema that rejects a body missing `:id`.
|
||||
|
||||
Malli maps are open by default, so this needs `:closed true` for the
|
||||
missing key to actually fail validation."
|
||||
[:map {:title "needs-id" :closed true}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(defn- mdata
|
||||
[perms]
|
||||
{:name "needs-id"
|
||||
::rpc/auth true
|
||||
::rpc/perms perms
|
||||
::sm/params schema:needs-id})
|
||||
|
||||
(defn- run-main-chain
|
||||
"Run `#'rpc/wrap` (the main chain) around a recording handler.
|
||||
|
||||
`superusers` is the registry set, injected straight into the config the
|
||||
chain receives. Returns `::reached` when the handler ran; any rejection
|
||||
is thrown so the caller can inspect the error code. Qualified keys stay
|
||||
in `params` as server context; the unqualified ones go into the request,
|
||||
which is where `wrap-params-validation` reads them from."
|
||||
[superusers mdata params]
|
||||
(let [cfg (assoc th/*system*
|
||||
::db/pool th/*pool*
|
||||
::auth/superusers superusers)
|
||||
request (assoc (th/make-dummy-request)
|
||||
:params (d/without-qualified params))
|
||||
server-params (with-meta params {::rpc/request-at (ct/now)
|
||||
::http/request request})
|
||||
wrapped (#'rpc/wrap cfg (fn [_ _] ::reached) mdata)]
|
||||
(wrapped cfg server-params)))
|
||||
|
||||
(defn- command-fn
|
||||
"The raw command function behind `cmd-name` in the given namespace,
|
||||
without the wrapper chain. Lets tests call a command with a hand-built
|
||||
config, e.g. carrying a non-empty superuser set."
|
||||
[nsym cmd-name]
|
||||
(some (fn [[f mdata]]
|
||||
(when (= cmd-name (::sv/name mdata))
|
||||
f))
|
||||
(sv/scan-ns nsym)))
|
||||
|
||||
(defn- caught-code
|
||||
"Run `thunk` and return the error code it raised, or `::no-throw`."
|
||||
[thunk]
|
||||
(try
|
||||
(thunk)
|
||||
::no-throw
|
||||
(catch clojure.lang.ExceptionInfo cause
|
||||
(th/ex-code cause))))
|
||||
|
||||
(defn- route-paths
|
||||
"Every full path in the route tree, joined from its segments."
|
||||
[prefix node]
|
||||
(if (vector? node)
|
||||
(let [segment (when (string? (first node)) (first node))
|
||||
here (str prefix segment)]
|
||||
(cons here
|
||||
(mapcat #(route-paths here %) (filter vector? (rest node)))))
|
||||
[]))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; The registry service
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest registry-resolves-listed-emails-to-ids
|
||||
(let [profile (th/create-profile* 1 {:email "listed@example.com"})]
|
||||
(with-redefs [cf/get (th/config-get-mock {:superusers #{"listed@example.com"
|
||||
"missing@example.com"}})]
|
||||
(let [registry (ig/init-key :app.auth/superusers {::db/pool th/*pool*})]
|
||||
(t/is (= #{(:id profile)} registry))))))
|
||||
|
||||
(t/deftest registry-ignores-what-does-not-exist-at-startup
|
||||
;; Frozen at boot: whoever registers afterwards waits for a restart.
|
||||
(with-redefs [cf/get (th/config-get-mock {:superusers #{"late@example.com"}})]
|
||||
(let [registry (ig/init-key :app.auth/superusers {::db/pool th/*pool*})]
|
||||
(t/is (= #{} registry))
|
||||
(th/create-profile* 1 {:email "late@example.com"})
|
||||
(t/is (= #{} registry)))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; The guard in the main chain
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest listed-session-reaches-a-guarded-method
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (= ::reached
|
||||
(run-main-chain #{(:id profile)}
|
||||
(mdata #{"superuser"})
|
||||
(merge (as-session (:id profile))
|
||||
{:id (random-uuid)}))))))))
|
||||
|
||||
(t/deftest listed-token-reaches-a-guarded-method
|
||||
;; The set wins: a token needs no granted perms when the id is listed.
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (= ::reached
|
||||
(run-main-chain #{(:id profile)}
|
||||
(mdata #{"superuser"})
|
||||
(merge (as-token (:id profile))
|
||||
{:id (random-uuid)}))))))))
|
||||
|
||||
(t/deftest unlisted-session-rejected-from-guarded-method
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (= :superuser-required
|
||||
(caught-code
|
||||
#(run-main-chain #{}
|
||||
(mdata #{"superuser"})
|
||||
(merge (as-session (:id profile))
|
||||
{:id (random-uuid)})))))))))
|
||||
|
||||
(t/deftest unlisted-token-rejected-from-guarded-method
|
||||
;; A valid token does not exempt whoever is not listed.
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (= :superuser-required
|
||||
(caught-code
|
||||
#(run-main-chain #{}
|
||||
(mdata #{"superuser"})
|
||||
(merge (as-token (:id profile))
|
||||
{:id (random-uuid)})))))))))
|
||||
|
||||
(t/deftest token-with-granted-superuser-reaches-guarded-method
|
||||
;; A token carrying "superuser" as an operator-granted scope satisfies the
|
||||
;; guard for a profile the registry does not list. Scopes are granted
|
||||
;; outside the public API, so this opens nothing by itself.
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (= ::reached
|
||||
(run-main-chain #{}
|
||||
(mdata #{"superuser"})
|
||||
(merge (as-token-with-perms (:id profile)
|
||||
#{"superuser"})
|
||||
{:id (random-uuid)}))))))))
|
||||
|
||||
(t/deftest token-with-other-scope-still-rejected
|
||||
;; Only the exact "superuser" scope counts.
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (= :superuser-required
|
||||
(caught-code
|
||||
#(run-main-chain #{}
|
||||
(mdata #{"superuser"})
|
||||
(merge (as-token-with-perms (:id profile)
|
||||
#{"error-reports:read"})
|
||||
{:id (random-uuid)})))))))))
|
||||
|
||||
(t/deftest anonymous-rejected-from-guarded-method
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (= :authentication-required
|
||||
(caught-code
|
||||
#(run-main-chain #{}
|
||||
(mdata #{"superuser"})
|
||||
{:id (random-uuid)})))))))
|
||||
|
||||
(t/deftest scope-perm-still-requires-token-for-sessions
|
||||
;; Methods with token scopes keep their meaning: a session cannot satisfy
|
||||
;; them, listed or not.
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (= :token-auth-required
|
||||
(caught-code
|
||||
#(run-main-chain #{(:id profile)}
|
||||
(mdata #{"error-reports:read"})
|
||||
(merge (as-session (:id profile))
|
||||
{:id (random-uuid)})))))))))
|
||||
|
||||
(t/deftest guard-runs-before-params-validation
|
||||
;; An invalid body from whoever is not listed is rejected as such, not as
|
||||
;; a params failure: the latter would leak which params the command
|
||||
;; expects.
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
;; `:id` missing, so params validation would fail if it ran first.
|
||||
(t/is (= :superuser-required
|
||||
(caught-code
|
||||
#(run-main-chain #{}
|
||||
(mdata #{"superuser"})
|
||||
(as-session (:id profile))))))))))
|
||||
|
||||
(t/deftest valid-params-still-validated-for-listed-caller
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (= :params-validation
|
||||
(caught-code
|
||||
#(run-main-chain #{(:id profile)}
|
||||
(mdata #{"superuser"})
|
||||
(as-session (:id profile))))))))))
|
||||
|
||||
(t/deftest any-profile-passes-on-devenv
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "devenv"
|
||||
(fn []
|
||||
(t/is (= ::reached
|
||||
(run-main-chain #{}
|
||||
(mdata #{"superuser"})
|
||||
(merge (as-session (:id profile))
|
||||
{:id (random-uuid)}))))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; The flag on get-profile and login
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(defn- call-profile
|
||||
[superusers profile-id]
|
||||
(let [f (command-fn 'app.rpc.commands.profile "get-profile")
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)]
|
||||
(f cfg (cond-> {}
|
||||
(some? profile-id) (assoc ::rpc/profile-id profile-id)))))
|
||||
|
||||
(t/deftest get-profile-flags-listed-id
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (true? (:is-superuser (call-profile #{(:id profile)} (:id profile)))))))))
|
||||
|
||||
(t/deftest get-profile-flags-unlisted-id-as-false
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "example.com"
|
||||
(fn []
|
||||
(t/is (false? (:is-superuser (call-profile #{} (:id profile)))))))))
|
||||
|
||||
(t/deftest get-profile-anonymous-carries-no-flag
|
||||
(t/is (not (contains? (call-profile #{} nil) :is-superuser))))
|
||||
|
||||
(t/deftest get-profile-on-devenv-flags-any-profile
|
||||
;; The flag and the guard share the rule, so they cannot diverge.
|
||||
(let [profile (th/create-profile* 1)]
|
||||
(with-host "devenv"
|
||||
(fn []
|
||||
(t/is (true? (:is-superuser (call-profile #{} (:id profile)))))))))
|
||||
|
||||
(defn- call-login
|
||||
[superusers email]
|
||||
(let [f (command-fn 'app.rpc.commands.auth "login-with-password")
|
||||
cfg (assoc th/*system* ::auth/superusers superusers)]
|
||||
(f cfg {:email email :password "Test123!"})))
|
||||
|
||||
(t/deftest login-flags-listed-id-instead-of-admin
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(let [out (call-login #{(:id profile)} (:email profile))]
|
||||
(t/is (true? (:is-superuser out)))
|
||||
(t/is (not (contains? out :is-admin))))))
|
||||
|
||||
(t/deftest login-flags-unlisted-id-as-not-superuser
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
(let [out (call-login #{} (:email profile))]
|
||||
(t/is (false? (:is-superuser out))))))
|
||||
|
||||
;; ----------------------------------------------------------------
|
||||
;; Wiring: admin lives on its own endpoint, outside main
|
||||
;; ----------------------------------------------------------------
|
||||
|
||||
(t/deftest admin-namespace-contributes-no-methods-to-main
|
||||
;; Short names exist in both maps; each endpoint resolves its own
|
||||
;; namespace, so there is no real collision.
|
||||
(let [main-methods (:app.rpc/methods th/*system*)
|
||||
admin-methods (:app.rpc/admin-methods th/*system*)]
|
||||
(t/is (= "app.rpc.admin.profile" (:ns (first (get admin-methods :get-profile)))))
|
||||
(t/is (= "app.rpc.admin.team" (:ns (first (get admin-methods :get-team)))))
|
||||
(t/is (= "app.rpc.admin.errors" (:ns (first (get admin-methods :get-error-reports)))))
|
||||
(t/is (= "app.rpc.commands.profile" (:ns (first (get main-methods :get-profile)))))
|
||||
(t/is (= "app.rpc.commands.teams" (:ns (first (get main-methods :get-team)))))
|
||||
(t/is (= "app.rpc.commands.error-reports" (:ns (first (get main-methods :get-error-reports)))))))
|
||||
|
||||
(t/deftest admin-route-exists-alongside-other-routes
|
||||
(let [paths (route-paths "" (:app.rpc/routes th/*system*))]
|
||||
(t/is (some #{"/api/admin/methods/:method-name"} paths))
|
||||
(t/is (some #{"/api/main/methods/:method-name"} paths))
|
||||
(t/is (some #{"/api/management/methods/:method-name"} paths))
|
||||
;; still the deprecated alias of main, out of scope for this change
|
||||
(t/is (some #{"/api/rpc/command/:method-name"} paths))))
|
||||
@ -181,10 +181,6 @@ http {
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
proxy_pass http://127.0.0.1:6063/admin;
|
||||
}
|
||||
|
||||
location /webhooks {
|
||||
proxy_pass http://127.0.0.1:6060/webhooks;
|
||||
}
|
||||
@ -193,6 +189,10 @@ http {
|
||||
proxy_pass http://127.0.0.1:6060/dbg;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
proxy_pass http://127.0.0.1:6060/admin;
|
||||
}
|
||||
|
||||
location /telemetry {
|
||||
proxy_pass http://127.0.0.1:6070/inbox;
|
||||
}
|
||||
|
||||
@ -87,7 +87,9 @@
|
||||
under `:progress`, the artifact as `:uri` when the job completed, and
|
||||
the public error of the job when it failed. The queue of a file is
|
||||
announced by `export-files` before any job exists and only ends with a
|
||||
milestone: a worker picking the job up is not one.
|
||||
milestone: a worker picking the job up is not one. A job cancelled
|
||||
elsewhere (e.g. from the admin panel) answers as `:cancelled`, so the
|
||||
dialog never hangs on a file whose job will never produce an artifact.
|
||||
|
||||
`on-job` is an optional callback invoked with `{:job-id ... :file-id
|
||||
...}` for the created job, so the caller can track it per file and
|
||||
@ -112,6 +114,9 @@
|
||||
(rx/of {:file-id (:id file)
|
||||
:error error})
|
||||
|
||||
;; cancelled elsewhere (e.g. from
|
||||
;; the admin panel): no artifact
|
||||
;; is coming, mark the file
|
||||
(= "cancelled" status)
|
||||
(rx/of {:file-id (:id file)
|
||||
:cancelled true})
|
||||
|
||||
@ -372,6 +372,10 @@
|
||||
(rx/from (entry-messages entries {:status :error
|
||||
:error (job-message error)}))
|
||||
|
||||
;; cancelled elsewhere (e.g. from
|
||||
;; the admin panel): no outcome is
|
||||
;; coming, fail the entries with
|
||||
;; the cancelled message
|
||||
(= "cancelled" status)
|
||||
(rx/from (entry-messages entries {:status :error
|
||||
:error (tr "jobs.import-cancelled")}))
|
||||
|
||||
@ -66,8 +66,8 @@
|
||||
files))
|
||||
|
||||
(defn- mark-file-cancelled
|
||||
"The job of a file was cancelled elsewhere: a neutral terminal state,
|
||||
neither success nor error, replacing any other."
|
||||
"The job of a file was cancelled elsewhere: no artifact is coming,
|
||||
a neutral terminal state, replacing any other."
|
||||
[files file-id]
|
||||
(mapv (fn [file]
|
||||
(if (same-file? file-id (:id file))
|
||||
|
||||
@ -477,7 +477,7 @@
|
||||
{:on-next #(swap! seen conj %)})
|
||||
job-id (:job-id (first @calls))]
|
||||
|
||||
(t/testing "the cancelled ending terminates the stream"
|
||||
(t/testing "the cancelled ending fails the entries instead of hanging them"
|
||||
(swap! rows assoc job-id (job job-id "cancelled"))
|
||||
(rx/push! ws-stream (message (event job-id :end {:outcome "cancelled"})))
|
||||
(await done)
|
||||
|
||||
@ -301,6 +301,17 @@
|
||||
(let [report (errors/generate-report (error-cause :type :network :hint "boom"))]
|
||||
(t/is (string? report)))))
|
||||
|
||||
(t/deftest generate-report-carries-no-separator-lines
|
||||
;; Scenario: a full internal report. The throwable dump used to be
|
||||
;; wrapped between `====` banners; they carry no information and
|
||||
;; bloated every stored report. Proves: no equals-run survives,
|
||||
;; while the Context and Last-events sections still do.
|
||||
(let [report (errors/generate-report (error-cause :type :internal :hint "boom"))]
|
||||
(t/is (string? report))
|
||||
(t/is (nil? (re-find #"={10,}" report)))
|
||||
(t/is (str/includes? report "Context:"))
|
||||
(t/is (str/includes? report "Last events:"))))
|
||||
|
||||
(t/deftest ^:async flash-emits-a-fallback-report-when-generation-fails
|
||||
;; Scenario: the full-report formatter throws. `generate-report` stays
|
||||
;; total and the fallback string is what gets emitted. Proves: a
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user