Admin Panel: Users + Workspaces + Audit log (V27 → V30)#10883
Admin Panel: Users + Workspaces + Audit log (V27 → V30)#10883MichaelUray wants to merge 164 commits into
Conversation
…t log Signed-off-by: Michael Uray <[email protected]>
…n + lastActivityAt Signed-off-by: Michael Uray <[email protected]>
Adds AdminAuditAction enum, AdminAuditLogEntry DTO and AdminAuditLogCollection interface to types.ts. Wires PostgresAdminAuditLogCollection (insert, findByTarget, findByAdmin) into PostgresAccountDB so admin actions can be recorded against the V27 admin_audit_log table. Signed-off-by: Michael Uray <[email protected]>
Adds two helpers in utils.ts that wrap @hcengineering/server-token: - generateTokenWithVersion attaches the account's tokenVersion as a token_version extra-claim when > 0 (skipped for GUEST and non-UUID principals). - verifyTokenVersion rejects tokens whose claim is stale relative to the account row, and rejects disabled accounts (disabledAt != null). Unit tests cover claim-presence, monotonic invalidation, and disabled rejection (5 cases, all green). Signed-off-by: Michael Uray <[email protected]>
Migrates 19 generateToken call-sites in utils.ts + operations.ts to the new helper so account.tokenVersion is honored for every issued JWT. Exceptions kept as direct generateToken: 3 GUEST_ACCOUNT paths (login as guest, share-link access, access-link grant). sendEmailConfirmation now takes AccountDB so the confirmation token also carries the version claim; tests + callers updated. MongoAccountDB gets a Mongo-backed AdminAuditLogCollection stub (lazy collection getter) so both backends implement the AccountDB interface. All 479 unit tests pass. Signed-off-by: Michael Uray <[email protected]>
…nInfoByToken Both DB-aware verification entry points now call verifyTokenVersion right after decodeTokenVerbose. This is where the account-disable / token-bump hard-cut takes effect for active sessions on next request. verifyTokenVersion also gains explicit short-circuits for systemAccountUuid and readOnlyGuestAccountUuid (service principals with no DB row), and the missing-account case is now treated as a service token (no rejection) so NIL_UUID-style 2FA-pending tokens keep working. Signed-off-by: Michael Uray <[email protected]>
Signed-off-by: Michael Uray <[email protected]>
… flows Wires touchLastActivity into: - login (after password verification + reset-failed-attempts) - validateOtp (after successful OTP verification) - selectWorkspace (after auth checks, skipped for system / read-only-guest) - loginOrSignUpWithProvider (after confirmHulyIds finalizes the OIDC account) System and read-only-guest accounts are excluded from activity tracking. All 482 unit tests still pass. Signed-off-by: Michael Uray <[email protected]>
…erver/account Introduces ListAccountsAdminParams, AccountListRow and AccountDetailsResponse in @hcengineering/account-client so the upcoming admin endpoints in server/account share a single source of truth with the frontend. server/account gains a workspace-dep on account-client; pnpm-lock.yaml refreshed via rush update. Signed-off-by: Michael Uray <[email protected]>
…aginate)
Adds the admin-only listing endpoint used by the new user-management UI.
Filters: search (name/email substring), status (active/disabled), auth-method
(email_only/oidc/mixed), workspace-overlap. Sorts: name / last_activity /
workspace_count. Pagination via {limit, offset}.
Registered as a service method; rejects non-admin callers with Forbidden.
Signed-off-by: Michael Uray <[email protected]>
…ships + recent audit) Signed-off-by: Michael Uray <[email protected]>
Admin-only role mutation for a workspace member. Rejects: - non-admin callers (Forbidden) - target not a member of the workspace (AccountNotFound) - demoting the last Owner of a workspace (last_owner_in_workspace) On success: updates the role via AccountDB and records a role_change entry in the admin_audit_log. Signed-off-by: Michael Uray <[email protected]>
… guard)
Idempotent: returns { ok: true, wasMember: false } when target is not a
member of the workspace. Same last-Owner guard as setWorkspaceMemberRole.
On success records a remove_member audit entry with the prior role.
Signed-off-by: Michael Uray <[email protected]>
…only — Option B) Admin-only password-reset trigger. Strict semantics per spec: - Rejects with user_has_no_email when target has no email identity - Rejects with user_has_no_password when target is OIDC-only (no hash) - Reuses existing requestPasswordReset flow for email send - Email-send failures are logged (audit entry still recorded) Signed-off-by: Michael Uray <[email protected]>
…sage Extends WorkspaceEvent with AccountDisabled so the broadcast TxWorkspaceEvent can carry the force-logout signal through the existing Tx-dispatch path. QueueAccountLifecycleMessage is the cross-pod payload published to the account.lifecycle topic by the account pod when an admin disables/enables an account; consumed by TSessionManager in each worker pod. Signed-off-by: Michael Uray <[email protected]>
…sion bump) Admin-only hard-disable that: - Rejects self-disable (cannot_self_disable) - Rejects disabling the only configured admin (last_admin) - Atomically sets disabledAt + bumps tokenVersion (synchronous fallback) - Emits QueueAccountLifecycleMessage on account.lifecycle (force-logout path) - Records a disable audit entry Queue producer is injected via the new AccountMethodDeps option to getMethods(). Producer failures are logged but the synchronous token-version bump still locks the user out on the next request. New wrapWithDeps helper mirrors wrap() but injects deps as the 4th arg. Signed-off-by: Michael Uray <[email protected]>
…ersion) Admin-only re-enable. Clears disabledAt and bumps tokenVersion so any remaining stale tokens with the old version still get rejected by verifyTokenVersion until the user re-authenticates. Signed-off-by: Michael Uray <[email protected]>
…r init serveAccount now accepts AccountMethodDeps (4th arg). The account-pod __start.ts conditionally creates an 'account.lifecycle' producer via @hcengineering/kafka when QUEUE_CONFIG is set, and injects it into the deps object. If QUEUE_CONFIG is missing or queue init throws, the producer is left undefined and disableAccount silently falls back to the synchronous token-version bump path. Signed-off-by: Michael Uray <[email protected]>
…ountDisabled Subscribes to the account.lifecycle topic. On a 'disabled' message: 1. Finds all sessions for that account in this pod's session table 2. Broadcasts a TxWorkspaceEvent.AccountDisabled to each matching session via the existing Tx-dispatch path (client-resources will treat this as a force-logout signal in the next task) 3. Closes the WebSocket so the client does not reconnect with the same stale token Cross-pod fan-out works because every transactor pod subscribes to the same topic with its own consumer-group id (generateId()). Signed-off-by: Michael Uray <[email protected]>
…orce-logout store client-resources: - Adds forcedLogoutReason private field on Connection. When the Tx-dispatch receives a TxWorkspaceEvent.AccountDisabled, the field is set and the module-private forceLogoutHandler is invoked. - wsocket.onclose now skips scheduleOpen when forcedLogoutReason is set, preventing the client from reconnecting with the same stale token. - Exports setForceLogoutHandler so consumers can register a callback without taking a circular dep. login-resources: - Adds the forceLogoutReason svelte writable store in utils.ts. - index.ts wires the client-resources handler to the store at module load so any caller that imports login-resources gets the bridge for free. Signed-off-by: Michael Uray <[email protected]>
Adds the modal that fires when the force-logout store is set. LoginApp.svelte subscribes to forceLogoutReason; when non-null the modal overlays everything (z-index 10000) and the only action is 'Sign out' which sends the user back to /login. By then client-resources has already cleared the local session token, so the redirect lands on the login page rather than re-entering with a stale token. New i18n strings AccountDisabledTitle / AccountDisabledBody / SignOut in en + de. Signed-off-by: Michael Uray <[email protected]>
…eholder LoginApp.svelte subscribes to the location store and reads path[2] under the 'admin' page to pick between AdminWorkspaces (legacy default) and the new AdminUsers component. The placeholder redirects non-admin users back to /login and shows a stub — Tasks 23-25 fill the page with the list/drawer UI. Signed-off-by: Michael Uray <[email protected]>
… endpoints Adds 7 new methods to the AccountClient interface and implementation: listAccountsAdmin, getAccountDetails, setWorkspaceMemberRole, removeWorkspaceMember, triggerPasswordReset, disableAccount, enableAccount. All thin RPC wrappers around the corresponding server endpoints added in Tasks 10-17. Frontend can now call them via getClient(...). Signed-off-by: Michael Uray <[email protected]>
…+ pagination Full UI for the /login/admin/users route: - AdminUsers.svelte: top-level state holder, calls listAccountsAdmin RPC - AdminUsersFilterBar.svelte: search box + auth-method + status dropdowns (300ms debounce on search) - AdminUsersTable.svelte: sortable table (name / workspace-count / last-activity) with row-click handler - AdminUsersRow.svelte: row layout with status/admin badges and a relative-time formatter for last activity - AdminUsersPagination.svelte: prev/next + page indicator - AdminUsersDrawer.svelte: stub for Task 25 Filter / sort / pagination changes all re-call the API. Drawer opens on row click and re-fetches when account-changed event fires. Signed-off-by: Michael Uray <[email protected]>
Replaces the Task 24 stub with the real detail view: - Loads AccountDetailsResponse via getAccountDetails RPC on mount - Renders identities (email + OIDC) with verification badges - Workspace memberships list with inline role selector and remove button - Last-activity timestamp - Admin action buttons: trigger password reset, disable/enable All error codes from the backend (last_owner_in_workspace, cannot_self_disable, last_admin, user_has_no_email, user_has_no_password) are caught and surfaced to the admin. Signed-off-by: Michael Uray <[email protected]>
…og + lifecycle event Exercises the full admin lifecycle end-to-end against a mocked AccountDB that mirrors the real persistence surface: - disable then enable bumps tokenVersion twice and clears disabledAt - audit-log accumulates role_change + disable + enable entries - lifecycle event is sent when a producer is provided The real-Postgres variant remains a deferred follow-up against postgres-real.test.ts. Signed-off-by: Michael Uray <[email protected]>
Marked as .skip until the dk3 staging deploy is reachable from the test runner. Documents the planned scenarios so they can be activated as a follow-up commit. Signed-off-by: Michael Uray <[email protected]>
… exprs The inline 'e.currentTarget.value as unknown as AccountRole' inside the select on:change attribute failed the webpack/svelte parser used by the front bundle (svelte-check tolerated it; webpack didn't). Extract a parseRole helper in the script block so the attribute expression is a plain function call. Signed-off-by: Michael Uray <[email protected]>
Backend security blockers: * login / validateOtp / loginOrSignUpWithProvider now reject when the target account.disabledAt is set. Without this, generateTokenWithVersion would happily mint a fresh token for a disabled account; verifyTokenVersion only blocked it later in selectWorkspace/getLoginInfoByToken. * All admin endpoints now go through a requireAdmin() helper that calls verifyTokenVersion BEFORE the admin-flag check, so a stale token from a freshly-disabled admin cannot be used to disable/role-change anyone else. Applies to setWorkspaceMemberRole, removeWorkspaceMember, triggerPasswordReset, disableAccount, enableAccount, listAccountsAdmin, getAccountDetails. UI correctness: * listAccountsAdmin and getAccountDetails now resolve firstName/lastName from the person table (the real source of truth) instead of reading non-existent fields on account. List rows + drawer details now show real names; search by name works. * Force-logout bridge in login-resources/index.ts also clears presentation.metadata.Token and login.metadata.LoginEndpoint so the next page nav can't re-enter with a stale token. Logic: * Last-admin guard now walks ADMIN_EMAILS, resolves each entry to an account via its email social id, and counts only currently-active admins. Two-admin configs with one missing/disabled now correctly refuse to disable the last active admin. * triggerPasswordReset re-throws on email-send failure (was silently returning ok:true) and writes a 'failed: true' audit entry. * enableAccount is now idempotent for never-disabled accounts: skips the tokenVersion bump (per spec §10.7) but still records an audit entry with details.noop=true. Tests: * Mock DBs in listAccountsAdmin/getAccountDetails tests gain a person stub (the new join). * triggerPasswordReset happy-path test now asserts the new password_reset_send_failed bubble-up + audit-on-failure. * New forceLogout.test.ts in client-resources covers the public setForceLogoutHandler contract (deep simulation deferred to E2E). Signed-off-by: Michael Uray <[email protected]>
… Tx-dispatch tests
Two Codex follow-up findings:
1. Force-logout bridge now clears login.metadata.LoginAccount in addition
to LoginEndpoint. The active-account marker is what LoginApp checks on
bootstrap to decide whether to auto-resume — without clearing it, a
disabled user's next page-load would still try to come back in.
2. forceLogout.test.ts was too shallow (just verified the setter exists).
Replaced with three integration cases inside connection.test.ts that
exercise the real wire path:
- AccountDisabled Tx on the socket invokes the registered handler
with the configured reason
- onclose after force-logout does NOT call socketFactory again
(skip-reconnect contract)
- missing params.reason defaults to 'account_disabled'
Uses the existing MockWebSocket harness, keeping the extraction-to-test-utils
work out of scope for this fix.
Signed-off-by: Michael Uray <[email protected]>
arrowFor() was a plain function reading `sort` via closure. Svelte 4
does not track closure-captured top-level state for function calls in
templates (the project's documented 'function-trap'), so every
{arrowFor(field)} call rendered against the snapshot taken at first
render — the arrow stayed frozen on its initial value (↓ for the
default time-desc sort) and never flipped when the admin toggled the
direction.
Wrap arrowFor in a `$:` reactive factory: each sort change re-runs
the statement and produces a fresh closure, which Svelte does re-bind
to all four header call sites. Behaviour now matches the symmetric
arrow logic the reviewer asked for (↑ asc, ↓ desc, '' inactive).
Signed-off-by: Michael Uray <[email protected]>
D1 — LIKE wildcard injection
User-supplied substrings in the admin-panel ILIKE filters were
interpolated directly into '%<input>%'. A user typing '%' got an
unconstrained wildcard match (effectively disabling the filter); '_'
matched any single char. Add a tiny escapeLike() helper (escapes
\ % _) and pair every ILIKE that takes a user-supplied substring
with ESCAPE '\' so PostgreSQL treats the doubled-up backslash as
a literal. Covered:
- listAccountsAdminPg.ts: search, nameContains, emailContains
- postgres.ts (listAuditAdmin): adminNameOrEmail, targetNameOrUrl
The unrelated legacy searchAccounts() ILIKE at postgres.ts:1390 is
pre-existing and outside this branch's scope; tracked separately.
D2 — CSV terminator + Excel BOM
csvLine() in account-client terminated rows with bare \n; RFC 4180
mandates CRLF. Excel-on-Windows additionally needs a UTF-8 BOM at
the head of the file or it decodes the bytes as ISO-8859-1 and
mangles every non-ASCII char.
- foundations/core/.../util/csv.ts: csvLine -> \r\n
- csv.test.ts: 2 assertions updated to expect \r\n
- account-service /api/v1/admin/export/accounts.csv: prepend BOM,
write header with \r\n
- AdminWorkspaces.svelte exportWorkspacesCsv(): BOM + \r\n header
and rows
D3 — 429 mojibake
The CSV-export rate-limit response served 'Too many exports — try
again in 60 seconds.' as text/plain with no charset; the em-dash
rendered as garbage in browsers that defaulted to ISO-8859-1.
Declare charset=utf-8.
Tests:
- new escapeLike.test.ts (4 cases — plain, %/_, backslash, empty)
- csv.test.ts now expects \r\n (9 tests pass)
- listAccountsAdmin.test.ts + listAuditAdmin.test.ts still pass
Signed-off-by: Michael Uray <[email protected]>
E1 — GlobalSearch.svelte was unimported anywhere (the Ctrl+K trigger was removed in 7809f02 / e7023e9). Only two stale comments mention it — those stay, since they only reference the historical drawer ?drawer=<uuid> entrypoint. Delete the file. E2 — The audit-log prune cron caught any error and re-armed the 24h timer. On MongoDB backends, pruneAuditOlderThan() throws 'pruneAuditOlderThan not implemented for Mongo backend' on every single run, polluting the log forever. Detect that message once, log an info-level note explaining the timer is being disabled, and clearInterval() the handle. Real errors (PG transient failures, auth issues, …) still log at error level and the next 24h interval still fires. Signed-off-by: Michael Uray <[email protected]>
…pper
UI-review caught: clicking the "Orphan accounts" button in AdminUsers
sets columnFilters = { orphan: { orphan: true } } which merges to a
top-level orphan: true on the request. The SQL builder at
listAccountsAdminPg.ts:114 honors it correctly. BUT the service-layer
mapper in serviceOperations.ts:187 explicitly listed every field it
forwarded to the DB and dropped `orphan` between client and SQL.
Add `orphan` to the mapper. Also publish `orphan?: boolean` on the
ListAccountsAdminParams interface in account-client so it's an
official API knob, not a magic field.
Signed-off-by: Michael Uray <[email protected]>
…er strip) Code-review D2 wanted UTF-8 BOM prepended to both CSV exports. The initial fix used a raw U+FEFF character in the source string. Both svelte-loader (frontend Workspaces blob) and esbuild (server CSV response) silently stripped the BOM during compile — Playwright verified the byte never made it into the blob/response. Two fixes for the same conceptual bug: - AdminWorkspaces.svelte: const BOM = String.fromCharCode(0xFEFF) - account-service/index.ts: ctx.res.write(Buffer.from([0xEF,0xBB,0xBF])) Both bypass tooling that drops literal U+FEFF as "BOM-at-start safety". Excel-on-Windows now renders non-ASCII characters in admin CSV exports correctly. Signed-off-by: Michael Uray <[email protected]>
The sort-arrow span reserved 0.75rem + 0.15rem margin even when its
content was empty, so unsorted column headers started ~14px to the
right of the body cell text. Wrap the arrow span in {#if sort.field === field}
so it doesn't render at all unless the column is the active sort.
Applies to:
- AdminUsersTable.svelte (6 sortable columns)
- AdminWorkspaces.svelte (loop-driven .ws-sort-arrow)
- AdminAudit.svelte (4 .sort-arrow spans inside .sort-btn)
Signed-off-by: Michael Uray <[email protected]>
Add filter-icon buttons to the Time/Admin/Action/Target column headers (no button on Details — that column is unfilterable). Clicking the icon scrolls the matching top-bar control into view and focuses it, which gives Users-table parity without duplicating the filter logic or introducing a new popup component. The Active state mirrors the AdminUsers .filter-btn rule: 0.35 opacity until hover, blue tint when the column has an active filter. Selectors target a small set of stable class names on the filter-bar inputs (audit-filter-admin, -target, -from) and a data-attribute wrapper for the action multi-select. Signed-off-by: Michael Uray <[email protected]>
Add a preset dropdown left of the From/To date inputs offering
1d/2d/3d/1w/2w/1m/Custom. Picking a preset clamps filterFrom and
filterTo to (today - N, today) and disables the date inputs so the
admin cannot edit them without first switching back to "Custom range".
Default on first mount is "Last 3 days", scoping the audit table to a
recent window instead of paging the full history. The previous
onMount(() => void reload()) is replaced by applyDateRangePreset('3d'),
which calls reload() exactly once so there is no double-fetch.
Reset also returns to "Last 3 days" instead of clearing the date range
entirely — the empty-range UX was a regression vector for accidental
universe scans.
Signed-off-by: Michael Uray <[email protected]>
…wnload
Admin Bearer token previously sat in the accounts.csv export URL,
leaking it to browser history, server access logs, Referer headers,
and any third-party APM. Replace window.open(url-with-token) with
fetch(url, { headers: Authorization: Bearer ... }) -> blob -> temporary
<a download> trick.
Server-side keeps the query-string fallback for one release with a
deprecation warning log so any external scripts that bookmarked the
URL still work.
Workspaces CSV is already client-side blob -- not affected.
Signed-off-by: Michael Uray <[email protected]>
bulkSetDisabled.enable previously called the public enableAccount per row -- each call re-ran assertAdmin + verifyTokenVersion + an account findOne, which the caller already did once before entering the loop. With a 50-row bulk-enable that's 50 redundant DB reads + 50 token- version checks for the same admin. Switch to enableAccountInternal (new sibling of disableAccountInternal that skips requireAdmin and takes the already-decoded adminUuid). Public enableAccount now delegates to enableAccountInternal so single-row callers stay unchanged. Signed-off-by: Michael Uray <[email protected]>
Toolbar previously displayed "Selected: 7" while the Mass Archive button next to it said "Mass Archive 3" -- admin selects archived rows + active rows, but archive only operates on active. Confusing. Show both when they differ: "Selected: 7 . 3 active". When the user has only selected active rows, the bar collapses to a single count. Signed-off-by: Michael Uray <[email protected]>
Mock expectation for AccountPostgresDbCollection.find lagged behind the production query. The account table SELECT projection grew three columns during the admin-panel work — disabled_at (V25 / disable feature), token_version (V26 / force-logout), and last_activity_at (V27 / last-activity tracking) — and the test still asserted the pre-V25 list, breaking reproducibly. Update the expected SQL string to match the current 10-column projection. No code change. 45/45 postgres.test.ts now passes. Caught by Codex's pre-PR test re-run. Signed-off-by: Michael Uray <[email protected]>
Issue 14: The "Orphan accounts" button on AdminUsers had broken UX — clicking it set columnFilters.orphan but provided no way to clear the filter, and rows had no visual marker indicating which accounts were orphans. Users had to manually click each column-filter or reload the page to escape. Fix: - Drop the standalone "Orphan accounts" Button. - Make the existing "Orphan N" stat-pill clickable: click toggles columnFilters.orphan on/off. Active state shows a stronger warning fill + filter-icon prefix so the on/off state is obvious. - pill is keyboard-accessible (role=button, tabindex=0, Enter/Space). - Add an "orphan" mini-badge in AdminUsersRow next to the workspace count "0" whenever status=active && workspaceCount=0, so admins can spot orphans by scanning the table even without the filter on. Signed-off-by: Michael Uray <[email protected]>
Issue 15: The FilterPresetMenu rendered 3 separate buttons ("Apply
preset" / "Save as preset…" / "Manage") which crowded the filter row
on both AdminUsers and AdminWorkspaces.
Fix: replace with one ButtonMenu titled "Presets". Items use a
prefix-based id ("apply::NAME", "__save", "del::NAME") so a single
on:selected handler routes to the right callback. Save action is
always present; apply/delete entries only render when presets exist.
Deviation from spec: dropped the divider entries between sections —
DropdownIntlItem has no `disabled`/`divider` field in @hcengineering/ui,
so the spec's "─────" placeholders would have rendered as clickable
no-op rows. Flat list per the spec's fallback note.
Signed-off-by: Michael Uray <[email protected]>
…tton
Issue 16: Export CSV on the Workspaces toolbar was kind='regular',
giving it the same visual weight as the brand-new primary action.
Drop it (and "Top 10 by storage") to kind='ghost' so secondary
discoverability actions visually recede behind the primary CTA.
AdminUsers received the same treatment as part of Issue 14 (the
"Add user" primary button now stands out from Export CSV).
Issue 17: There was no admin-panel entry-point to create a workspace
— users had to go through /login/createWorkspace by URL. Add an
"Add workspace" primary button to the workspaces toolbar. It uses
the existing goTo('createWorkspace') helper to reuse the multi-step
creation form (CreateWorkspace.svelte) rather than duplicating it
inline.
Signed-off-by: Michael Uray <[email protected]>
Issue 18: the Export CSV button currently sits in the stats-row using
kind={'ghost'} which renders as plain text — users don't recognize it
as a button. Move it into the filter/preset row right after the
FilterPresetMenu and switch to kind={'regular'} so it matches the
other filter-row controls visually.
AdminUsers.svelte: removed from stats-row, added after FilterPresetMenu
inside the filters div with regular kind.
AdminWorkspaces.svelte: moved within ws-list-toolbar-actions from the
front of the row to just after FilterPresetMenu (still before Add
workspace), regular kind, keeps small size to match neighbours. Top 10
by storage and Add workspace stay where they are.
Signed-off-by: Michael Uray <[email protected]>
…lters
Issue 19: previously only the Orphan pill was clickable. The other
four pills (Total, Active, Disabled, Admins) were static labels even
though they describe natural filter axes.
Behaviour
- Total: clears every quick-filter (status + Admin + Orphan); shows
the "show all" neutral active state when no filter is currently set.
- Active / Disabled: drive filter.status; clicking the same pill again
clears, clicking the other one switches (mutually exclusive via the
shared dropdown).
- Admins: toggles columnFilters.isAdmin = { isAdmin: true } so the
payload flows through the existing mergeColumnFilters() pipeline
into the listAccountsAdmin call.
- Orphan: unchanged.
Server wiring
The DB-layer query type (ListAccountsAdminQueryParams) already
supported isAdmin (listAccountsAdminPg.ts:60-63) but the public DTO
ListAccountsAdminParams and the serviceOperations mapper did not
forward it. Added the field to the public type and a passthrough in
serviceOperations.listAccountsAdmin, analogous to the orphan plumbing
shipped in c72f852.
Visual state
Each pill gets a semantic active tint:
- Active -> green rgba(16,185,129,0.18)
- Disabled -> red rgba(239,68,68,0.18)
- Admins -> blue rgba(96,165,250,0.18)
- Orphan -> amber (existing)
- Total -> neutral theme-bg-accent
Refactored .stat-pill-clickable to a shared base (cursor, border-radius,
padding, focus ring) so all variants share affordance; the previous
amber-only hover that was scoped to Orphan moves into
.stat-pill-warning.is-filter-active.
Each pill keeps role="button" + tabindex="0" + keyboard handler so
Enter/Space toggle the filter.
Signed-off-by: Michael Uray <[email protected]>
|
Connected to Huly®: UBERF-16473 |
|
Share some screenshots, would be interesting to see. But also I think this PR is too big |
|
@ignatremizov thanks for taking a look! Both points well taken. Screenshots — the companion docs PR hcengineering/huly-docs#71 already carries 14 anonymized captures (1700×1000) of every panel state. The most informative ones at a glance:
Full set (14 PNGs) in PR size — fair, ~159 commits. Happy to split if that makes review easier. My suggested cut, each PR independently deployable, each on its own branch, in this order so later PRs can build on the earlier migrations:
If you'd prefer a different split (e.g. extract V27–V30 as a backend-only PR first, then UI), just say so — happy to land it however gets the review through. Either way I'll keep the current PR open until you tell me to close + repost, so review work here isn't lost. |
|
Thanks for the detailed reply @MichaelUray It's up to the maintainers how they want the review PRs - kept as is or split. My two cents, for UX, bulk actions bars are usually at the top of the table, either appearing just under the header row, or above the table, rarely below it Also check the identities management of users when connected with GitHub, and how it interfaces with canonical contacts/ employee and the new beta HR management areas. User can mean a lot of things - also guest users or client contacts that could be added to the workspace. Would be good to add in your PR body what problem this solves - it sounded like you manage multiple workspaces and would avoid SQL for mass user management across those workspaces, which is fair. I'm new to the codebase so I'm not sure how well Huly supports multi tenancy, sounds like an admin of workspaces primitive existed but wasn't adequately exposed for controls? I think it might be better expanding existing user/person management surfaces rather than creating new ones, but again, it's just a question of UX and long-term maintance. |
Bulk action bars in data-grid UIs conventionally appear above the table
(either just under the header row or as a strip directly above), not
below the pagination row. The original "sticky bottom" placement was
chosen to avoid a row-position shift on first selection, but the
convention break costs more in discoverability than it saves in layout
stability. Move it to the top:
- BulkActionBar.svelte: switch sticky anchor from `bottom: 0` to
`top: 0`, flip box-shadow direction (now casts downward), swap
margin-top → margin-bottom so the table sits below it cleanly.
- AdminUsers.svelte: place <BulkActionBar> between the filter row
and <AdminUsersTable>; pagination stays below the table.
Visibility logic unchanged: `display: none` when count === 0, so the
single-row workflow shows no gap. Follows @ignatremizov's review note
on PR hcengineering#10883.
Signed-off-by: Michael Uray <[email protected]>
Adds a per-account "Delete account…" button to the AdminUsersDrawer's
Danger zone, behind a typed-confirm dialog. Surfaces the existing
`deleteAccount` RPC (already wired in account-client) that Huly's
postgres collection cascades across account_passwords, mailbox /
integration secrets, and workspace_members. Social IDs are kept but
marked un-verified so historical createdBy / modifiedBy references
keep resolving to a name.
Server-side changes (server/account/src/operations.ts):
- Replace ad-hoc `extra?.admin === 'true'` check with the standard
`requireAdmin()` (adds tokenVersion verification, returns adminUuid
for audit-log).
- Add cannot_self_delete + last_admin guards consistent with
`disableAccount`.
- Insert `admin_audit_log` row (new action `delete_account`) after a
successful cascade so the deletion shows up in the Audit log
alongside disable / enable / role-change entries.
- Keep the existing `account_events` insert (ACCOUNT_DELETED) so the
lifecycle producer still sees the event.
UI changes:
- New `DeleteAccountConfirm.svelte`: identity label + workspace count
+ irreversible-consequences list + typed `DELETE` phrase. Disabled
submit until the phrase matches; renders a `Last admin` blocker
state for completeness even though server enforces it.
- AdminUsersDrawer Actions section gains a "Danger zone" gutter that
sits below disable/enable, separated by a divider so the visual
weight matches the consequence.
- Maps server error codes (cannot_self_delete, last_admin, Forbidden)
to friendly toasts, falls back to err.message otherwise.
Bulk delete is intentionally NOT added: hard-delete is irreversible
and the bulk-bar pattern (single click + dialog) is too easy to fire
by accident on a destructive operation.
Signed-off-by: Michael Uray <[email protected]>
Addresses four findings from the Codex review on the delete-account commit (208ab5b): 1. BLOCKER — FK cascade failure on accounts with subscriptions or workspace permissions. subscription.account_uuid (V19) and workspace_permissions.account_uuid (V24) both reference account(uuid) without ON DELETE CASCADE. Without explicit cleanup, the final DELETE FROM account row hits a foreign-key violation for any account that owns billing rows or has any per-workspace permission grant — exactly the accounts most likely to be deleted by an admin. Fix in postgres deleteAccount (collections/postgres/postgres.ts): delete the subscription and workspace_permissions rows in the same transaction as the rest of the teardown, before the final account.deleteMany. 2. Important — `deleteAccount` did not validate target existence. A bad UUID slipped through into db.deleteAccount() and then into the audit / accountEvent writes, leaving either a DB-layer cascade error or a phantom ACCOUNT_DELETED + admin_audit_log row referencing a never-existed account. Mirror disableAccount: db.account.findOne first, throw AccountNotFound BEFORE any destructive work or audit insertion. 3. Important — Audit log UI action dropdown was missing `delete_account`. Server emits action: 'delete_account' on the new RPC, but AdminAudit.svelte's ACTION_OPTIONS list omitted it, so admins could not filter the audit log by this action. Added next to create_account in the alphabetical position. 4. Test gap — no Jest coverage for the new RPC. Added __tests__/deleteAccount.test.ts (7 tests): - admin-token-required (Forbidden) - cannot_self_delete - bad uuid / empty uuid → BadRequest - AccountNotFound when target row missing (asserts neither cascade nor audit fired) - last_admin when target is the only ADMIN_EMAILS member (asserts cascade did NOT fire) - success: cascade + ACCOUNT_DELETED event + admin_audit_log row (asserts adminAccount / targetAccount / action / details.email) - OIDC-only target: audit row carries details.targetEmail = null Mocks @hcengineering/server-token decodeTokenVerbose and supplies a minimal AccountDB shape — same pattern as disableAccount.test.ts. All 7 pass locally. Signed-off-by: Michael Uray <[email protected]>
AdminAudit.svelte was calling getAccountClient(null).listAuditAdmin(), which builds an unauthenticated client. The server-side listAuditAdmin RPC requires extra.admin === 'true' (assertAdmin), so every reload returned PlatformError(Forbidden) and the audit table rendered the empty-state regardless of which filter was active. Fix: call getAccountClient() with the default token (the same admin's token already carrying extra.admin === 'true' that powers every other admin-panel RPC — listAccountsAdmin, disable, delete, etc.). Found while end-to-end testing the delete_account action on dev: the DB had two delete_account audit rows from the just-deleted accounts, but the table stayed empty across every filter combination. With this fix and the appropriate date range (the default 'Last 3 days' preset sets the to-date to today 00:00, which is a separate UX papercut: it excludes anything that happened today — workaround is a Custom range extending to tomorrow), the rows show up correctly. Signed-off-by: Michael Uray <[email protected]>
Two non-blocking but real bugs surfaced during the full Playwright sweep
of the admin panel after the hard-delete work landed. Both fixed here.
1. Audit log date-range 'to' boundary excluded today's events.
AdminAudit.svelte interpreted the yyyy-mm-dd 'to' input as Date(str),
which yields UTC midnight (00:00:00.000) of that day. That made the
'Last 3 days' default preset exclude every audit entry logged between
00:00 and 'now' on today's date — i.e. on a day where the admin had
just disabled or deleted an account, the resulting audit row was
permanently invisible until they switched to a Custom range
extending to tomorrow.
Anchor 'to' to the end of the local day (23:59:59.999) inside the
listAuditAdmin params builder. The matching server-side bound is a
<= comparison so the row at 04:30 today now falls inside the
05/23-05/26 default window.
2. Drawer offered Disable / Delete on the admin's own account.
Server-side guards (cannot_self_disable, cannot_self_delete) catch
the call, but the user only sees the error after hitting Disable /
typing DELETE and pressing the red button. Surface it earlier: the
AdminUsersDrawer now reads the calling admin's uuid from the JWT
payload (base64-decoded locally — no extra RPC) and disables both
buttons with explanatory labels ('Cannot disable yourself' /
'Cannot delete yourself') when isSelf === true.
The guards are not security primitives — they remain on the server
side. This is a UX layer so the admin doesn't have to type DELETE
into a typed-confirm to find out they can't lock themselves out.
Signed-off-by: Michael Uray <[email protected]>
…block + audit cutoff fix Documentation update for the iteration on the admin panel since the first review of hcengineering/platform#10883. Three new screenshots, two updated screenshots, two new sections, and several inline edits. New / updated content ===================== users.mdx - 'Danger zone — Delete account' section: walks the typed-confirm dialog, lists the full Postgres cascade (account row, password, workspace_members, mailbox+secrets, integrations+secrets, V19 subscription rows, V24 workspace_permissions rows), and explains what is preserved (social_id rows kept but un-verified so historical createdBy/modifiedBy refs keep resolving). Documents the cannot_self_delete / last_admin / Forbidden server-side guards. - 'Self-block' section: documents that the drawer disables both Disable and Delete buttons when opened on the calling admin's own row, with explanatory labels. - Bulk-actions section reworded to describe the new top placement (sticky strip above the table, between filter row and header row) — matches the standard data-grid convention rather than the bottom-toolbar pattern the original PR shipped with. - 'Bulk delete is intentionally not offered' rationale. audit-log.mdx - Lists the new delete_account action alongside the existing vocabulary in the page intro and the Actions filter dropdown. - Documents the inclusive-end-of-day To-date behaviour so reviewers know the 'Last 3 days' default does capture today's entries (this was previously a papercut where the To filter was anchored to UTC midnight 00:00 of the chosen day). - Details column section now notes the delete_account audit payload shape ({ targetEmail }) and links to the Users page for the cascade rationale. Screenshots =========== - audit-delete-account.png (NEW): audit table filtered to show delete_account entries alongside disable / add_workspace_member, with anonymized synthetic data. - users-delete-confirm.png (NEW): typed-DELETE confirmation dialog on a synthetic test account. - users-drawer-self-block.png (NEW): admin's own drawer showing the greyed-out Disable / Delete buttons. - users-bulk-bar.png (UPDATED): bar in its new sticky-top position above the user table, with the standard 2-selected affordance. - audit-default.png (UPDATED): re-captured against the same synthetic dataset so names match across the three audit shots. All screenshots were captured against the self-hosted dev instance with personal data anonymized via DOM patching at capture time. Only synthetic test accounts (Alice Admin, Bob Builder, Carol Carpenter, Dan Demo, Erin External, Frank Foxtrot, Greta Golf, Hank Hotel, Iris India, Jake Juliet) appear in the captures. Signed-off-by: Michael Uray <[email protected]>
|
@ignatremizov — thank you again for taking the time on the first review. Every one of the three points you raised has produced a concrete change in the code now. Walkthrough below. 1. Bulk-action bar positionYou wrote:
Fully agreed once you pointed it out — the original "sticky bottom" came from wanting to avoid a layout shift on first selection, but that's a worse trade than the convention break. Moved to a sticky-top position directly above the table, between the filter row and the header row, hidden when no rows are selected. Commit 2. Identities — Employee / Contact / HR / Guest / GitHubYou raised the broader concern of how this panel relates to canonical
So a single human can appear as: (a) one global account, (b) zero-to-N Employees (one per workspace they joined), (c) one Person/Contact entry per workspace, (d) optional GitHub identity. The admin panel currently surfaces (a) and the role part of Guest accounts and external client contacts appear in the table the same way as User+ accounts (each carries a global account row + an email social ID). The role chip in the table and the role picker in the drawer reflect what's in 3. PR body — what problem this solvesThat was the gap. The PR body now opens with a "The problem this solves" section that states the operator pain (every cross-workspace administrative task today routes through raw SQL), then a "What already existed" subsection that maps each new piece of UI to the existing account-service primitive it wraps ( Your specific suggestion — "it might be better expanding existing user/person management surfaces rather than creating new ones" — got an explicit answer there too: the two existing surfaces we evaluated were Two additional things I owed back to the reviewWhile verifying the above with an end-to-end Playwright sweep of the panel, two more bugs surfaced. Both fixed in this iteration:
Hard-delete account (operator gap reported separately)Came up in parallel testing as the missing companion to Disable. Added behind a typed- Server-side guards ( In the drawer, both Disable and Delete are greyed out when opened on the calling admin's own row so the destructive paths are unreachable client-side. Server guards still enforce as the security primitive — this is a UX layer that surfaces the "you can't do this" earlier than the typed-confirm step. Bulk delete is intentionally not offered. Hard-delete is irreversible and the bulk-bar pattern (one click + one dialog) is too easy to misfire on a destructive operation. For fleet cleanup: Disable first, then delete one row at a time. The new Status5 new commits since the original PR head, all on Companion docs PR hcengineering/huly-docs#71 is updated to match, including the three new sections (Danger zone — Delete account, Self-block, delete_account in Audit) and 17 anonymized screenshots. Thanks again — the review was load-bearing for the iteration; happy to keep going on anything else. |
|
Is hard delete safe? How would it affect Tracker issues assigned/created by the user? Chats? Cards? Documents? Do they disappear in the delete cascade, or render with an error/empty user slot? Usually suspend is the only option available in most services due to data integrity... Hard delete may introduce unintended consequences. I've seen only GitHub on hard delete just re-assign user repos/comments to "@ghost" when an account has been hard deleted, which is somewhat elegant but loses auditability. Suspend/soft-delete is usually just much safer. |











Admin Panel: Users + Workspaces + Audit log (V27 → V30)
This PR introduces a full admin panel under
/login/adminwith threesections — Users, Workspaces, and Audit log — backed by four DB
migrations (V27 → V30) and a suite of new admin-only RPC methods on
@hcengineering/account. Self-hosted Huly operators can now manageaccounts and workspaces, audit administrative actions, and bulk-disable
or archive at scale, all without dropping to SQL.
Companion docs: hcengineering/huly-docs#71 — adds a top-level Admin panel section to the docs site with Overview / Users / Workspaces / Audit log / Configuration pages and screenshots.
The problem this solves
(Added in response to @ignatremizov's first-round review.)
Self-hosted Huly operators have to drop to raw SQL for everything
cross-workspace today: disabling a compromised account, force-logging
out a user after a security event, finding orphan accounts (no
workspace memberships), checking who has admin access on which
workspace, bulk-archiving stale workspaces, or auditing what an admin
did last quarter. There is a workspace-scoped
Settings → Memberssurface, but no global-account surface — and once you have more than
~5 workspaces, the per-workspace Settings pane stops scaling.
What already existed (so this is exposure, not reinvention). The
account service already shipped the primitives —
assignWorkspace,unassignWorkspace, social-id management,setWorkspaceMemberRole,the
ADMIN_EMAILSenv gate. They just had no UI and were not exposedvia
account-client. This PR wires UI + bulk endpoints + audit on topof what the account service already enforces.
Why a separate route instead of expanding an existing one. Two
existing surfaces were in scope to extend rather than add:
Settings → Members— workspace-scoped, member-of-workspace view. Can't cross workspace; can't disable an account
globally; can't show which workspaces a person is in. Wrong domain
to bolt cross-workspace controls onto.
system-managerplugin — runs inside the per-workspacetransactor session pipeline and doesn't talk to the account DB
directly. The admin panel uses the account-service RPC instead,
which is the source of truth for accounts and workspaces.
We chose
/login/adminbecause of the session boundary (the panel mustwork even when no workspace is open). If you'd rather see this folded
under
system-manageronce the dust settles, the routes can be moved.Multi-tenancy. Huly already supports one account → many workspaces.
ADMIN_EMAILSwas the existing RBAC primitive; before this PR, "beingadmin" had no UI meaning — it only gated server-side mass operations
callable from internal scripts. This PR is the first surfaced consumer
of that primitive.
Iteration since the first review
Five commits were added on top of the original 159-commit history in
response to your feedback. Each one is small and addresses one specific
concern:
0c3af29d1cBulkActionBarfrom sticky-bottom to sticky-top inAdminUsers.svelte; the bar now appears between the filter row and the table, hidden when no rows are selected.208ab5bdd4DELETEconfirm inAdminUsersDrawer. Surfaces the existingdeleteAccountRPC, gated by server-sidecannot_self_delete+last_adminchecks (now also wired) and a Danger Zone in the drawer Actions section.cddaa1c288deleteAccountnow also clearssubscription+workspace_permissionsin the same transaction (the FK violation was a release blocker for any account that owned billing rows or per-workspace permissions). AddedAccountNotFoundexistence check before destructive work / audit insert. Addeddelete_accounttoACTION_OPTIONSinAdminAudit.svelte. NewdeleteAccount.test.tswith 7 cases (admin-token, self-delete, last-admin, AccountNotFound, success cascade, audit row shape, OIDC-only target).384a81d979AdminAudit.sveltewas callinggetAccountClient(null).listAuditAdmin()— explicitly passing no token. Server-sideassertAdminconsequently always returned Forbidden. Fix: use the default token (getAccountClient()) so the existing admin claim travels with the call, like every other admin-panel RPC.ab2589361eto-date parsed as UTC midnight 00:00:00.000, which excluded every event logged "today" — the default "Last 3 days" preset hid the freshly-created disable / delete entries until the admin manually picked a Custom range. Anchortoto end-of-day (23:59:59.999) inside the listAuditAdmin params builder. (b) Drawer offered Disable / Delete buttons on the admin's own row; server guards (cannot_self_disable,cannot_self_delete) caught it, but only after the admin had clicked through. SurfaceisSelfclient-side by decoding the JWT payload locally — no extra RPC — and grey out both buttons with explanatory labels. Server guards still enforce as the security primitive; this is purely a UX preview of the impossible action.Identities / Employee / Contact mapping
You asked about
Employee/ canonical contacts / HR integration. Wherethis PR sits today:
account.uuidplus itsemail-typed
social_id. Person / Employee / Contact / HR recordsare workspace-internal and untouched.
Employeebridge: lives in the workspace'stransactor DB, not the account DB. The admin panel is a global-
account view and does not enumerate Employee rows.
accounts (each has a global account row + email social-id). The
drawer's role picker and the table's role chip reflect what's in
global_account.workspace_members.rolefor that workspace.additional
github-typed social-id. The drawer surfaces this in theIdentities section but does not link out to the IdP — deferred.
If the value would be there, the drawer can be extended in a follow-up
to add a "in workspace X, you're Employee Y" back-link. Not in this PR.
Per-phase commit ranges
394e3db143…efe42d1617(~110 commits)listAccountsAdmin,getAccountDetails,disableAccount/enableAccount,setWorkspaceMemberRole,removeWorkspaceMember,triggerPasswordReset,addToWorkspace,bulkSetDisabled,bulkSendPasswordReset,bulkAddToWorkspace,bulkRemoveFromWorkspace. Force-logout hook (AccountDisabledTx → client-side store). Admin-only RBAC viaassertAdmin. UI: AdminShell, AdminUsers + drawer, AdminWorkspaces + drawer, MassActionConfirm, FilterPresetMenu, ColumnFilterPopup. V28 migration to relaxadmin_audit_log.target_accountNOT NULL + 5 query-tuning indexes.c5da4d170c…d80a9710d0(~10 commits)decodeFilterParamextract with strict base64 + recursive prototype-pollution rejection + 32-level depth cap (15 tests). SharedcsvEscape/csvLinein@hcengineering/account-client(9 tests).mergeColumnFilters+DEBOUNCE_MSextract (5 tests). A11y basics —html lang,<th scope="col">, drawer closearia-label. Audit empty state component. AdminAudit render cap (200 rows). 10s → 30s throttle on workspace stats poll.9e03bc3948…d97b80f8c0(~4 commits)TokenBucketLimiter, 4 tests).AUDIT_RETENTION_DAYSenv-driven daily prune cron with Mongo auto-disable. V29/V30 migration:batch_id UUID NULLcolumn + partial indexWHERE batch_id IS NOT NULL, split into two migrations because CockroachDB rejects partial-index DDL on a same-tx-added column. Bulk-action service calls stamp one UUID across all their audit rows; UI groups consecutive same-batch rows under a non-interactive header.4da2f06f8e…cff6e8a19e(~6 commits)a267d3d2f4…9461de3092(~5 commits)83b9cdeedb…60e4d27e1d(9 commits)enableAccountInternalextracted (drops N redundantassertAdminper bulk-enable). Workspaces selection-bar count vs Mass Archive count clarified. Orphan-as-clickable-pill with row badge. All stat pills toggleable as filters. "Add workspace" primary button on Workspaces. Export CSV moved next to Presets in the filter row, regular kind. Three-button preset trio collapsed into a singlePresetsdropdown.postgres.test.tsmock SELECT projection updated to match V25–V27 column additions.0c3af29d1c…ab2589361e(5 commits)Database migrations
disabled_at,token_version,last_activity_atonaccount+admin_audit_logtableadmin_audit_log.target_accountNULLABLE + 5 query indexesadmin_audit_log.batch_id UUID NULLadmin_audit_log(batch_id) WHERE batch_id IS NOT NULLAll migrations are forward-only with
IF NOT EXISTSguards. Rollbackis "leave the schema, redeploy the previous account pod" — no down-
migrations.
Security posture
decodeFilterParam: strict base64 + JSON-object + recursive__proto__/constructor/prototyperejection + 32-level depth cap. Returns400 Bad Requestwith a structured reason; never silently accepts malformed input.%,_,\) escaped on every user-supplied substring filter (Users search, audit substring filters) with explicitESCAPE '\'clauses.fetch + Authorization: Bearer + blob downloadinstead ofwindow.open(url-with-token). The server retains the legacy?token=…query-string fallback for one release with a deprecation warning log, so any out-of-band scripts that bookmarked the URL keep working. Removing the legacy fallback is in the deferred list.assertAdmingates every admin-only RPC. Single-source-of-truth:ADMIN_EMAILSenv. Force-logout when an active admin is disabled mid-session.cannot_self_delete+last_adminguards, full Postgres cascade in one transaction (account, password, workspace_members, mailbox+secrets, integrations+secrets, subscription, workspace_permissions); social_ids are kept but marked un-verified so historicalcreatedBy/modifiedByreferences keep showing a name. Client requires typingDELETEinto a confirmation dialog. Bulk delete is intentionally NOT offered.Tests
Pass: 580 / 584 across the admin-targeted suites.
server/account(all)postgres-real.test.tsare pre-existing CockroachDB-required integration tests, identical atupstream/developmerge-baseserver/account-service rateLimiterserver/accountdeleteAccount.test.tsserver/accountlistAuditAdmin.test.tsfoundations/core/packages/account-client csv + listAccountsAdminplugins/login-resources(mutex, columnFilters, signupTokenGuard)Total new tests added by this PR: ~127 (decodeFilterParam, csv,
columnFilters, rateLimiter, pruneAuditOlderThan, escapeLike,
bulkActions, listAccountsAdmin, listAuditAdmin, getAccountDetails,
assertAdmin, deleteAccount).
Reviews completed before this PR
passed spec-compliance review + code-quality review before
integration.
wildcard escape, bulk-enable redundant validation, CSV CRLF + BOM,
429 charset, token-in-URL.
status filter sending wrong field shape, double Popup host, stuck
sort arrow, orphan-mapper drop, header/body alignment, audit filter
UX, ~6 more.
postgres.test.tsmockthat hadn't been updated for the V25–V27 SELECT projection
additions (fixed in
b90bbbcc30).cascade blocker, the missing existence check, the audit-dropdown
gap, and the missing test coverage. All four fixed and verified in
cddaa1c288; Codex green on the follow-up.T1 stat-pill filters → T9 Workspaces table. Caught the audit-token
bug (Forbidden on every audit reload), the audit-date end-of-day
cutoff, and the self-disable/self-delete UX gap; all three fixed in
384a81d979andab2589361e.Deliberately deferred (tracked, not blocking this PR)
product/security questions; what's the inverse of
archive_workspacein 6 months? Reset_password has no inverse. Doing this wrong is
worse than not doing it.
?token=…query-string fallback on the CSV exportroute once one release has shipped with the new Authorization-header
flow. Currently emits a deprecation warning when hit.
/admin/export/accounts.csv: iflistAccountsAdminorctx.res.writethrows after the 200 headerhas been sent, the request fails mid-stream without a structured
error envelope. Pre-existing behaviour, not introduced here, but worth
hardening alongside the legacy-token-fallback removal.
DELETEin retention prune — only matters at >10Mrow scale.
BulkPick / AddMember) — refactor candidate, not a feature.
fill/inline/disabledpropsrejected by 6 icon components) — 39 console warnings per page load,
upstream-side cleanup more appropriate than admin-panel side.
Employeerow (in response to theidentities question above).
Live deployment evidence
Deployed and live-verified on a self-hosted Huly v0.7.423 instance
with the CockroachDB backend and OIDC SSO. The following behaviours
have been exercised end-to-end on a 12-user / 14-workspace test
dataset: 5 clickable stat pills with per-pill filter state, drawer
reactive refetch on row swap, outside-click + Escape drawer dismiss,
Workspaces per-row selection + selection-driven Mass Archive,
batch_idrow grouping in the audit log, date-range presets, CSVexport with Authorization header, CSV rate-limit 429 response,
hard-delete with typed confirm + cascade + audit-log entry, and
self-disable/self-delete UI guards on the admin's own row.
Screenshots in hcengineering/huly-docs#71 (
src/assets/screenshots/huly/admin-panel/).Notes for upstream reviewers
stubs throw "not implemented" with a meaningful error. The audit
retention cron auto-disables itself on Mongo to avoid log spam.
getEmbeddedLabeleverywhere soi18n is a future drop-in.
primitives:
@hcengineering/uiButton / ButtonMenu / Dropdown / CheckBox,@hcengineering/platformgetEmbeddedLabel, postgres.js Sql template tag).develop.We've kept the granular history as documentation, not because we
insist on preserving it.