Skip to content

Admin Panel: Users + Workspaces + Audit log (V27 → V30)#10883

Open
MichaelUray wants to merge 159 commits into
hcengineering:developfrom
MichaelUray:feat/admin-user-management
Open

Admin Panel: Users + Workspaces + Audit log (V27 → V30)#10883
MichaelUray wants to merge 159 commits into
hcengineering:developfrom
MichaelUray:feat/admin-user-management

Conversation

@MichaelUray
Copy link
Copy Markdown

@MichaelUray MichaelUray commented May 25, 2026

Admin Panel: Users + Workspaces + Audit log (V27 → V30)

This PR introduces a full admin panel under /login/admin with three
sections — 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 manage
accounts 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.

Status: Implementation complete, deployed and live-verified on
our self-hosted Huly v0.7.423 instance (CockroachDB backend) for ~7
days. All admin-targeted test suites pass (568/572 — the 4 failures
are pre-existing postgres-real.test.ts integration tests that
require a live CockroachDB and fail identically at upstream/develop).

159 commits, intentionally not squashed — the history is grouped
into 6 logical phases and each commit message tells a complete story.
Reviewers who prefer to read chronologically can follow the per-phase
ranges below.

Per-phase commit ranges

Phase Range Focus
1a — Foundations 394e3db143efe42d1617 (~110 commits) V27 migration (account disable + token version + last activity + admin_audit_log table). New collections + service operations: listAccountsAdmin, getAccountDetails, disableAccount / enableAccount, setWorkspaceMemberRole, removeWorkspaceMember, triggerPasswordReset, addToWorkspace, bulkSetDisabled, bulkSendPasswordReset, bulkAddToWorkspace, bulkRemoveFromWorkspace. Force-logout hook (AccountDisabled Tx → client-side store). Admin-only RBAC via assertAdmin. UI: AdminShell, AdminUsers + drawer, AdminWorkspaces + drawer, MassActionConfirm, FilterPresetMenu, ColumnFilterPopup. V28 migration to relax admin_audit_log.target_account NOT NULL + 5 query-tuning indexes.
1c — Polish + tests c5da4d170cd80a9710d0 (~10 commits) decodeFilterParam extract with strict base64 + recursive prototype-pollution rejection + 32-level depth cap (15 tests). Shared csvEscape/csvLine in @hcengineering/account-client (9 tests). mergeColumnFilters + DEBOUNCE_MS extract (5 tests). A11y basics — html lang, <th scope="col">, drawer close aria-label. Audit empty state component. AdminAudit render cap (200 rows). 10s → 30s throttle on workspace stats poll.
1d — Audit infrastructure 9e03bc3948d97b80f8c0 (~4 commits) Per-token CSV rate-limit (5/min, SHA-256-keyed in-memory TokenBucketLimiter, 4 tests). AUDIT_RETENTION_DAYS env-driven daily prune cron with Mongo auto-disable. V29/V30 migration: batch_id UUID NULL column + partial index WHERE 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.
1e — Visual design pass 4da2f06f8ecff6e8a19e (~6 commits) Members icon prefix on Users stats row. Tabular-nums + truncate-with-tooltip on Last-activity column. Audit Details JSON collapse-by-default. GlobalSearch empty-state with query echo (later removed). Workspaces stats two-band layout (counts vs capacity). V29/V30 migration split (CockroachDB partial-index-on-new-column constraint).
Audit log redesign a267d3d2f49461de3092 (~5 commits) Removed UUID-typing filter inputs in favor of admin name/email substring + target name/url substring + actions multi-select dropdown. Sortable column headers with arrow indicator only on active sort. Per-column filter icons that scroll-and-focus the relevant top-bar input. Date-range preset dropdown (1d / 2d / 3d / 1w / 2w / 1m / Custom; default Last 3 days).
Final UX cleanup 83b9cdeedb60e4d27e1d (9 commits) CSV token moved from URL to Authorization header (admin UI side; legacy server fallback retained — see Security). enableAccountInternal extracted (drops N redundant assertAdmin per 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 single Presets dropdown. postgres.test.ts mock SELECT projection updated to match V25–V27 column additions.

Database migrations

Migration DDL Purpose
V27 disabled_at, token_version, last_activity_at on account + admin_audit_log table Foundation for disable / force-logout / audit
V28 admin_audit_log.target_account NULLABLE + 5 query indexes Workspace-level audit entries + admin-panel query perf
V29 admin_audit_log.batch_id UUID NULL Bulk-action correlation
V30 partial index on admin_audit_log(batch_id) WHERE batch_id IS NOT NULL Small-footprint index (single-action rows dominate)

All migrations are forward-only with IF NOT EXISTS guards. Rollback
is "leave the schema, redeploy the previous account pod" — no down-
migrations.

Security posture

  • decodeFilterParam: strict base64 + JSON-object + recursive __proto__/constructor/prototype rejection + 32-level depth cap. Returns 400 Bad Request with a structured reason; never silently accepts malformed input.
  • LIKE wildcards (%, _, \) escaped on every user-supplied substring filter (Users search, audit substring filters) with explicit ESCAPE '\' clauses.
  • CSV-export rate-limit: 5/min per SHA-256(token). Token is hashed before entering the limiter Map so a leaked heap dump / APM sample doesn't expose a usable admin Bearer.
  • CSV export: the admin UI now uses fetch + Authorization: Bearer + blob download instead of window.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.
  • assertAdmin gates every admin-only RPC. Single-source-of-truth: ADMIN_EMAILS env. Force-logout when an active admin is disabled mid-session.

Tests

568 / 572 tests pass across the admin-targeted suites:

Suite Result
server/account (all) 568 / 572 — 4 failures in postgres-real.test.ts are pre-existing CockroachDB-required integration tests, identical at upstream/develop merge-base
server/account-service rateLimiter 4 / 4
foundations/core/packages/account-client csv + listAccountsAdmin 47 / 47
plugins/login-resources (mutex, columnFilters, signupTokenGuard) 12 / 12

Total new tests added by this PR: ~120 (decodeFilterParam, csv,
columnFilters, rateLimiter, pruneAuditOlderThan, escapeLike,
bulkActions, listAccountsAdmin, listAuditAdmin, getAccountDetails,
assertAdmin).

Reviews completed before this PR

  1. Per-task implementation review — every task in Plans 1c/1d/1e
    passed spec-compliance review + code-quality review before
    integration.
  2. Independent full-codebase code review — found and we fixed: LIKE
    wildcard escape, bulk-enable redundant validation, CSV CRLF + BOM,
    429 charset, token-in-URL.
  3. Playwright UI/UX walkthrough — found and we fixed: status filter
    sending wrong field shape, double Popup host, stuck sort arrow,
    orphan-mapper drop, header/body alignment, audit filter UX, ~6 more.
  4. Three rounds of user-feedback fixes during deployment.
  5. Pre-PR test review — caught the stale postgres.test.ts mock
    that hadn't been updated for the V25–V27 SELECT projection
    additions (fixed in b90bbbcc30).

Deliberately deferred (tracked, not blocking this PR)

  • Reverse-batch CTA — design stub for a future iteration. Six open
    product/security questions; what's the inverse of archive_workspace
    in 6 months? Reset_password has no inverse. Doing this wrong is
    worse than not doing it.
  • Remove legacy ?token=… query-string fallback on the CSV export
    route once one release has shipped with the new Authorization-header
    flow. Currently emits a deprecation warning when hit.
  • Streaming-loop catch-all on /admin/export/accounts.csv: if
    listAccountsAdmin or ctx.res.write throws after the 200 header
    has 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.
  • Trigram index for audit ILIKE substring filters — only matters at

    100k row scale.

  • Unbatched audit DELETE in retention prune — only matters at >10M
    row scale.
  • Three duplicate workspace-picker popups (~464 LOC overlap, AddTo /
    BulkPick / AddMember) — refactor candidate, not a feature.
  • Pre-existing Huly icon-API drift (fill/inline/disabled props
    rejected by 6 icon components) — 39 console warnings per page load,
    upstream-side cleanup more appropriate than admin-panel side.

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 13-workspace / 10-user 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_id row grouping in the audit log, date-range presets, CSV
export with Authorization header, CSV rate-limit 429 response.

Screenshots and verification logs available on request.

Notes for upstream reviewers

  • CommunityDB.ts (CockroachDB) backend only — Mongo collection
    stubs throw "not implemented" with a meaningful error. The audit
    retention cron auto-disables itself on Mongo to avoid log spam.
  • English-only UI strings so far; getEmbeddedLabel everywhere so
    i18n is a future drop-in.
  • No new dependencies added to package.json (uses existing Huly
    primitives: @hcengineering/ui Button / ButtonMenu / Dropdown / CheckBox,
    @hcengineering/platform getEmbeddedLabel, postgres.js Sql template tag).
  • Sign-off + DCO compliant; single author across all 159 commits.
  • Squash-merge is fine if you prefer a single commit on develop.
    We've kept the granular history as documentation, not because we
    insist on preserving it.

…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>
…getEmbeddedLabel

The EditBox placeholder expected an IntlString but received the raw
typedConfirmPhrase string ('ARCHIVE ALL' / 'DISABLE ALL' / etc.).
Huly's resource resolver tried to interpret it as a platform Id,
failed, and rendered "Invalid Id: ARCHIVE ALL" in the status bar
while the popup was open.

Wrap with getEmbeddedLabel() so the platform treats it as an
inline-embedded literal string rather than a resource lookup.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ers)

