Admin Panel: Users + Workspaces + Audit log (V27 → V30)#10883
Open
MichaelUray wants to merge 159 commits into
Open
Admin Panel: Users + Workspaces + Audit log (V27 → V30)#10883MichaelUray wants to merge 159 commits into
MichaelUray wants to merge 159 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>
…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>
|
Connected to Huly®: UBERF-16473 |
|
Share some screenshots, would be interesting to see. But also I think this PR is too big |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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.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
568 / 572 tests pass across the admin-targeted suites:
server/account(all)postgres-real.test.tsare pre-existing CockroachDB-required integration tests, identical atupstream/developmerge-baseserver/account-service rateLimiterfoundations/core/packages/account-client csv + listAccountsAdminplugins/login-resources(mutex, columnFilters, signupTokenGuard)Total new tests added by this PR: ~120 (decodeFilterParam, csv,
columnFilters, rateLimiter, pruneAuditOlderThan, escapeLike,
bulkActions, listAccountsAdmin, listAuditAdmin, getAccountDetails,
assertAdmin).
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.
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).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.
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_idrow grouping in the audit log, date-range presets, CSVexport with Authorization header, CSV rate-limit 429 response.
Screenshots and verification logs available on request.
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.
i18n 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.