Admin Panel: Users + Workspaces + Audit log (V27 → V30)#10883
Admin Panel: Users + Workspaces + Audit log (V27 → V30)#10883MichaelUray wants to merge 169 commits into
Conversation
…t log Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…n + lastActivityAt Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… 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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
…ships + recent audit) Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
… 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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
… 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 <michaeluray@users.noreply.github.com>
…+ 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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
… 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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
… 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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
|
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
|
@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. |
Per Igor's PR comment, hard-delete should not ship in this PR. The scope reduction removes the full code path, not just the UI surface: - DeleteAccountConfirm.svelte component file - Danger Zone section in AdminUsersDrawer (with the Delete button + Self-block markup specific to it; Disable's Self-block stays) - deleteAccount RPC function in operations.ts (+ cannot_self_delete guard, V19/V24 cascade calls scoped to it, audit-row write, AccountEventType.ACCOUNT_DELETED emit) - 'deleteAccount' from the AccountMethods union (operations.ts) - deleteAccount: wrap(deleteAccount) from getMethods (operations.ts) - deleteAccount method from AccountDB interface (types.ts) - 'delete_account' from AdminAuditAction (types.ts) - Postgres and Mongo implementations of deleteAccount - deleteAccount client binding in foundations/core/packages/account-client/src/client.ts - 'delete_account' entry from AdminAudit ACTION_OPTIONS dropdown - deleteAccount.test.ts test file The ACCOUNT_DELETED entry in AccountEventType stays — Task 1 discovery confirmed it pre-existed PR hcengineering#10883 (introduced by upstream UBERF-11998 in PR hcengineering#9441, reachable from develop), so it isn't ours to remove. Hard-delete, if it comes back, will be a separate smaller PR with a content-reference audit, sole-owner workspace guard, and impact preview. Disable remains as the only account-state action in this PR. Spec: docs/superpowers/specs/2026-05-27-huly-admin-account-lifecycle-design.md Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Each workspace membership row in the admin user drawer now exposes an 'Open ↗' link to /workbench/<wsUrl> in a new tab. workspaceMembers rows do not carry an archived field (verified at server/account/src/serviceOperations.ts:227), so the link is always enabled; archived workspaces are handled workbench-side. Per spec D12, V5b (Employee deep-link via per-workspace Ref<Employee> enrichment) is a future PR — V5a provides ~80% of the operational value with zero backend changes. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Switching the date-range preset dropdown to 'Custom range' no longer clears the date inputs; the previously-computed From/To stay so the admin can tweak one boundary instead of re-entering both. Cold-start with both inputs empty falls back to a 3-day window. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Each audit-row timestamp (and the bulk-batch header) now exposes a two-line title tooltip on hover: Local time: 2026-05-27 14:33 (Europe/Vienna, UTC+1) UTC: 2026-05-27 13:33 Helper tzTooltip is extracted as its own module for unit testability; covers the Intl-unavailable degraded path. Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…uced scope)
V13 instruments three call sites because the code flow is more complex
than the v2 plan assumed:
1. disableAccountInternal (operations.ts) - already had the
cannot_self_disable + last_admin checks. v13 adds an audit-write
before each throw. Method tag: 'disableAccount'.
2. disableAccount (operations.ts) was a SEPARATE duplicate of
disableAccountInternal's logic, not a thin wrapper. v3 refactors
it to delegate: const adminUuid = await requireAdmin(...); return
await disableAccountInternal(...). This eliminates ~50 lines of
duplication and routes the audit-write from (1) through to the
single-RPC path automatically.
3. bulkSetDisabled (serviceOperations.ts) calls bulkLoop with a
selfFilter. bulkLoop short-circuits self-targets BEFORE op() runs,
so disableAccountInternal never sees the bulk-self case - meaning
the audit-write from (1) doesn't fire for that path. v3 adds an
explicit pre-bulkLoop audit-write in bulkSetDisabled. Method tag:
'bulkSetDisabled'.
Two deny reasons emit an audit row before throwing. The methodName
parameter on disableAccountInternal carries the caller identity so
both reasons get the correct method tag:
Single RPC -> method='disableAccount' (default)
Bulk RPC -> method='bulkSetDisabled' (passed explicitly by
bulkSetDisabled per-target,
and by the pre-bulkLoop
self-write in bulkSetDisabled
itself)
Reasons -> 'self_disable' (UI/RPC-caller is target) or
'last_admin' (target is sole remaining admin)
Pre-auth Forbidden (no admin claim) writes NO audit row - attack-
surface concern per spec.
Rate-limit key: (actor_uuid, reason, method) per 1-hour window,
in-memory Map. Resets on pod restart (acceptable threat-model trade-
off). The method component means single-RPC and bulk attempts produce
separate rows when both happen, giving operators visibility into both
attack surfaces.
Reduced scope vs. v5 plan: the self_delete reason is gone with the
deleteAccount removal in PR hcengineering#10883. Hard-Delete v2 (future PR) will
re-introduce the self_delete + workspace-orphan deny audits.
Audit filter dropdown in the UI gains the new action type.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Per Igor's PR comment, hard-delete should not ship in this PR. The scope reduction removes the full code path, not just the UI surface: - DeleteAccountConfirm.svelte component file - Danger Zone section in AdminUsersDrawer (with the Delete button + Self-block markup specific to it; Disable's Self-block stays) - deleteAccount RPC function in operations.ts (+ cannot_self_delete guard, V19/V24 cascade calls scoped to it, audit-row write, AccountEventType.ACCOUNT_DELETED emit) - 'deleteAccount' from the AccountMethods union (operations.ts) - deleteAccount: wrap(deleteAccount) from getMethods (operations.ts) - deleteAccount method from AccountDB interface (types.ts) - 'delete_account' from AdminAuditAction (types.ts) - Postgres and Mongo implementations of deleteAccount - deleteAccount client binding in foundations/core/packages/account-client/src/client.ts - 'delete_account' entry from AdminAudit ACTION_OPTIONS dropdown - deleteAccount.test.ts test file The ACCOUNT_DELETED entry in AccountEventType stays — Task 1 discovery confirmed it pre-existed PR hcengineering#10883 (introduced by upstream UBERF-11998 in PR hcengineering#9441, reachable from develop), so it isn't ours to remove. Hard-delete, if it comes back, will be a separate smaller PR with a content-reference audit, sole-owner workspace guard, and impact preview. Disable remains as the only account-state action in this PR. Spec: docs/superpowers/specs/2026-05-27-huly-admin-account-lifecycle-design.md Signed-off-by: Michael Uray <michael.uray@gmail.com>
…uced scope)
V13 instruments three call sites because the code flow is more complex
than the v2 plan assumed:
1. disableAccountInternal (operations.ts) - already had the
cannot_self_disable + last_admin checks. v13 adds an audit-write
before each throw. Method tag: 'disableAccount'.
2. disableAccount (operations.ts) was a SEPARATE duplicate of
disableAccountInternal's logic, not a thin wrapper. v3 refactors
it to delegate: const adminUuid = await requireAdmin(...); return
await disableAccountInternal(...). This eliminates ~50 lines of
duplication and routes the audit-write from (1) through to the
single-RPC path automatically.
3. bulkSetDisabled (serviceOperations.ts) calls bulkLoop with a
selfFilter. bulkLoop short-circuits self-targets BEFORE op() runs,
so disableAccountInternal never sees the bulk-self case - meaning
the audit-write from (1) doesn't fire for that path. v3 adds an
explicit pre-bulkLoop audit-write in bulkSetDisabled. Method tag:
'bulkSetDisabled'.
Two deny reasons emit an audit row before throwing. The methodName
parameter on disableAccountInternal carries the caller identity so
both reasons get the correct method tag:
Single RPC -> method='disableAccount' (default)
Bulk RPC -> method='bulkSetDisabled' (passed explicitly by
bulkSetDisabled per-target,
and by the pre-bulkLoop
self-write in bulkSetDisabled
itself)
Reasons -> 'self_disable' (UI/RPC-caller is target) or
'last_admin' (target is sole remaining admin)
Pre-auth Forbidden (no admin claim) writes NO audit row - attack-
surface concern per spec.
Rate-limit key: (actor_uuid, reason, method) per 1-hour window,
in-memory Map. Resets on pod restart (acceptable threat-model trade-
off). The method component means single-RPC and bulk attempts produce
separate rows when both happen, giving operators visibility into both
attack surfaces.
Reduced scope vs. v5 plan: the self_delete reason is gone with the
deleteAccount removal in PR hcengineering#10883. Hard-Delete v2 (future PR) will
re-introduce the self_delete + workspace-orphan deny audits.
Audit filter dropdown in the UI gains the new action type.
Signed-off-by: Michael Uray <michael.uray@gmail.com>
86302bf to
1d61952
Compare
|
This is a fair concern. I re-checked the delete path and agree it should not ship in this PR. I'm removing the hard-delete UI and RPC from #10883. Disable remains as the only account-state action in this PR. It's reversible — it preserves the account / person / social-id anchors, blocks login via the If hard-delete comes back, I'll do it as a separate, smaller PR with an explicit content/reference audit, sole-owner workspace guard, and an impact preview before confirmation. I also want to separate that from a privacy-oriented anonymization path, since deleting authored project content is not the same problem as removing personal identifiers. The structural removal commit is Four additional commits layered on top while this branch was up for review (V5a workspace-root link in the drawer, V7 audit Custom-range pre-fill, V8 audit timestamp timezone tooltip, V13 Thanks for catching this before it shipped as default-on — the answer to "is hard-delete safe?" was "we haven't proven it yet," and shipping with that assumption would have been wrong. |
|
Cool, yeah it's good to not include it. I'd also suggest to update PR body to remove that block about hard delete. Edit: I see removed already 👍 |











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.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 the Disable button on the admin's own row; the server guardcannot_self_disablecaught it, but only after the admin had clicked through. SurfaceisSelfclient-side by decoding the JWT payload locally — no extra RPC — and grey out the button with an explanatory label. The server guard still enforces as the security primitive; this is purely a UX preview of the impossible action.6dd2458727DeleteAccountConfirm.sveltedeleted;deleteAccountfunction, postgres impl, mongo impl,AccountDB.deleteAccountinterface method,'deleteAccount'fromAccountMethodsunion,'delete_account'fromAdminAuditAction, the client-binding in account-client, and thedelete_accountentry in the audit-action dropdown all removed.deleteAccount.test.tsdeleted. Static grep verifies zero stragglers. Disable remains as the only account-state action in this PR. If hard-delete returns, it will be a separate smaller PR with a full content/reference audit, a sole-owner workspace guard, and an impact preview before confirmation.3d5ec4cb8bOpen ↗link to/workbench/<wsUrl>in a new tab. Workspace-root link only; an Employee deep-link via per-workspaceRef<Employee>lookup is deferred to a future enrichment.ea3765a17dbbd843f3d9titletooltip on hover —Local time: ... (<TZ>, UTC<offset>)andUTC: .... ExtractedtzTooltiphelper into its own module with 5 unit tests covering the Intl-unavailable degraded path.4e712eda74cannot_self_disableandlast_admin(for Disable) now write anadmin_action_deniedaudit row before throwing. Rate-limited per(actor, reason, method)in a 1-hour window. Pre-auth Forbidden writes no row. Instrumentation lives indisableAccountInternal(Step 5a),disableAccountis refactored to delegate to it (Step 5b, eliminates 50 lines of duplicate body), andbulkSetDisabledadds a pre-bulkLoop self-audit becausebulkLoopshort-circuits self-targets before the per-target call runs (Step 5c). 7 unit tests cover the decision matrix. Audit-action dropdown gainsadmin_action_denied.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…4e712eda74(8 commits)admin_action_deniedaudit. Detail in the table above.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.Tests
Pass: 575 / 579 across the admin-targeted suites (4 failures in
postgres-real.test.tsare infra-bound, identical atupstream/developmerge-base).server/account(all)postgres-real.test.tsare pre-existing CockroachDB-required integration tests, identical atupstream/developmerge-baseserver/account-service rateLimiterserver/accountadminActionDenied.test.tsplugins/login-resourcestzTooltip.test.tsserver/accountlistAuditAdmin.test.tsfoundations/core/packages/account-client csv + listAccountsAdminplugins/login-resources(mutex, columnFilters, signupTokenGuard)Total new tests added by this PR: ~125 (decodeFilterParam, csv,
columnFilters, rateLimiter, pruneAuditOlderThan, escapeLike,
bulkActions, listAccountsAdmin, listAuditAdmin, getAccountDetails,
assertAdmin, adminActionDenied, tzTooltip).
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).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,
and self-disable UI guard on the admin's own row (Disable button greyed with explanatory label when the caller is the target).
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.