Mass Archive + Mass Migrate previously operated on ALL currently
visible active workspaces, with no per-row selection. Footgun: an
admin clicking "Mass Archive 13" archives every visible workspace
regardless of intent. Inconsistent with AdminUsers which requires
explicit per-row check-box selection.

Add the same selection model:
- per-row CheckBox cell (and select-all in the header)
- selectedWorkspaceUuids Set state + toggle helpers
- Mass Archive / Mass Migrate buttons now gate on
  selectedActiveWorkspaces (intersection of selection + isActiveMode)
- Toolbar shows "Selected: N" with a Clear button

Selection-based Mass actions don't trigger the typed-confirm bar -
the admin has already explicitly picked the affected rows.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
When totalStorageMb === 0 the Total Storage tile previously showed a
flat "0 MB" with no context, and the "Top 10 by storage" button was
silently inert (sorted a column where every value was 0).

- Total Storage tile now shows "No backup data yet" hint when 0
- "Top 10 by storage" button is disabled when 0

Both changes make the empty-data state legible instead of looking
like a broken UI.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Two parity gaps with AdminUsers the user surfaced:

1. AdminWorkspaceDrawer only loaded on mount, so clicking a different
   workspace row while the drawer was already open kept showing the
   previous workspace's members + audit. Add the same loadedUuid
   reactive pattern AdminUsersDrawer uses — when the workspaceUuid
   prop changes, refetch.

