Skip to content

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

Draft
MichaelUray wants to merge 183 commits into
hcengineering:developfrom
MichaelUray:feat/admin-user-management
Draft

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

Conversation

@MichaelUray

@MichaelUray MichaelUray commented May 25, 2026 •

Copy link
Copy Markdown
Contributor

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.

Scope note (revised 2026-05-27): This PR provides Disable, not erasure or anonymization. Hard-delete was originally part of this PR but has been removed in response to @ignatremizov's review (see iteration table). Other lifecycle paths (anonymize, hard-delete with content cascade) are deferred to follow-up PRs.

The problem this solves

(Added in response to @ignatremizov's first-round review.)

Self-hosted Huly operators have to drop to raw SQL for everything
cross-workspace today: disabling a compromised account, force-logging
out a user after a security event, finding orphan accounts (no
workspace memberships), checking who has admin access on which
workspace, bulk-archiving stale workspaces, or auditing what an admin
did last quarter. There is a workspace-scoped Settings → Members
surface, but no global-account surface — and once you have more than
~5 workspaces, the per-workspace Settings pane stops scaling.

What already existed (so this is exposure, not reinvention). The
account service already shipped the primitives — assignWorkspace,
unassignWorkspace, social-id management, setWorkspaceMemberRole,
the ADMIN_EMAILS env gate. They just had no UI and were not exposed
via account-client. This PR wires UI + bulk endpoints + audit on top
of what the account service already enforces.

Why a separate route instead of expanding an existing one. Two
existing surfaces were in scope to extend rather than add:

  1. Per-workspace Settings → Members — workspace-scoped, member-of-
    workspace view. Can't cross workspace; can't disable an account
    globally; can't show which workspaces a person is in. Wrong domain
    to bolt cross-workspace controls onto.
  2. The tiny system-manager plugin — runs inside the per-workspace
    transactor session pipeline and doesn't talk to the account DB
    directly. The admin panel uses the account-service RPC instead,
    which is the source of truth for accounts and workspaces.

We chose /login/admin because of the session boundary (the panel must
work even when no workspace is open). If you'd rather see this folded
under system-manager once the dust settles, the routes can be moved.

Multi-tenancy. Huly already supports one account → many workspaces.
ADMIN_EMAILS was the existing RBAC primitive; before this PR, "being
admin" had no UI meaning — it only gated server-side mass operations
callable from internal scripts. This PR is the first surfaced consumer
of that primitive.

Iteration since the first review

Five commits were added on top of the original 159-commit history in
response to your feedback. Each one is small and addresses one specific
concern:

Commit Concern (from review) What changed
0c3af29d1c "Bulk actions bars are usually at the top of the table, either appearing just under the header row, or above the table, rarely below it" Move BulkActionBar from sticky-bottom to sticky-top in AdminUsers.svelte; the bar now appears between the filter row and the table, hidden when no rows are selected.
384a81d979 Audit table was always empty (found during end-to-end testing) AdminAudit.svelte was calling getAccountClient(null).listAuditAdmin() — explicitly passing no token. Server-side assertAdmin consequently always returned Forbidden. Fix: use the default token (getAccountClient()) so the existing admin claim travels with the call, like every other admin-panel RPC.
ab2589361e Two UX papercuts surfaced during the Playwright sweep (a) Audit log to-date parsed as UTC midnight 00:00:00.000, which excluded every event logged "today" — the default "Last 3 days" preset hid the freshly-created disable / delete entries until the admin manually picked a Custom range. Anchor to to end-of-day (23:59:59.999) inside the listAuditAdmin params builder. (b) Drawer offered the Disable button on the admin's own row; the server guard cannot_self_disable caught it, but only after the admin had clicked through. Surface isSelf client-side by decoding the JWT payload locally — no extra RPC — and grey out the button with an explanatory label. The server guard still enforces as the security primitive; this is purely a UX preview of the impossible action.
086b7f9530 Reviewer @ignatremizov flagged that hard-delete cannot ship as proven safe without a cross-reference audit + sole-owner workspace guard Hard-delete UI and RPC removed entirely from this PR. DeleteAccountConfirm.svelte deleted; deleteAccount function, postgres impl, mongo impl, AccountDB.deleteAccount interface method, 'deleteAccount' from AccountMethods union, 'delete_account' from AdminAuditAction, the client-binding in account-client, and the delete_account entry in the audit-action dropdown all removed. deleteAccount.test.ts deleted. Static grep verifies zero stragglers. Disable remains as the only account-state action in this PR. If hard-delete returns, it will be a separate smaller PR with a full content/reference audit, a sole-owner workspace guard, and an impact preview before confirmation.
08472f43e8 Operational ergonomics — admin had to manually navigate from the user drawer to the corresponding workspace V5a: each workspace-membership row in the user drawer now exposes an Open ↗ link to /workbench/<wsUrl> in a new tab. Workspace-root link only; an Employee deep-link via per-workspace Ref<Employee> lookup is deferred to a future enrichment.
2869af32ba Audit-log UX papercut — switching the date-range preset to Custom range cleared the From/To inputs and forced the admin to re-enter both V7: switching to Custom range now retains the From/To values from the previously-active preset; only on a cold-start with both inputs empty does the dialog fall back to a 3-day window.
cb8c4a2409 Audit-log timestamps were ambiguous between local time and UTC V8: each timestamp cell exposes a two-line title tooltip on hover — Local time: ... (<TZ>, UTC<offset>) and UTC: .... Extracted tzTooltip helper into its own module with 5 unit tests covering the Intl-unavailable degraded path.
1d61952b93 Denied admin actions had no audit trace V13: cannot_self_disable and last_admin (for Disable) now write an admin_action_denied audit row before throwing. Rate-limited per (actor, reason, method) in a 1-hour window. Pre-auth Forbidden writes no row. Instrumentation lives in disableAccountInternal (Step 5a), disableAccount is refactored to delegate to it (Step 5b, eliminates 50 lines of duplicate body), and bulkSetDisabled adds a pre-bulkLoop self-audit because bulkLoop short-circuits self-targets before the per-target call runs (Step 5c). 7 unit tests cover the decision matrix. Audit-action dropdown gains admin_action_denied.

Identities / Employee / Contact mapping

You asked about Employee / canonical contacts / HR integration. Where
this PR sits today:

  • Subject of the panel: the global account.uuid plus its
    email-typed social_id. Person / Employee / Contact / HR records
    are workspace-internal and untouched.
  • Per-workspace Employee bridge: lives in the workspace's
    transactor DB, not the account DB. The admin panel is a global-
    account view and does not enumerate Employee rows.
  • Guest / client-contact accounts: show up the same way as User+
    accounts (each has a global account row + email social-id). The
    drawer's role picker and the table's role chip reflect what's in
    global_account.workspace_members.role for that workspace.
  • GitHub OIDC-linked accounts: one global account row with an
    additional github-typed social-id. The drawer surfaces this in the
    Identities section but does not link out to the IdP — deferred.

If the value would be there, the drawer can be extended in a follow-up
to add a "in workspace X, you're Employee Y" back-link. Not in this PR.

Status: Implementation complete, deployed and live-verified on
our self-hosted Huly v0.7.423 instance (CockroachDB backend) for ~8
days. All admin-targeted test suites pass — 568 + 7 + 5 = 580 of
584
(the 4 pre-existing failures are in postgres-real.test.ts,
which requires a live CockroachDB and fails identically at
upstream/develop merge-base).

159 + 5 = 164 commits, intentionally not squashed — the original
history is grouped into 6 logical phases, the new 5 commits are
review-driven and each addresses one concern. Reviewers who prefer
to read chronologically can follow the per-phase ranges below.

Per-phase commit ranges

Phase Range Focus
1a — Foundations 394e3db143 … efe42d1617 (~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 c5da4d170c … d80a9710d0 (~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 9e03bc3948 … d97b80f8c0 (~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 4da2f06f8e … cff6e8a19e (~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 a267d3d2f4 … 9461de3092 (~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 83b9cdeedb … 60e4d27e1d (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.
Review iteration 0c3af29d1c … 1d61952b93 (8 commits) Bulk-bar position; audit-listing token bug; audit date inclusive end-of-day + self-disable UI guard; hard-delete REMOVED from this PR per safety review; V5a workspace-root backlink; V7 audit Custom-range prefill; V8 timezone tooltip; V13 admin_action_denied audit. Detail in the table above.

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

Pass: 575 / 579 across the admin-targeted suites (4 failures in postgres-real.test.ts are infra-bound, identical at upstream/develop merge-base).

Suite Result
server/account (all) 575 / 579 — 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
server/account adminActionDenied.test.ts 7 / 7 (self-disable + last_admin audit-write paths, rate-limit dedup by key, bulk method tag, pre-auth Forbidden no-audit) — new in iteration
plugins/login-resources tzTooltip.test.ts 5 / 5 (Local time / UTC line format, Intl-unavailable fallback, DST/non-DST) — new in iteration
server/account listAuditAdmin.test.ts 5 / 5
foundations/core/packages/account-client csv + listAccountsAdmin 47 / 47
plugins/login-resources (mutex, columnFilters, signupTokenGuard) 12 / 12

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

Reviews completed before this PR

  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 (original) — 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).
  6. End-to-end Playwright sweep of the full panel (new) — exercised
    T1 stat-pill filters → T9 Workspaces table. Caught the audit-token
    bug (Forbidden on every audit reload), the audit-date end-of-day
    cutoff, and the self-disable/self-delete UX gap; all three fixed in
    384a81d979 and ab2589361e.

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.
  • Drawer back-link to per-workspace Employee row (in response to the
    identities question above).

Live deployment evidence

Deployed and live-verified on a self-hosted Huly v0.7.423 instance
with the CockroachDB backend and OIDC SSO. The following behaviours
have been exercised end-to-end on a 12-user / 14-workspace test
dataset: 5 clickable stat pills with per-pill filter state, drawer
reactive refetch on row swap, outside-click + Escape drawer dismiss,
Workspaces per-row selection + selection-driven Mass Archive,
batch_id row grouping in the audit log, date-range presets, CSV
export with Authorization header, CSV rate-limit 429 response,
and self-disable UI guard on the admin's own row (Disable button greyed with explanatory label when the caller is the target).

Screenshots in hcengineering/huly-docs#71 (src/assets/screenshots/huly/admin-panel/).

Notes for upstream reviewers

  • 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 164 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>
…uced scope)

V13 instruments three call sites because the code flow is more complex
than the v2 plan assumed:

1. disableAccountInternal (operations.ts) - already had the
   cannot_self_disable + last_admin checks. v13 adds an audit-write
   before each throw. Method tag: 'disableAccount'.

2. disableAccount (operations.ts) was a SEPARATE duplicate of
   disableAccountInternal's logic, not a thin wrapper. v3 refactors
   it to delegate: const adminUuid = await requireAdmin(...); return
   await disableAccountInternal(...). This eliminates ~50 lines of
   duplication and routes the audit-write from (1) through to the
   single-RPC path automatically.

3. bulkSetDisabled (serviceOperations.ts) calls bulkLoop with a
   selfFilter. bulkLoop short-circuits self-targets BEFORE op() runs,
   so disableAccountInternal never sees the bulk-self case - meaning
   the audit-write from (1) doesn't fire for that path. v3 adds an
   explicit pre-bulkLoop audit-write in bulkSetDisabled. Method tag:
   'bulkSetDisabled'.

Two deny reasons emit an audit row before throwing. The methodName
parameter on disableAccountInternal carries the caller identity so
both reasons get the correct method tag:
  Single RPC      -> method='disableAccount'   (default)
  Bulk RPC        -> method='bulkSetDisabled'  (passed explicitly by
                                              bulkSetDisabled per-target,
                                              and by the pre-bulkLoop
                                              self-write in bulkSetDisabled
                                              itself)
  Reasons         -> 'self_disable' (UI/RPC-caller is target) or
                    'last_admin' (target is sole remaining admin)

Pre-auth Forbidden (no admin claim) writes NO audit row - attack-
surface concern per spec.

Rate-limit key: (actor_uuid, reason, method) per 1-hour window,
in-memory Map. Resets on pod restart (acceptable threat-model trade-
off). The method component means single-RPC and bulk attempts produce
separate rows when both happen, giving operators visibility into both
attack surfaces.

Reduced scope vs. v5 plan: the self_delete reason is gone with the
deleteAccount removal in PR hcengineering#10883. Hard-Delete v2 (future PR) will
re-introduce the self_delete + workspace-orphan deny audits.

Audit filter dropdown in the UI gains the new action type.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
MichaelUray added a commit to MichaelUray/huly-platform that referenced this pull request May 27, 2026
Per Igor's PR comment, hard-delete should not ship in this PR. The
scope reduction removes the full code path, not just the UI surface:

- DeleteAccountConfirm.svelte component file
- Danger Zone section in AdminUsersDrawer (with the Delete button +
  Self-block markup specific to it; Disable's Self-block stays)
- deleteAccount RPC function in operations.ts (+ cannot_self_delete
  guard, V19/V24 cascade calls scoped to it, audit-row write,
  AccountEventType.ACCOUNT_DELETED emit)
- 'deleteAccount' from the AccountMethods union (operations.ts)
- deleteAccount: wrap(deleteAccount) from getMethods (operations.ts)
- deleteAccount method from AccountDB interface (types.ts)
- 'delete_account' from AdminAuditAction (types.ts)
- Postgres and Mongo implementations of deleteAccount
- deleteAccount client binding in foundations/core/packages/account-client/src/client.ts
- 'delete_account' entry from AdminAudit ACTION_OPTIONS dropdown
- deleteAccount.test.ts test file

The ACCOUNT_DELETED entry in AccountEventType stays — Task 1 discovery
confirmed it pre-existed PR hcengineering#10883 (introduced by upstream UBERF-11998
in PR hcengineering#9441, reachable from develop), so it isn't ours to remove.

Hard-delete, if it comes back, will be a separate smaller PR with a
content-reference audit, sole-owner workspace guard, and impact
preview. Disable remains as the only account-state action in this PR.

Spec: docs/superpowers/specs/2026-05-27-huly-admin-account-lifecycle-design.md
Signed-off-by: Michael Uray <michael.uray@gmail.com>
MichaelUray added a commit to MichaelUray/huly-platform that referenced this pull request May 27, 2026
…uced scope)

V13 instruments three call sites because the code flow is more complex
than the v2 plan assumed:

1. disableAccountInternal (operations.ts) - already had the
   cannot_self_disable + last_admin checks. v13 adds an audit-write
   before each throw. Method tag: 'disableAccount'.

2. disableAccount (operations.ts) was a SEPARATE duplicate of
   disableAccountInternal's logic, not a thin wrapper. v3 refactors
   it to delegate: const adminUuid = await requireAdmin(...); return
   await disableAccountInternal(...). This eliminates ~50 lines of
   duplication and routes the audit-write from (1) through to the
   single-RPC path automatically.

3. bulkSetDisabled (serviceOperations.ts) calls bulkLoop with a
   selfFilter. bulkLoop short-circuits self-targets BEFORE op() runs,
   so disableAccountInternal never sees the bulk-self case - meaning
   the audit-write from (1) doesn't fire for that path. v3 adds an
   explicit pre-bulkLoop audit-write in bulkSetDisabled. Method tag:
   'bulkSetDisabled'.

Two deny reasons emit an audit row before throwing. The methodName
parameter on disableAccountInternal carries the caller identity so
both reasons get the correct method tag:
  Single RPC      -> method='disableAccount'   (default)
  Bulk RPC        -> method='bulkSetDisabled'  (passed explicitly by
                                              bulkSetDisabled per-target,
                                              and by the pre-bulkLoop
                                              self-write in bulkSetDisabled
                                              itself)
  Reasons         -> 'self_disable' (UI/RPC-caller is target) or
                    'last_admin' (target is sole remaining admin)

Pre-auth Forbidden (no admin claim) writes NO audit row - attack-
surface concern per spec.

Rate-limit key: (actor_uuid, reason, method) per 1-hour window,
in-memory Map. Resets on pod restart (acceptable threat-model trade-
off). The method component means single-RPC and bulk attempts produce
separate rows when both happen, giving operators visibility into both
attack surfaces.

Reduced scope vs. v5 plan: the self_delete reason is gone with the
deleteAccount removal in PR hcengineering#10883. Hard-Delete v2 (future PR) will
re-introduce the self_delete + workspace-orphan deny audits.

Audit filter dropdown in the UI gains the new action type.

Signed-off-by: Michael Uray <michael.uray@gmail.com>
@MichaelUray
MichaelUray force-pushed the feat/admin-user-management branch from 86302bf to 1d61952 Compare May 27, 2026 14:06
@MichaelUray

Copy link
Copy Markdown
Contributor Author

This is a fair concern. I re-checked the delete path and agree it should not ship in this PR.

I'm removing the hard-delete UI and RPC from #10883. Disable remains as the only account-state action in this PR. It's reversible — it preserves the account / person / social-id anchors, blocks login via the disabled_at flag, and doesn't touch authored workspace content.

If hard-delete comes back, I'll do it as a separate, smaller PR with an explicit content/reference audit, sole-owner workspace guard, and an impact preview before confirmation. I also want to separate that from a privacy-oriented anonymization path, since deleting authored project content is not the same problem as removing personal identifiers.

The structural removal commit is 086b7f9530 — DeleteAccountConfirm.svelte deleted, deleteAccount RPC + DB cascade + types-union entries + client binding + delete_account from the audit-action dropdown all removed; deleteAccount.test.ts deleted. Static grep against the working tree shows zero deleteAccount / delete_account / DeleteAccountConfirm / cannot_self_delete references outside lib/ and *.d.ts output. Disable's existing cannot_self_disable + last_admin guards stay as before.

Four additional commits layered on top while this branch was up for review (V5a workspace-root link in the drawer, V7 audit Custom-range pre-fill, V8 audit timestamp timezone tooltip, V13 admin_action_denied audit event for the surviving Disable / last_admin denials). The PR body's iteration table has the per-commit detail.

Thanks for catching this before it shipped as default-on — the answer to "is hard-delete safe?" was "we haven't proven it yet," and shipping with that assumption would have been wrong.

@ignatremizov

ignatremizov commented May 27, 2026 •

Copy link
Copy Markdown
Contributor

Cool, yeah it's good to not include it. I'd also suggest to update PR body to remove that block about hard delete.

Edit: I see removed already 👍

@ignatremizov

ignatremizov commented Jun 13, 2026 •

Copy link
Copy Markdown
Contributor

Hey @MichaelUray, curious whether you continued this work or have follow-up branches around workspace-level RBAC / access management.

From looking through current Huly settings, this PR seems to solve the global instance-admin surface well: users across workspaces, workspace membership, audit, disable/enable, bulk operations, etc. The gap I am seeing is the in-workspace access model: workspace account roles (Owner / Maintainer / User / Guest / read-only), space/project members / owners / private / autoJoin, per-space-type roles/permissions, chat channel public/private/members, Office room access/guest links, and inherited visibility for issues/docs/cards/files.

Have you explored a centralized Workspace Access Center that sits inside a workspace and lets an owner see/edit access per person and per resource?

Potential MVP:

  • People: workspace role, guest/user status, accessible projects/channels/spaces.
  • Resources: tracker projects, chat channels, document/drive/teamspaces, card spaces, Office rooms, guests.
  • For each resource: app/type, public/private, auto-join, owners, members, role assignments where applicable.
  • Common edits: change workspace role, add/remove members, toggle private/auto-join, assign per-space roles.

I would avoid making Classes or Space Types the user-facing RBAC UI. They are useful platform primitives, but operational admins need a consolidated view over Space.members, owners, private, autoJoin, and role assignment fields. Separately, the current Settings labels could use clarification: Members -> Workspace Members, Spaces -> Global Space Admins, Space Types -> Advanced / Schema.

Would be interested if you already have work in this direction, or thoughts on whether this PR's admin table/drawer/audit patterns could be reused for an in-workspace access center.

@MichaelUray

Copy link
Copy Markdown
Contributor Author

Hi @ignatremizov — quick follow-up to your Workspace Access Center question. Thank you for the suggestion — it was a clear framing of where workspace administration could use a centralized surface, and gave me a concrete direction to work toward.

I implemented a v1 of that direction in my fork: a per-workspace settings panel with People, Resources, My Access, Audit, and Guest Settings tabs, plus common access-management actions and audit visibility for workspace Owners.

It is not an upstream PR yet because the account-service host wiring builds on helpers introduced in this Admin Panel PR (listAccountsAdmin, CSV rate-limiting / filter decode helpers, etc.). Once #10883 lands, I can open the Workspace Access Center PR against develop — at which point the code becomes reviewable as a clean diff on top.

Until then, the user-facing surface and design are captured in:

The v1.5 preview features (Permission Templates, Webhooks, Time-bounded Grants, CSV Bulk-Invite, Effective Permissions Drilldown) are intentionally out of scope for the first upstream PR.

Any comments or suggestions on the direction, scope, or specific design decisions would be welcome at this stage — much easier to adjust now than after the upstream PR opens. Sharing as context for the admin/access-management direction; no action needed unless useful.

…management

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…-Scan (L-FTS)

Full-Table-Scans (db.account/person/socialId.find({})) luden komplette
Tabellen und filterten in-memory -> Speicher-/DoS-Risiko. Jetzt gezielte
$in-Query auf die Member-UUIDs; leere Member-Menge ueberspringt die Query.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
- requireAdmin/assertAdmin loggen pre-auth Denials (ctx.warn) — bislang
  spurlos, da kein Audit-Row fuer nicht-vertrauenswuerdige Actor.
- Verschluckte Audit-Write-Failures emittieren stabilen Alert-Marker
  AUDIT_WRITE_FAILED (auditAdminActionDenied + performWorkspaceOperation);
  Mutation-Verhalten unveraendert.
- Rate-Limit-Kommentar praezisiert: Security-Log ist forensische Quelle.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Guard-Zaehlung + Write waren nicht atomar -> parallele Calls konnten den
letzten Owner/Admin gleichzeitig entfernen.

- Neue DB-Methoden updateWorkspaceRoleIfOtherOwnerExists /
  unassignIfNotLastOwner: Postgres sperrt die Owner-Zeilen (SELECT .. FOR
  UPDATE) in einer withRetry-Transaktion und prueft+schreibt atomar; auf
  CockroachDB serialisiert der 40001-Retry. Mongo best-effort (deprecated,
  kein Cross-Doc-Lock — dokumentiert).
- setWorkspaceMemberRole/removeWorkspaceMember(Internal) nutzen die
  bedingten Updates; 0 betroffene Zeilen -> last_owner_in_workspace.
- disableAccountInternal: Re-Check der Last-Admin-Invariante NACH dem Write
  mit Rollback (re-enable), fail-closed. tokenVersion bleibt bewusst
  gebumpt (nie zuruecksetzen).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…t in webpack dev-proxy note

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

Copy link
Copy Markdown
Contributor Author

@ArtyomSavchenko — following up on the question of direction here.

This PR has grown large and now conflicts with the recent revocable-API-tokens work in server/account. Before rebasing the full PR, I could prepare a small standalone, backend-only PR against current develop, with the admin-panel UI left as a stacked follow-up. This is account/admin infrastructure (lifecycle, audit, listing) — orthogonal to the object-permission model, not another take on it. The proposed foundation would cover:

  • account lifecycle: disable/enable and a token-version bump to force logout;
  • an admin audit log, including the database migration; and
  • admin listing/filter queries (listAccountsAdmin, assertAdmin, decodeFilterParam).

For the new disable flow, it would also ensure that the disabled account's API tokens are invalidated. Would that foundation be useful upstream, and if so, what shape would you prefer? Or would you rather design instance-admin/account tooling separately? I'd prefer to align before investing in the extraction.

@MichaelUray

Copy link
Copy Markdown
Contributor Author

@ArtyomSavchenko — following up on the question above (2026-08-09). The branch now conflicts with current develop, and I'd rather not rebase the full backend + UI diff if the shape is going to change.

If a backend-only foundation PR is a shape you'd review — account disable/enable with a token-version bump, the admin audit log with its migration, and the listAccountsAdmin / assertAdmin / decodeFilterParam helpers, with the admin-panel UI stacked on top later — I can prepare it against current develop. If you'd rather approach instance-admin tooling differently, a short note on that is equally useful; I'll adjust before investing in the extraction.

@ArtyomSavchenko

Copy link
Copy Markdown
Member

@ArtyomSavchenko — following up on the question above (2026-08-09). The branch now conflicts with current develop, and I'd rather not rebase the full backend + UI diff if the shape is going to change.

If a backend-only foundation PR is a shape you'd review — account disable/enable with a token-version bump, the admin audit log with its migration, and the listAccountsAdmin / assertAdmin / decodeFilterParam helpers, with the admin-panel UI stacked on top later — I can prepare it against current develop. If you'd rather approach instance-admin tooling differently, a short note on that is equally useful; I'll adjust before investing in the extraction.

Hi @MichaelUray
Yes, I think the backend-only foundation PR is better

…sendInvite, resendInvite, changePassword

These four operations mint or extend privileges from the caller's token
(guest access links, invites, password rotation) but never checked the
account's token_version or disabled_at. A session invalidated by a
token_version bump (or a disabled account) could therefore still create
access links / invites or change the password.

- utils.ts: split verifyTokenVersion into a pure checker
  (checkTokenVersionClaim) and the existing DB wrapper; the wrapper keeps
  its exact semantics for getLoginInfoByToken / selectWorkspace /
  requireAdmin.
- utils.ts: new verifySessionTokenVersion(accountUuid, extra, account),
  called once in each of the four operations with the account row the
  operation already loads (no second account query).
- wrap() is NOT changed: no central token-version gate, so UUID-backed
  service tokens without a token_version claim keep working for all other
  RPCs.
- Tests: tokenVersion.test.ts (pure checker + API-token exemption) and new
  tokenVersionOps.test.ts covering stale / disabled / fresh / API / legacy
  tokens per operation, directly and through getMethods()/wrap (the bearer
  RPC dispatch path), plus a getUserWorkspaces pass-through test for
  versionless service and API tokens (guards against a gate in wrap()).

API tokens (extra.apiTokenId) are exempt from this check: they carry no
token_version claim and keep their existing revocation/expiry check in
wrap(). API-token semantics are unchanged. Whether API tokens should be
allowed to mint access links / invites at all is tracked as a follow-up
(E-T3).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ole-writing path

The admin RPCs wrote the requested role straight to the DB. Values that
are not workspace-member roles (Admin, DocGuest, ReadOnlyGuest, or any
non-enum string such as the NaN produced by the old UI parseRole) either
caused a DB error (500), were persisted as a member role (READONLYGUEST),
or - in createAccountAdmin - silently left a freshly created account
without its initial workspace.

New helper assertAssignableRole (utils.ts) throws BadRequest unless the
role is one of assignableRoles (Guest, User, Maintainer, Owner). It is
called before any DB access in:

- setWorkspaceMemberRole (right after requireAdmin; no audit row)
- addWorkspaceMember (right after assertAdmin)
- bulkAddToWorkspace (once per batch, before bulkLoop; no batch, no
  audit rows)
- addWorkspaceMemberInternal (defense in depth for future callers)
- createAccountAdmin (for initialWorkspace.role, before the account is
  created)

Internal writers of non-assignable roles (ReadOnlyGuest pseudo-account
when enabling read-only guests, access-link grants, invites) are not
admin paths and are unchanged; invites are already validated against
assignableRoles at creation.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ethodIn 'none'

Two mismatches between the public account-client types and the
server-side SQL pushdown (both reported by `rush validate`):

- AccountListRow.hasPassword was declared but never populated. The rows
  query now selects
  EXISTS (SELECT 1 FROM <ns>.account_passwords pw
          WHERE pw.account_uuid = a.uuid) AS has_password
  and rowToAccountListRow maps it to hasPassword.
- ListAccountsAdminParams.authMethodIn allows 'none', but the internal
  ListAccountsAdminQueryParams type and the SQL builder did not, so the
  "Auth -> none" filter was silently a no-op. The builder now emits
  (NOT COALESCE(s.has_email, false) AND NOT COALESCE(s.has_oidc, false))
  for 'none' (COALESCE because accounts without any social_id row get
  NULLs from the LEFT JOIN), OR-combined with the other methods.

New listAccountsAdminPg.test.ts covers buildListAccountsAdminSql and
rowToAccountListRow directly (listAccountsAdmin.test.ts only exercises
the RPC with an in-memory DB).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…oles (Number() yielded NaN)

AccountRole is a string enum ('GUEST', 'USER', 'MAINTAINER', 'OWNER', ...),
but parseRole returned Number(v) as AccountRole, i.e. NaN for every role
(and a TS2352 error under `rush validate`). Every admin role change /
add-to-workspace sent NaN to the server.

- New admin-users/roles.ts (depends only on @hcengineering/core):
  ASSIGNABLE_ROLES mirrors the server's assignableRoles; parseRole returns
  the value unchanged when it is one of them and throws otherwise
  (including Admin, DocGuest and ReadOnlyGuest, which are not member
  roles), so a mismatch surfaces in the UI instead of reaching the API.
- util.ts re-exports parseRole from roles.ts.
- AdminUsersDrawer.svelte used parseRole without importing it (the import
  was lost when confirmAction/notify were consolidated into util.ts), so
  changing a member role from the user drawer threw a ReferenceError.
  Import it.
- AdminWorkspaceDrawer.svelte passed the raw dropdown value to
  onChangeRole; it now goes through parseRole like the other callers.
- New __tests__/parseRole.test.ts: the four roles round-trip; every
  non-assignable enum role and '4', 4, 'NaN', undefined, null, '',
  'user', 'DOCGUEST' (DB label, not an enum value) and {} throw.

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

Copy link
Copy Markdown
Contributor Author

Thanks @ArtyomSavchenko. The backend-only foundation is now up as #11055, as a fresh port against current develop.

In #11055:

  • account disable/enable with a token-version bump
  • a central disabled-principal check in wrap(), which covers session, API and user-scoped service tokens
  • the admin_audit_log table and its migration. It is idempotent and tested on top of this PR's earlier V27-V30 migrations.
  • assertAdmin, listAccountsAdmin, and a reduced listAuditAdmin

Not in #11055:

  • UI
  • workspace-membership admin RPCs and bulk operations
  • CSV export and decodeFilterParam (its only consumer is the CSV export)
  • realtime session eviction

I'll keep this PR open as the stacked follow-up for the admin panel UI (and the workspace-membership part) and move it to draft. Once the foundation is merged I'll rebase it, and the diff will shrink to those parts.

@MichaelUray

Copy link
Copy Markdown
Contributor Author

@ignatremizov following up on this: I opened a short design question for the in-workspace access overview in #11057, scoped down to an Owner-only, read-only overview of space membership and configuration over the existing Space primitives (no new server mechanism). I'd value your and @ArtyomSavchenko's view there before I write code.

…opy email

The workspace drawer called setWorkspaceMemberRole and
removeWorkspaceMember with a params object although both client methods
take positional arguments, so the server received an undefined role
(BadRequest) and an undefined workspace. Copy email read a primaryEmail
field the details response does not have; derive it from socialIds.
Also use the ButtonIcon tooltip prop and IntlString placeholders.

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

This branch has not been deployed

No deployments
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.

3 participants