2. The currently-open workspace row had no visual indicator. Add
   .ws-is-active class driven by `workspace.uuid === selectedWorkspaceUuid`
   with the same theme-list-row-color background AdminUsers row uses.
   Also theme-tokenize the .ws-is-focused outline color while in here
   (was hardcoded #2563eb, same pattern as the drawer-close fix).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Initial .ws-is-active treatment used --theme-list-row-color which
differs from the zebra-stripe by only ~6 RGB units — visually
indistinguishable. Match the AdminUsers .is-active treatment:
rgba(96, 165, 250, 0.14) on all cells plus an inset 3px left bar
on the checkbox cell so the eye locks on the active row.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
The AdminUsers page already has a search input, so the sidebar's
GlobalSearch trigger was redundant — two ways to do the same thing.
Drop the <GlobalSearch /> render from AdminShell + the stale
:global(.hulyNavPanel-header) override that only existed to keep the
trigger from colliding with the title.

The GlobalSearch.svelte component is left in the codebase but no
longer mounted, so the Ctrl+K keyboard hotkey is also gone with it.
That matches the user's "kann entfernt werden" intent — full removal
rather than hidden-but-still-active.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Two small parity gaps the user surfaced:

1. The Audit-log NavItem in the sidebar referenced
   setting.icon.AdminPanel which is an unregistered Asset (empty
   string), so no icon rendered. Switch to IconActivity from
   @hcengineering/ui — semantically right for an audit-history
   entry and a registered icon, so it actually shows up.

2. AdminWorkspaceDrawer ignored clicks on empty page area. Copy
   the document-mousedown + Escape listener pattern from
   AdminUsersDrawer: clicks inside .ws-table keep the drawer open
   (so switching to another row continues to swap), clicks inside
   any data-drawer-keep-open / .popup descendant stay open
   (MessageBox / dropdowns), everything else dismisses.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
The audit log page previously showed four UUID inputs whose values the
admin never sees in the table (the table renders names, not UUIDs), and
the column headers had no sort affordance. Both made the page hard to
use in practice — filter fields appeared to filter by 'something not
shown', and the only sort was an implicit ts_ms DESC.

Frontend (AdminAudit.svelte)
- Drop the four UUID inputs (adminUuid, action exact, targetAccountUuid,
  targetWorkspaceUuid).
- Add one Admin (name or email) substring input bound to the same
  identifier the Admin column renders.
- Add one Target (user or workspace) substring input bound to the same
  identifier the Target column renders.
- Add an Actions multi-select using DropdownLabelsIntl (multiselect=true)
  populated from the actual audit-write call sites in the account
  service (create_account, disable, enable, trigger_password_reset,
  add_workspace_member, remove_member, role_change).
- Group From/To into a single visually-coherent Date range row.
- Add clickable sort headers on Time/Admin/Action/Target with arrow
  indicators (same pattern as AdminUsersTable). Default Time DESC
  matches the previous implicit ordering. Filter and sort changes
  reload(true) so the user always sees a consistent first page.
- Reset button clears all filters and restores default sort.

Backend (server/account)
- ListAuditAdminParams (account-client + server/account types): add
  adminNameOrEmail / targetNameOrUrl substring filters, actionIn
  multi-select, and a sort { field, direction } block. Legacy UUID
  fields and action exact-match stay for API compat (drawer audit-
  tab callers still use targetAccountUuid / targetWorkspaceUuid).
- postgres listAuditAdmin query: add ILIKE conditions against
  ap.first_name/last_name + EXISTS social_id for admin, and against
  tp.first_name/last_name + w.name/w.url for target. action = ANY()
  for multi-select. ORDER BY uses a whitelist map (time -> al.ts_ms,
  admin -> ap.first_name||' '||ap.last_name, action -> al.action,
  target -> coalesce(target person, workspace name, url)) plus al.id
  tiebreaker for deterministic paging — never interpolates the user
  input direction or field name into SQL. Cursor pagination stays
  keyset-style under the time-DESC default; offset-style cursor under
  other sort fields so 'Load more' still works without composite
  cursors.

Build: rush build --to @hcengineering/pod-account --to
@hcengineering/login-resources succeeds; types/types.d.ts regenerated
under foundations/core/packages/account-client. Pre-existing TS errors
on this branch (WorkspaceConfiguration import in server/account,
AccountListRow.hasPassword, login-resources setForceLogoutHandler)
are unchanged.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
The status and auth-method toolbar dropdowns sent the legacy single-string
`status` / `authMethod` fields, but listAccountsAdminPg.ts only reads the
`statusIn` / `authMethodIn` array forms. Result: selecting Disabled (or
Email only / OIDC only / Mixed) silently returned the full active+disabled
set instead of narrowing.

Map filter.status -> statusIn ['active'|'disabled'] (or undefined for 'all')
and filter.authMethod -> authMethodIn [...] (single-element array, undefined
for 'all') in both refresh() and exportAccountsCsv() so the CSV reflects
what the user sees on screen. The vestigial single-string fields are
dropped from the payload entirely (the ListAccountsAdminParams type makes
them optional).

A2 (auth dropdown does not open) and A3 (Orphan-accounts button does
nothing) could not be reproduced after this change. The auth dropdown
markup is structurally identical to the (verified-working) status one,
both populated DropdownLabelsIntl items wired to onAuthChange/onStatusChange.
The orphan button sets columnFilters = { orphan: { orphan: true } }, which
mergeColumnFilters() spreads into a top-level { orphan: true }, matching
the server filter at listAccountsAdminPg.ts:114. Once the status/auth
mapping above is in place the orphan flow narrows correctly.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…on state

The Workspaces page rendered a second <Popup /> at the end of its
markup, but LoginApp.svelte:144 already mounts a <Popup /> for the
entire admin section. With two Popup hosts subscribed to the same
modalStore, each showPopup() call painted the column-filter popup
twice (the reviewer's 'two stacked popups' observation) and the
overlays/close handlers fought over event delivery — which also
explains the sporadic 'sticky filter icon after Escape' artefact:
one Popup host consumed the close-with-undefined for cleanup, the
other left state behind.

Remove the local <Popup /> render and its import. The Users page
(verified working) does not render its own Popup either; it relies
on the one in LoginApp.

B2 (active-state stuck on Escape) could not be reproduced with the
single Popup host:
  - openColumnFilter()'s callback only mutates columnFilters when
    result != null (Escape/click-outside pass undefined).
  - onApply() in WorkspaceColumnFilterPopup returns 'clear' for the
    no-input cases, and the parent deletes the key on 'clear'.
The class:active={columnFilters[col.field] != null} predicate is
strictly correct under those invariants.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
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 <michaeluray@users.noreply.github.com>
…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 <michaeluray@users.noreply.github.com>
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 <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>
@huly-github-staging
Copy link
Copy Markdown

Connected to Huly®: UBERF-16473

@ignatremizov
Copy link
Copy Markdown

Share some screenshots, would be interesting to see. But also I think this PR is too big

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants