From 394e3db143dc6649fde1d1ff6804731103a8d2e5 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 13:48:55 +0000 Subject: [PATCH 001/177] =?UTF-8?q?feat(account):=20V27=20migration=20?= =?UTF-8?q?=E2=80=94=20account=20disable=20+=20token=20version=20+=20audit?= =?UTF-8?q?=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Michael Uray --- .../src/collections/postgres/migrations.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/server/account/src/collections/postgres/migrations.ts b/server/account/src/collections/postgres/migrations.ts index e91e883905d..e2954c66c4c 100644 --- a/server/account/src/collections/postgres/migrations.ts +++ b/server/account/src/collections/postgres/migrations.ts @@ -83,7 +83,8 @@ export function getMigrations (ns: string, flavor: DBFlavor): [string, string][] getV23Migration(ns, flavor), getV24Migration(ns, flavor), getV25Migration(ns, flavor), - getV26Migration(ns, flavor) + getV26Migration(ns, flavor), + getV27Migration(ns, flavor) ] } @@ -809,3 +810,33 @@ function getV26Migration (ns: string, flavor: DBFlavor): [string, string] { ` ] } + +function getV27Migration (ns: string, flavor: DBFlavor): [string, string] { + const types = dbTypes[flavor] + return [ + 'account_db_v27_admin_user_management', + ` + /* Account: disable + token-version + last activity */ + ALTER TABLE ${ns}.account + ADD COLUMN IF NOT EXISTS disabled_at ${types.int8} NULL, + ADD COLUMN IF NOT EXISTS token_version ${types.int4} NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS last_activity_at ${types.int8} NULL; + + /* Admin audit log table */ + CREATE TABLE IF NOT EXISTS ${ns}.admin_audit_log ( + id ${types.string} NOT NULL DEFAULT gen_random_uuid()::TEXT, + ts_ms ${types.int8} NOT NULL DEFAULT current_epoch_ms(), + admin_account ${types.string} NOT NULL, + target_account ${types.string} NOT NULL, + action ${types.string} NOT NULL, + workspace_uuid ${types.string} NULL, + details JSONB NULL, + PRIMARY KEY (id) + ); + + CREATE INDEX IF NOT EXISTS admin_audit_log_target_idx ON ${ns}.admin_audit_log (target_account, ts_ms DESC); + CREATE INDEX IF NOT EXISTS admin_audit_log_admin_idx ON ${ns}.admin_audit_log (admin_account, ts_ms DESC); + CREATE INDEX IF NOT EXISTS admin_audit_log_ts_idx ON ${ns}.admin_audit_log (ts_ms DESC); + ` + ] +} From 30983970af1eb2a3cc62b08a8244543608ccea63 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 13:52:59 +0000 Subject: [PATCH 002/177] feat(account): extend Account interface with disabledAt + tokenVersion + lastActivityAt Signed-off-by: Michael Uray --- server/account/src/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/account/src/types.ts b/server/account/src/types.ts index 88f10f47064..dbe36c86cc8 100644 --- a/server/account/src/types.ts +++ b/server/account/src/types.ts @@ -67,6 +67,10 @@ export interface Account { maxWorkspaces?: number failedLoginAttempts?: number // Number of consecutive failed login attempts tfaSecret?: string + // V27 admin user management: + disabledAt?: number | null // epoch-ms; null = active + tokenVersion?: number // monotonic counter, default 0 + lastActivityAt?: number | null // epoch-ms; null = no logins yet } // TODO: type data with generic type From b8eba0aaa7c959033efceecdbf524cda6ff1e76c Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:15:15 +0000 Subject: [PATCH 003/177] =?UTF-8?q?feat(account):=20AdminAuditLogCollectio?= =?UTF-8?q?n=20=E2=80=94=20types=20+=20Postgres=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/collections/postgres/postgres.ts | 70 ++++++++++++++++++- server/account/src/types.ts | 25 +++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/server/account/src/collections/postgres/postgres.ts b/server/account/src/collections/postgres/postgres.ts index 73e4c1d9c2f..c8df9a71dad 100644 --- a/server/account/src/collections/postgres/postgres.ts +++ b/server/account/src/collections/postgres/postgres.ts @@ -50,7 +50,9 @@ import type { UserProfile, Subscription, WorkspacePermission, - DBFlavor + DBFlavor, + AdminAuditLogCollection, + AdminAuditLogEntry } from '../../types' function toSnakeCase (str: string): string { @@ -515,6 +517,70 @@ export class AccountPostgresDbCollection } } +class PostgresAdminAuditLogCollection implements AdminAuditLogCollection { + constructor ( + private readonly client: Sql, + private readonly ns: string + ) {} + + private getTableName (): string { + return `${this.ns}.admin_audit_log` + } + + async insert (entry: Omit): Promise { + const sql = ` + INSERT INTO ${this.getTableName()} + (admin_account, target_account, action, workspace_uuid, details) + VALUES ($1::text, $2::text, $3::text, $4::text, $5::jsonb) + ` + await this.client.unsafe(sql, [ + entry.adminAccount, + entry.targetAccount, + entry.action, + entry.workspaceUuid, + entry.details != null ? JSON.stringify(entry.details) : null + ]) + } + + async findByTarget (target: AccountUuid, limit: number): Promise { + const sql = ` + SELECT id, ts_ms AS "tsMs", admin_account AS "adminAccount", + target_account AS "targetAccount", action, + workspace_uuid AS "workspaceUuid", details + FROM ${this.getTableName()} + WHERE target_account = $1::text + ORDER BY ts_ms DESC LIMIT $2::int + ` + const rows = await this.client.unsafe(sql, [target, limit]) + return rows.map(this.parseRow) + } + + async findByAdmin (admin: AccountUuid, limit: number): Promise { + const sql = ` + SELECT id, ts_ms AS "tsMs", admin_account AS "adminAccount", + target_account AS "targetAccount", action, + workspace_uuid AS "workspaceUuid", details + FROM ${this.getTableName()} + WHERE admin_account = $1::text + ORDER BY ts_ms DESC LIMIT $2::int + ` + const rows = await this.client.unsafe(sql, [admin, limit]) + return rows.map(this.parseRow) + } + + private parseRow (row: any): AdminAuditLogEntry { + return { + id: row.id, + tsMs: Number(row.tsMs), + adminAccount: row.adminAccount, + targetAccount: row.targetAccount, + action: row.action, + workspaceUuid: row.workspaceUuid, + details: typeof row.details === 'string' ? JSON.parse(row.details) : row.details + } + } +} + export class PostgresAccountDB implements AccountDB { private readonly retryOptions = { maxAttempts: 5, @@ -540,6 +606,7 @@ export class PostgresAccountDB implements AccountDB { userProfile: PostgresDbCollection subscription: PostgresDbCollection workspacePermission: PostgresDbCollection + adminAuditLog: AdminAuditLogCollection constructor ( readonly client: Sql, @@ -609,6 +676,7 @@ export class PostgresAccountDB implements AccountDB { timestampFields: ['createdOn'], withRetryClient }) + this.adminAuditLog = new PostgresAdminAuditLogCollection(client, ns) } getWsMembersTableName (): string { diff --git a/server/account/src/types.ts b/server/account/src/types.ts index dbe36c86cc8..b8a1868c373 100644 --- a/server/account/src/types.ts +++ b/server/account/src/types.ts @@ -73,6 +73,30 @@ export interface Account { lastActivityAt?: number | null // epoch-ms; null = no logins yet } +// V27 admin user management +export type AdminAuditAction = + | 'role_change' + | 'remove_member' + | 'trigger_password_reset' + | 'disable' + | 'enable' + +export interface AdminAuditLogEntry { + id: string + tsMs: number + adminAccount: AccountUuid + targetAccount: AccountUuid + action: AdminAuditAction + workspaceUuid: WorkspaceUuid | null + details: Record | null +} + +export interface AdminAuditLogCollection { + insert: (entry: Omit) => Promise + findByTarget: (target: AccountUuid, limit: number) => Promise + findByAdmin: (admin: AccountUuid, limit: number) => Promise +} + // TODO: type data with generic type export interface AccountEvent { accountUuid: AccountUuid @@ -331,6 +355,7 @@ export interface AccountDB { userProfile: DbCollection subscription: DbCollection workspacePermission: DbCollection + adminAuditLog: AdminAuditLogCollection init: () => Promise createWorkspace: (data: WorkspaceData, status: WorkspaceStatusData) => Promise From 7dc536537862326e5e0eed84764f47ae31226c51 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:17:52 +0000 Subject: [PATCH 004/177] feat(account): generateTokenWithVersion + verifyTokenVersion helpers 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 --- .../src/__tests__/tokenVersion.test.ts | 58 +++++++++++++++++++ server/account/src/utils.ts | 51 ++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 server/account/src/__tests__/tokenVersion.test.ts diff --git a/server/account/src/__tests__/tokenVersion.test.ts b/server/account/src/__tests__/tokenVersion.test.ts new file mode 100644 index 00000000000..0daa8e2ce4c --- /dev/null +++ b/server/account/src/__tests__/tokenVersion.test.ts @@ -0,0 +1,58 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { type MeasureContext } from '@hcengineering/core' +import { setMetadata } from '@hcengineering/platform' +import serverToken, { decodeTokenVerbose, TokenError } from '@hcengineering/server-token' +import { generateTokenWithVersion, verifyTokenVersion } from '../utils' +import type { AccountDB } from '../types' + +setMetadata(serverToken.metadata.Secret, 'test-secret') + +const TEST_UUID = 'a1111111-1111-4111-9111-111111111111' as any +const ctx = { newChild: () => ctx } as unknown as MeasureContext + +function mockDbWith (account: { uuid: string, tokenVersion?: number, disabledAt?: number | null }): AccountDB { + return { + account: { + findOne: async (q: any) => (q.uuid === account.uuid ? account : null) + } + } as unknown as AccountDB +} + +describe('token-version helpers', () => { + it('issues a token without token_version claim when account.tokenVersion is 0', async () => { + const db = mockDbWith({ uuid: TEST_UUID, tokenVersion: 0 }) + const token = await generateTokenWithVersion(ctx, db, TEST_UUID) + expect(token).toBeTruthy() + const decoded = decodeTokenVerbose(ctx, token) + expect(decoded.extra?.token_version).toBeUndefined() + }) + + it('issues a token with token_version claim when account.tokenVersion is > 0', async () => { + const db = mockDbWith({ uuid: TEST_UUID, tokenVersion: 5 }) + const token = await generateTokenWithVersion(ctx, db, TEST_UUID) + const decoded = decodeTokenVerbose(ctx, token) + expect(decoded.extra?.token_version).toBe('5') + }) + + it('verifyTokenVersion accepts a token whose version matches the account', async () => { + const db = mockDbWith({ uuid: TEST_UUID, tokenVersion: 3 }) + const token = await generateTokenWithVersion(ctx, db, TEST_UUID) + await expect(verifyTokenVersion(ctx, db, token)).resolves.toBeUndefined() + }) + + it('verifyTokenVersion rejects when account.tokenVersion is greater than token claim', async () => { + const db = mockDbWith({ uuid: TEST_UUID, tokenVersion: 3 }) + const token = await generateTokenWithVersion(ctx, db, TEST_UUID) + ;(db.account as any).findOne = async (): Promise => ({ uuid: TEST_UUID, tokenVersion: 4 }) + await expect(verifyTokenVersion(ctx, db, token)).rejects.toThrow(TokenError) + }) + + it('verifyTokenVersion rejects when account is disabled', async () => { + const db = mockDbWith({ uuid: TEST_UUID, tokenVersion: 0, disabledAt: 1700000000000 }) + const token = await generateTokenWithVersion(ctx, db, TEST_UUID) + await expect(verifyTokenVersion(ctx, db, token)).rejects.toThrow(TokenError) + }) +}) diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index aacc22811a0..176974ecaee 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -172,6 +172,57 @@ export function isGuest (account: AccountUuid, extra: Record | unde return account === GUEST_ACCOUNT && extra?.guest === 'true' } +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Issue a JWT token that includes the account's current `token_version` as a string claim + * when version > 0. Accounts with version 0 (or no row) get a token without the claim, + * which `verifyTokenVersion` treats as version 0. + * + * Skips the DB lookup for guest accounts and non-UUID principals (e.g. service accounts). + */ +export async function generateTokenWithVersion ( + ctx: MeasureContext, + db: AccountDB, + accountUuid: PersonUuid, + workspaceUuid?: WorkspaceUuid, + extra?: Record, + options?: Parameters[4] +): Promise { + let mergedExtra = extra + if (accountUuid !== GUEST_ACCOUNT && UUID_REGEX.test(accountUuid)) { + const account = await db.account.findOne({ uuid: accountUuid as AccountUuid }) + if (account?.tokenVersion != null && account.tokenVersion > 0) { + mergedExtra = { ...(extra ?? {}), token_version: String(account.tokenVersion) } + } + } + return generateToken(accountUuid, workspaceUuid, mergedExtra, undefined, options) +} + +/** + * Verify that a token's `token_version` claim is not less than the account's current + * tokenVersion, and that the account is not disabled. Called from DB-aware token- + * verification paths (getLoginInfoByToken, selectWorkspace, provider-login refresh). + * + * Throws TokenError on mismatch / disabled. + */ +export async function verifyTokenVersion (ctx: MeasureContext, db: AccountDB, token: string): Promise { + const { account: accountUuid, extra } = decodeTokenVerbose(ctx, token) + if (accountUuid === GUEST_ACCOUNT) return + if (!UUID_REGEX.test(accountUuid)) return + const tokenVersionClaim = parseInt(extra?.token_version ?? '0', 10) + const account = await db.account.findOne({ uuid: accountUuid as AccountUuid }) + if (account == null) { + throw new TokenError('Account not found') + } + if (account.disabledAt != null) { + throw new TokenError('Account disabled') + } + if ((account.tokenVersion ?? 0) > tokenVersionClaim) { + throw new TokenError('Token version invalidated') + } +} + export function wrap ( accountMethod: (ctx: MeasureContext, db: AccountDB, branding: Branding | null, ...args: any[]) => Promise ): AccountMethodHandler { From e6be9733e1fac4f7192f640ff477d8182867870e Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:26:08 +0000 Subject: [PATCH 005/177] feat(account): route all token issuance through generateTokenWithVersion 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 --- .../account/src/__tests__/operations.test.ts | 3 +- server/account/src/__tests__/utils.test.ts | 6 +-- server/account/src/collections/mongo.ts | 42 ++++++++++++++++ server/account/src/operations.ts | 49 ++++++++++++------- server/account/src/utils.ts | 11 +++-- 5 files changed, 84 insertions(+), 27 deletions(-) diff --git a/server/account/src/__tests__/operations.test.ts b/server/account/src/__tests__/operations.test.ts index ca1bb24d3a1..cd1a2d51f3f 100644 --- a/server/account/src/__tests__/operations.test.ts +++ b/server/account/src/__tests__/operations.test.ts @@ -1780,7 +1780,7 @@ describe('account operations', () => { mockFirstName, mockLastName ) - expect(utils.sendEmailConfirmation).toHaveBeenCalledWith(mockCtx, mockBranding, mockAccountId, mockEmail) + expect(utils.sendEmailConfirmation).toHaveBeenCalledWith(mockCtx, mockDb, mockBranding, mockAccountId, mockEmail) expect(mockCtx.warn).not.toHaveBeenCalled() }) @@ -2397,6 +2397,7 @@ describe('account operations', () => { // Confirmation email must be sent, threading the invite info through the token expect(utils.sendEmailConfirmation).toHaveBeenCalledWith( mockCtx, + mockDb, mockBranding, mockAccountId, mockEmail, diff --git a/server/account/src/__tests__/utils.test.ts b/server/account/src/__tests__/utils.test.ts index 6a86da07997..65e35dc3155 100644 --- a/server/account/src/__tests__/utils.test.ts +++ b/server/account/src/__tests__/utils.test.ts @@ -995,7 +995,7 @@ describe('account utils', () => { const email = 'test@example.com' test('should send confirmation email with correct link', async () => { - await sendEmailConfirmation(mockCtx, mockBranding, account, email) + await sendEmailConfirmation(mockCtx, mockDb, mockBranding, account, email) expect(mockFetch).toHaveBeenCalledWith( 'https://ses.example.com/send', @@ -1013,7 +1013,7 @@ describe('account utils', () => { test('should throw error if MAIL_URL is missing', async () => { ;(getMetadata as jest.Mock).mockReturnValue(undefined) - await expect(sendEmailConfirmation(mockCtx, mockBranding, account, email)).rejects.toThrow( + await expect(sendEmailConfirmation(mockCtx, mockDb, mockBranding, account, email)).rejects.toThrow( new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {})) ) @@ -1026,7 +1026,7 @@ describe('account utils', () => { front: 'https://custom.example.com' } - await sendEmailConfirmation(mockCtx, brandingWithFront, account, email) + await sendEmailConfirmation(mockCtx, mockDb, brandingWithFront, account, email) expect(mockFetch).toHaveBeenCalledWith( expect.any(String), diff --git a/server/account/src/collections/mongo.ts b/server/account/src/collections/mongo.ts index 18e0ff33b78..8ec50801d74 100644 --- a/server/account/src/collections/mongo.ts +++ b/server/account/src/collections/mongo.ts @@ -40,6 +40,8 @@ import type { AccountDB, AccountEvent, AccountAggregatedInfo, + AdminAuditLogCollection, + AdminAuditLogEntry, DbCollection, Integration, IntegrationSecret, @@ -392,6 +394,44 @@ interface MigrationInfo { lastProcessedTime: number } +class MongoAdminAuditLogCollection implements AdminAuditLogCollection { + constructor (private readonly db: Db) {} + + private get collection (): Collection { + return this.db.collection('admin_audit_log') + } + + async insert (entry: Omit): Promise { + await this.collection.insertOne({ + id: new UUID().toString(), + tsMs: Date.now(), + adminAccount: entry.adminAccount, + targetAccount: entry.targetAccount, + action: entry.action, + workspaceUuid: entry.workspaceUuid, + details: entry.details + } as any) + } + + async findByTarget (target: AccountUuid, limit: number): Promise { + const rows = await this.collection + .find({ targetAccount: target } as any) + .sort({ tsMs: -1 }) + .limit(limit) + .toArray() + return rows.map(({ _id, ...rest }) => rest as AdminAuditLogEntry) + } + + async findByAdmin (admin: AccountUuid, limit: number): Promise { + const rows = await this.collection + .find({ adminAccount: admin } as any) + .sort({ tsMs: -1 }) + .limit(limit) + .toArray() + return rows.map(({ _id, ...rest }) => rest as AdminAuditLogEntry) + } +} + export class MongoAccountDB implements AccountDB { migration: MongoDbCollection person: MongoDbCollection @@ -411,6 +451,7 @@ export class MongoAccountDB implements AccountDB { workspaceMembers: MongoDbCollection workspacePermission: MongoDbCollection + adminAuditLog: AdminAuditLogCollection constructor (readonly db: Db) { this.migration = new MongoDbCollection('migration', db, 'key') @@ -431,6 +472,7 @@ export class MongoAccountDB implements AccountDB { this.workspaceMembers = new MongoDbCollection('workspaceMembers', db) this.workspacePermission = new MongoDbCollection('workspacePermissions', db) + this.adminAuditLog = new MongoAdminAuditLogCollection(db) } async init (): Promise { diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 42e1c17636a..4ff84351ad8 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -127,7 +127,8 @@ import { checkPasswordAging, generateTotpSecret, verifyTotpCode, - getTotpUrl + getTotpUrl, + generateTokenWithVersion } from './utils' const NIL_UUID = '00000000-0000-0000-0000-000000000000' as AccountUuid @@ -236,7 +237,9 @@ export async function login ( return { account: existingAccount.uuid, token: isConfirmed - ? generateToken( + ? await generateTokenWithVersion( + ctx, + db, existingAccount.tfaSecret != null ? NIL_UUID : existingAccount.uuid, undefined, existingAccount.tfaSecret != null ? { ...extraToken, tfaAccount: existingAccount.uuid } : extraToken @@ -322,7 +325,7 @@ export async function signUp ( if (forceConfirmation) { const normalizedEmail = cleanEmail(email) - await sendEmailConfirmation(ctx, branding, account, normalizedEmail) + await sendEmailConfirmation(ctx, db, branding, account, normalizedEmail) } else { ctx.warn('Please provide MAIL_URL to enable sign up email confirmations.') await confirmEmail(ctx, db, account, email) @@ -334,7 +337,7 @@ export async function signUp ( account, name: getPersonName(person), socialId, - token: !forceConfirmation ? generateToken(account) : undefined + token: !forceConfirmation ? await generateTokenWithVersion(ctx, db, account) : undefined } } @@ -528,7 +531,9 @@ export async function validateOtp ( : { authMethod: 'otp' } const _token = isConfirmed - ? generateToken( + ? await generateTokenWithVersion( + ctx, + db, targetAccount?.tfaSecret != null ? NIL_UUID : emailSocialId.personUuid, undefined, targetAccount?.tfaSecret != null ? { ...extraToken, tfaAccount: emailSocialId.personUuid } : extraToken @@ -624,7 +629,7 @@ export async function createWorkspace ( account, socialId: socialId._id, name: getPersonName(person), - token: generateToken(account, workspaceUuid, extra), + token: await generateTokenWithVersion(ctx, db, account, workspaceUuid, extra), endpoint: getEndpoint(workspaceUuid, region, EndpointKind.External), workspace: workspaceUuid, workspaceUrl, @@ -1247,7 +1252,7 @@ export async function checkAutoJoin ( } if (token === undefined || token === null) { - token = generateToken(targetAccount.uuid) + token = await generateTokenWithVersion(ctx, db, targetAccount.uuid) } return await selectWorkspace(ctx, db, branding, token, { workspaceUrl: workspace.url, kind: 'external' }) } @@ -1271,7 +1276,15 @@ export async function checkAutoJoin ( true ) - return await doJoinByInvite(ctx, db, branding, generateToken(account, workspaceUuid), account, workspace, invite) + return await doJoinByInvite( + ctx, + db, + branding, + await generateTokenWithVersion(ctx, db, account, workspaceUuid), + account, + workspace, + invite + ) } /** @@ -1333,7 +1346,7 @@ export async function signUpJoin ( const normalizedEmail = cleanEmail(email) // Thread the invite info through the confirmation token so the user // is auto-joined to the workspace once they confirm their email. - await sendEmailConfirmation(ctx, branding, account, normalizedEmail, { + await sendEmailConfirmation(ctx, db, branding, account, normalizedEmail, { inviteId, workspaceUrl }) @@ -1353,7 +1366,7 @@ export async function signUpJoin ( ctx, db, branding, - generateToken(account, workspaceJoinInfo.workspace?.uuid), + await generateTokenWithVersion(ctx, db, account, workspaceJoinInfo.workspace?.uuid), account, workspaceJoinInfo.workspace, workspaceJoinInfo.invite @@ -1387,7 +1400,7 @@ export async function confirm ( account, name: getPersonName(person), socialId, - token: generateToken(account) + token: await generateTokenWithVersion(ctx, db, account) } // If invite info was carried through the confirmation token (signUpJoin flow), @@ -1401,7 +1414,7 @@ export async function confirm ( ctx, db, branding, - generateToken(account, joinInfo.workspace?.uuid), + await generateTokenWithVersion(ctx, db, account, joinInfo.workspace?.uuid), account, joinInfo.workspace, joinInfo.invite @@ -1517,7 +1530,7 @@ export async function requestPasswordReset ( const { mailURL, mailAuth } = getMailUrl() const front = getFrontUrl(branding) - const token = generateToken(account.uuid, undefined, { + const token = await generateTokenWithVersion(ctx, db, account.uuid, undefined, { restoreEmail: normalizedEmail }) @@ -1590,7 +1603,7 @@ export async function requestPasswordSetup ( const { mailURL, mailAuth } = getMailUrl() const front = getFrontUrl(branding) - const resetToken = generateToken(accountUuid, undefined, { restoreEmail: emailSocialId.value }) + const resetToken = await generateTokenWithVersion(ctx, db, accountUuid, undefined, { restoreEmail: emailSocialId.value }) const link = concatLink(front, `/login/recovery?id=${resetToken}`) const lang = branding?.language const text = await translate(accountPlugin.string.PasswordSetupText, { link }, lang) @@ -1728,7 +1741,7 @@ export async function leaveWorkspace ( return { account, name: getPersonName(person), - token: generateToken(account, undefined, extra) + token: await generateTokenWithVersion(ctx, db, account, undefined, extra) } } @@ -1910,7 +1923,7 @@ export async function verify2fa ( return { account: accountUuid, - token: generateToken(accountUuid, undefined, filteredExtra), + token: await generateTokenWithVersion(ctx, db, accountUuid, undefined, filteredExtra), name: getPersonName(person), socialId: socialId?._id } @@ -2190,7 +2203,7 @@ export async function getLoginInfoByToken ( account: accountUuid, name: getPersonName(person), socialId: socialId?._id, - token: generateToken(accountUuid, workspaceUuid, extra, undefined, { grant, nbf, exp, sub }) + token: await generateTokenWithVersion(ctx, db, accountUuid, workspaceUuid, extra, { grant, nbf, exp, sub }) } if (!isSystem) { @@ -2777,7 +2790,7 @@ export async function refreshHulyAssistantToken ( key } - const secret = generateToken(account, undefined, { userAiAssistant: 'true' }) + const secret = await generateTokenWithVersion(ctx, db, account, undefined, { userAiAssistant: 'true' }) const existingToken = await db.integrationSecret.findOne(integrationSecretKey) diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index 176974ecaee..1119fcbf332 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -866,7 +866,7 @@ export async function selectWorkspace ( if (accountUuid === systemAccountUuid) { return { account: accountUuid, - token: generateToken(accountUuid, workspace.uuid, extra, undefined, { + token: await generateTokenWithVersion(ctx, db, accountUuid, workspace.uuid, extra, { grant, sub, exp, @@ -928,7 +928,7 @@ export async function selectWorkspace ( return { account: accountUuid, - token: generateToken(accountUuid, workspace.uuid, extra, undefined, { + token: await generateTokenWithVersion(ctx, db, accountUuid, workspace.uuid, extra, { grant, sub, exp, @@ -1284,6 +1284,7 @@ export async function checkInvite (ctx: MeasureContext, invite: WorkspaceInvite, export async function sendEmailConfirmation ( ctx: MeasureContext, + db: AccountDB, branding: Branding | null, account: PersonUuid, email: string, @@ -1303,7 +1304,7 @@ export async function sendEmailConfirmation ( throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {})) } - const token = generateToken(account, undefined, { + const token = await generateTokenWithVersion(ctx, db, account, undefined, { confirmEmail: email, ...(extra ?? {}) }) @@ -1666,7 +1667,7 @@ export async function loginOrSignUpWithProvider ( account: personUuid as AccountUuid, socialId: socialIdId, name: getPersonName(person), - token: generateToken(personUuid, undefined, extraToken) + token: await generateTokenWithVersion(ctx, db, personUuid, undefined, extraToken) } } catch (err: any) { Analytics.handleError(err) @@ -1718,7 +1719,7 @@ export async function joinWithProvider ( ctx, db, branding, - generateToken(loginInfo.account, workspaceUuid), + await generateTokenWithVersion(ctx, db, loginInfo.account, workspaceUuid), loginInfo.account, workspace, invite From 9abca6d55871d5782bdab08e4ddf26aa0f5f3e45 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:28:05 +0000 Subject: [PATCH 006/177] feat(account): wire verifyTokenVersion into selectWorkspace + getLoginInfoByToken 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 --- server/account/src/operations.ts | 4 +++- server/account/src/utils.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 4ff84351ad8..dc905bf1542 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -128,7 +128,8 @@ import { generateTotpSecret, verifyTotpCode, getTotpUrl, - generateTokenWithVersion + generateTokenWithVersion, + verifyTokenVersion } from './utils' const NIL_UUID = '00000000-0000-0000-0000-000000000000' as AccountUuid @@ -2078,6 +2079,7 @@ export async function getLoginInfoByToken ( try { ;({ account, workspace: workspaceUuid, extra, grant, nbf, exp, sub } = decodeTokenVerbose(ctx, token)) + await verifyTokenVersion(ctx, db, token) if (grant != null && sub == null) { sub = (await db.generatePersonUuid()) as AccountUuid } diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index 1119fcbf332..5aa00d2a8a9 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -209,12 +209,14 @@ export async function generateTokenWithVersion ( export async function verifyTokenVersion (ctx: MeasureContext, db: AccountDB, token: string): Promise { const { account: accountUuid, extra } = decodeTokenVerbose(ctx, token) if (accountUuid === GUEST_ACCOUNT) return + if (accountUuid === systemAccountUuid) return + if (accountUuid === readOnlyGuestAccountUuid) return if (!UUID_REGEX.test(accountUuid)) return const tokenVersionClaim = parseInt(extra?.token_version ?? '0', 10) const account = await db.account.findOne({ uuid: accountUuid as AccountUuid }) - if (account == null) { - throw new TokenError('Account not found') - } + // Account row may be missing for service-issued tokens (e.g. NIL_UUID for 2FA-pending). + // Only enforce when a row exists. + if (account == null) return if (account.disabledAt != null) { throw new TokenError('Account disabled') } @@ -809,6 +811,7 @@ export async function selectWorkspace ( let nbf: number | undefined try { const decodedToken = decodeTokenVerbose(ctx, token ?? '') + await verifyTokenVersion(ctx, db, token ?? '') accountUuid = decodedToken.account if (workspace == null) { workspace = await getWorkspaceById(db, decodedToken.workspace) From aeaf654087e7ef1c31841a5d917e8a01fd941ac3 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:28:59 +0000 Subject: [PATCH 007/177] feat(account): touchLastActivity helper with 5-min throttle Signed-off-by: Michael Uray --- .../src/__tests__/touchLastActivity.test.ts | 47 +++++++++++++++++++ server/account/src/utils.ts | 21 +++++++++ 2 files changed, 68 insertions(+) create mode 100644 server/account/src/__tests__/touchLastActivity.test.ts diff --git a/server/account/src/__tests__/touchLastActivity.test.ts b/server/account/src/__tests__/touchLastActivity.test.ts new file mode 100644 index 00000000000..c0d714dcd13 --- /dev/null +++ b/server/account/src/__tests__/touchLastActivity.test.ts @@ -0,0 +1,47 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { touchLastActivity } from '../utils' +import type { AccountDB } from '../types' + +const TEST_UUID = '22222222-2222-2222-2222-222222222222' as any + +describe('touchLastActivity', () => { + let updatedTo: number | null = null + const mockDb = (lastActivityAt: number | null): AccountDB => + ({ + account: { + findOne: async () => ({ uuid: TEST_UUID, lastActivityAt }), + update: async (q: any, ops: any) => { + updatedTo = ops.lastActivityAt + } + } + }) as unknown as AccountDB + + beforeEach(() => { + updatedTo = null + }) + + it('updates lastActivityAt when never set', async () => { + const db = mockDb(null) + await touchLastActivity(db, TEST_UUID) + expect(updatedTo).not.toBeNull() + expect(updatedTo! > Date.now() - 1000).toBe(true) + }) + + it('updates lastActivityAt when older than throttle (5 minutes)', async () => { + const oldTime = Date.now() - 6 * 60 * 1000 + const db = mockDb(oldTime) + await touchLastActivity(db, TEST_UUID) + expect(updatedTo).not.toBeNull() + expect(updatedTo! > oldTime).toBe(true) + }) + + it('skips update when within throttle window', async () => { + const recentTime = Date.now() - 60 * 1000 + const db = mockDb(recentTime) + await touchLastActivity(db, TEST_UUID) + expect(updatedTo).toBeNull() + }) +}) diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index 5aa00d2a8a9..917e61d52b5 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -225,6 +225,27 @@ export async function verifyTokenVersion (ctx: MeasureContext, db: AccountDB, to } } +const LAST_ACTIVITY_THROTTLE_MS = 5 * 60 * 1000 + +/** + * Update account.lastActivityAt with throttling. Only writes when the existing + * value is older than 5 minutes (or null) to avoid a hot-path write storm. + * Best-effort: failures are logged and swallowed, never block auth. + */ +export async function touchLastActivity (db: AccountDB, accountUuid: AccountUuid): Promise { + try { + const account = await db.account.findOne({ uuid: accountUuid }) + if (account == null) return + const now = Date.now() + const last = account.lastActivityAt ?? 0 + if (now - last < LAST_ACTIVITY_THROTTLE_MS) return + await db.account.update({ uuid: accountUuid }, { lastActivityAt: now }) + } catch (err) { + // eslint-disable-next-line no-console + console.warn('touchLastActivity failed', { accountUuid, err }) + } +} + export function wrap ( accountMethod: (ctx: MeasureContext, db: AccountDB, branding: Branding | null, ...args: any[]) => Promise ): AccountMethodHandler { From 6b708728f0bf87857de58a520d30ce4874569ed9 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:30:18 +0000 Subject: [PATCH 008/177] feat(account): touch lastActivityAt from all login + workspace-select 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 --- server/account/src/operations.ts | 8 +++++++- server/account/src/utils.ts | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index dc905bf1542..06dc6a72d17 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -129,7 +129,8 @@ import { verifyTotpCode, getTotpUrl, generateTokenWithVersion, - verifyTokenVersion + verifyTokenVersion, + touchLastActivity } from './utils' const NIL_UUID = '00000000-0000-0000-0000-000000000000' as AccountUuid @@ -227,6 +228,7 @@ export async function login ( // Successful login - reset failed attempts counter await resetFailedLoginAttempts(db, existingAccount.uuid) + await touchLastActivity(db, existingAccount.uuid) const isConfirmed = emailSocialId.verifiedOn != null @@ -541,6 +543,10 @@ export async function validateOtp ( ) : undefined + if (emailSocialId.personUuid != null) { + await touchLastActivity(db, emailSocialId.personUuid as AccountUuid) + } + return { account: emailSocialId.personUuid as AccountUuid, name: getPersonName(person), diff --git a/server/account/src/utils.ts b/server/account/src/utils.ts index 917e61d52b5..6c34090b7ce 100644 --- a/server/account/src/utils.ts +++ b/server/account/src/utils.ts @@ -928,6 +928,10 @@ export async function selectWorkspace ( void setTimezone(ctx, db, accountUuid, account, meta) } + if (accountUuid !== systemAccountUuid && accountUuid !== readOnlyGuestAccountUuid) { + await touchLastActivity(db, accountUuid) + } + if (role === AccountRole.ReadOnlyGuest) { if (extra == null) { extra = {} @@ -1684,6 +1688,7 @@ export async function loginOrSignUpWithProvider ( } await confirmHulyIds(ctx, db, personUuid as AccountUuid) + await touchLastActivity(db, personUuid as AccountUuid) const extraToken: Record = isAdminEmail(normalizedEmail) ? { admin: 'true' } : {} ctx.info('Provider login succeeded', { email, normalizedEmail, emailSocialId, socialId, ...extraToken }) From f79a43d10a96ffa686d515255e4e249bc0eda239 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:32:58 +0000 Subject: [PATCH 009/177] feat(account-client): admin user management DTOs + workspace-dep in server/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 --- common/config/rush/pnpm-lock.yaml | 3 ++ .../core/packages/account-client/src/types.ts | 53 +++++++++++++++++++ server/account/package.json | 3 +- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 5437a5f45b7..50d93a129d5 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -35779,6 +35779,9 @@ importers: ../../server/account: dependencies: + '@hcengineering/account-client': + specifier: workspace:^0.7.25 + version: link:../../foundations/core/packages/account-client '@hcengineering/analytics': specifier: workspace:^0.7.19 version: link:../../foundations/core/packages/analytics diff --git a/foundations/core/packages/account-client/src/types.ts b/foundations/core/packages/account-client/src/types.ts index c7c68a45a51..2cf9a67bcc3 100644 --- a/foundations/core/packages/account-client/src/types.ts +++ b/foundations/core/packages/account-client/src/types.ts @@ -243,3 +243,56 @@ export interface Subscription { * Used by billing service to upsert subscription data */ export type SubscriptionData = Omit + +// ===================================================================== +// Admin user management DTOs (V27) +// ===================================================================== + +export interface ListAccountsAdminParams { + search?: string + authMethod?: 'all' | 'email_only' | 'oidc' | 'mixed' + status?: 'all' | 'active' | 'disabled' + workspaceUuids?: WorkspaceUuid[] + sort?: { + field: 'name' | 'last_activity' | 'workspace_count' + direction: 'asc' | 'desc' + } + pagination: { limit: number, offset: number } +} + +export interface AccountListRow { + uuid: AccountUuid + firstName: string + lastName: string + primaryEmail: string | null + authMethods: Array<'email' | 'oidc'> + hasPassword: boolean + workspaceCount: number + status: 'active' | 'disabled' + lastActivityAt: number | null + isAdmin: boolean +} + +export interface AccountDetailsResponse { + uuid: AccountUuid + firstName: string + lastName: string + status: 'active' | 'disabled' + disabledAt: number | null + lastActivityAt: number | null + isAdmin: boolean + socialIds: Array<{ type: string, value: string, verified: boolean }> + workspaceMemberships: Array<{ + workspaceUuid: WorkspaceUuid + workspaceName: string + workspaceUrl: string + role: AccountRole + }> + recentAuditEntries: Array<{ + tsMs: number + adminFirstName: string + adminLastName: string + action: string + details: any + }> +} diff --git a/server/account/package.json b/server/account/package.json index 7d1dccbaf1b..8538a587097 100644 --- a/server/account/package.json +++ b/server/account/package.json @@ -48,6 +48,7 @@ "@hcengineering/server-storage": "workspace:^0.7.16", "@hcengineering/server-core": "workspace:^0.7.19", "otplib": "^12.0.1", - "@hcengineering/server-pipeline": "workspace:^0.7.0" + "@hcengineering/server-pipeline": "workspace:^0.7.0", + "@hcengineering/account-client": "workspace:^0.7.25" } } From 4ad9dbf09945deb809052b4f8dbff55413c63666 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:35:02 +0000 Subject: [PATCH 010/177] feat(account): listAccountsAdmin endpoint (admin-gated, filter+sort+paginate) 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 --- .../src/__tests__/listAccountsAdmin.test.ts | 46 ++++++++ server/account/src/serviceOperations.ts | 105 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 server/account/src/__tests__/listAccountsAdmin.test.ts diff --git a/server/account/src/__tests__/listAccountsAdmin.test.ts b/server/account/src/__tests__/listAccountsAdmin.test.ts new file mode 100644 index 00000000000..306d778aee0 --- /dev/null +++ b/server/account/src/__tests__/listAccountsAdmin.test.ts @@ -0,0 +1,46 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { MeasureContext } from '@hcengineering/core' +import { PlatformError } from '@hcengineering/platform' + +const ADMIN_TOKEN = 'fake-admin-token' +const USER_TOKEN = 'fake-user-token' + +jest.mock('@hcengineering/server-token', () => ({ + decodeTokenVerbose: (ctx: any, token: string) => { + if (token === ADMIN_TOKEN) return { account: 'admin-uuid', extra: { admin: 'true' } } + if (token === USER_TOKEN) return { account: 'user-uuid', extra: {} } + throw new Error('bad token') + }, + TokenError: class extends Error {} +})) + +const ctx = { newChild: () => ctx, info: () => {}, warn: () => {}, error: () => {} } as unknown as MeasureContext + +const fakeDb = (): any => ({ + account: { + find: async () => [ + { uuid: 'u1', firstName: 'Alice', lastName: 'A', disabledAt: null, tokenVersion: 0, lastActivityAt: 1700000000000 } + ] + }, + socialId: { find: async () => [] }, + getWorkspaceRoles: async () => new Map() +}) + +import { listAccountsAdmin } from '../serviceOperations' + +describe('listAccountsAdmin', () => { + it('returns 403 for non-admin caller', async () => { + await expect( + listAccountsAdmin(ctx, fakeDb(), null, USER_TOKEN, { pagination: { limit: 50, offset: 0 } }) + ).rejects.toThrow(PlatformError) + }) + + it('returns a list for admin caller with default sort', async () => { + const res = await listAccountsAdmin(ctx, fakeDb(), null, ADMIN_TOKEN, { pagination: { limit: 50, offset: 0 } }) + expect(res.total).toBeGreaterThanOrEqual(0) + expect(Array.isArray(res.accounts)).toBe(true) + }) +}) diff --git a/server/account/src/serviceOperations.ts b/server/account/src/serviceOperations.ts index f13af033458..d978461acbc 100644 --- a/server/account/src/serviceOperations.ts +++ b/server/account/src/serviceOperations.ts @@ -32,6 +32,7 @@ import { } from '@hcengineering/core' import platform, { getMetadata, PlatformError, Severity, Status, unknownError } from '@hcengineering/platform' import { decodeTokenVerbose } from '@hcengineering/server-token' +import type { ListAccountsAdminParams, AccountListRow } from '@hcengineering/account-client' import { accountPlugin } from './plugin' import type { @@ -119,6 +120,108 @@ export async function listAccounts ( return await db.listAccounts(search, skip, limit) } +export async function listAccountsAdmin ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: ListAccountsAdminParams +): Promise<{ total: number, accounts: AccountListRow[] }> { + const { extra } = decodeTokenVerbose(ctx, token) + if (extra?.admin !== 'true') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + // Initial implementation does the filtering in JS for simplicity. The Postgres-side + // optimization (server-side filter + count) is a follow-up. + const allAccounts = await db.account.find({}) + const allSocialIds = await db.socialId.find({}) + const adminEmails = new Set( + (process.env.ADMIN_EMAILS ?? '') + .split(',') + .map((e) => e.trim()) + .filter(Boolean) + ) + + const rows: AccountListRow[] = await Promise.all( + allAccounts.map(async (acc) => { + const socials = allSocialIds.filter((s) => s.personUuid === acc.uuid) + const emailSocials = socials.filter((s) => s.type === SocialIdType.EMAIL) + const oidcSocials = socials.filter((s) => s.type === SocialIdType.OIDC) + const workspaceRoles = await db.getWorkspaceRoles(acc.uuid) + const primaryEmail = emailSocials[0]?.value ?? null + const authMethods: Array<'email' | 'oidc'> = [] + if (emailSocials.length > 0) authMethods.push('email') + if (oidcSocials.length > 0) authMethods.push('oidc') + + return { + uuid: acc.uuid, + firstName: (acc as any).firstName ?? '', + lastName: (acc as any).lastName ?? '', + primaryEmail, + authMethods, + hasPassword: acc.hash != null, + workspaceCount: workspaceRoles.size, + status: acc.disabledAt != null ? ('disabled' as const) : ('active' as const), + lastActivityAt: acc.lastActivityAt ?? null, + isAdmin: primaryEmail != null && adminEmails.has(primaryEmail) + } + }) + ) + + let filtered = rows + if (params.search != null && params.search.trim() !== '') { + const needle = params.search.trim().toLowerCase() + filtered = filtered.filter( + (r) => + r.firstName.toLowerCase().includes(needle) || + r.lastName.toLowerCase().includes(needle) || + (r.primaryEmail ?? '').toLowerCase().includes(needle) + ) + } + if (params.status != null && params.status !== 'all') { + filtered = filtered.filter((r) => r.status === params.status) + } + if (params.authMethod != null && params.authMethod !== 'all') { + if (params.authMethod === 'email_only') { + filtered = filtered.filter((r) => r.authMethods.length === 1 && r.authMethods[0] === 'email') + } else if (params.authMethod === 'oidc') { + filtered = filtered.filter((r) => r.authMethods.length === 1 && r.authMethods[0] === 'oidc') + } else if (params.authMethod === 'mixed') { + filtered = filtered.filter((r) => r.authMethods.length > 1) + } + } + if (params.workspaceUuids != null && params.workspaceUuids.length > 0) { + const wsSet = new Set(params.workspaceUuids) + const filteredWithCheck: AccountListRow[] = [] + for (const r of filtered) { + const wsRoles = await db.getWorkspaceRoles(r.uuid) + if (Array.from(wsRoles.keys()).some((uuid) => wsSet.has(uuid))) { + filteredWithCheck.push(r) + } + } + filtered = filteredWithCheck + } + + const sortField = params.sort?.field ?? 'name' + const sortDir = params.sort?.direction ?? 'asc' + filtered.sort((a, b) => { + let cmp = 0 + if (sortField === 'name') { + cmp = (a.firstName + a.lastName).localeCompare(b.firstName + b.lastName) + } else if (sortField === 'last_activity') { + cmp = (a.lastActivityAt ?? 0) - (b.lastActivityAt ?? 0) + } else if (sortField === 'workspace_count') { + cmp = a.workspaceCount - b.workspaceCount + } + return sortDir === 'asc' ? cmp : -cmp + }) + + const total = filtered.length + const accounts = filtered.slice(params.pagination.offset, params.pagination.offset + params.pagination.limit) + return { total, accounts } +} + export async function performWorkspaceOperation ( ctx: MeasureContext, db: AccountDB, @@ -1146,6 +1249,7 @@ export type AccountServiceMethods = | 'mergeSpecifiedAccounts' | 'findPersonBySocialKey' | 'listAccounts' + | 'listAccountsAdmin' | 'findFullSocialIds' | 'getSubscriptionByProviderId' | 'upsertSubscription' @@ -1182,6 +1286,7 @@ export function getServiceMethods (): Partial Date: Sat, 23 May 2026 15:36:50 +0000 Subject: [PATCH 011/177] feat(account): getAccountDetails endpoint (admin, identities + memberships + recent audit) Signed-off-by: Michael Uray --- .../src/__tests__/getAccountDetails.test.ts | 58 ++++++++++++++++ server/account/src/serviceOperations.ts | 68 ++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 server/account/src/__tests__/getAccountDetails.test.ts diff --git a/server/account/src/__tests__/getAccountDetails.test.ts b/server/account/src/__tests__/getAccountDetails.test.ts new file mode 100644 index 00000000000..3350120c6b4 --- /dev/null +++ b/server/account/src/__tests__/getAccountDetails.test.ts @@ -0,0 +1,58 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { MeasureContext } from '@hcengineering/core' +import { PlatformError } from '@hcengineering/platform' + +const ADMIN_TOKEN = 'admin-token' +const USER_TOKEN = 'user-token' +const TARGET_UUID = '33333333-3333-3333-3333-333333333333' as any + +jest.mock('@hcengineering/server-token', () => ({ + decodeTokenVerbose: (ctx: any, token: string) => { + if (token === ADMIN_TOKEN) return { account: 'admin-uuid', extra: { admin: 'true' } } + if (token === USER_TOKEN) return { account: 'user-uuid', extra: {} } + throw new Error('bad token') + }, + TokenError: class extends Error {} +})) + +const ctx = { newChild: () => ctx, info: () => {} } as unknown as MeasureContext + +const fakeDb = (account: any): any => ({ + account: { findOne: async () => account }, + socialId: { find: async () => [] }, + getAccountWorkspaces: async () => [], + getWorkspaceRoles: async () => new Map(), + adminAuditLog: { findByTarget: async () => [] } +}) + +import { getAccountDetails } from '../serviceOperations' + +describe('getAccountDetails', () => { + it('rejects non-admin caller', async () => { + await expect( + getAccountDetails(ctx, fakeDb({ uuid: TARGET_UUID }), null, USER_TOKEN, { accountUuid: TARGET_UUID }) + ).rejects.toThrow(PlatformError) + }) + + it('returns 404 for unknown account', async () => { + await expect( + getAccountDetails(ctx, fakeDb(null), null, ADMIN_TOKEN, { accountUuid: TARGET_UUID }) + ).rejects.toThrow() + }) + + it('returns full detail for admin caller', async () => { + const account = { + uuid: TARGET_UUID, + firstName: 'Charlie', + lastName: 'Citrine', + disabledAt: null, + lastActivityAt: 1700000000000 + } + const res = await getAccountDetails(ctx, fakeDb(account), null, ADMIN_TOKEN, { accountUuid: TARGET_UUID }) + expect(res.uuid).toBe(TARGET_UUID) + expect(res.status).toBe('active') + }) +}) diff --git a/server/account/src/serviceOperations.ts b/server/account/src/serviceOperations.ts index d978461acbc..fb199c884cf 100644 --- a/server/account/src/serviceOperations.ts +++ b/server/account/src/serviceOperations.ts @@ -13,7 +13,7 @@ // limitations under the License. // import { - type AccountRole, + AccountRole, type Data, isActiveMode, type MeasureContext, @@ -32,7 +32,7 @@ import { } from '@hcengineering/core' import platform, { getMetadata, PlatformError, Severity, Status, unknownError } from '@hcengineering/platform' import { decodeTokenVerbose } from '@hcengineering/server-token' -import type { ListAccountsAdminParams, AccountListRow } from '@hcengineering/account-client' +import type { ListAccountsAdminParams, AccountListRow, AccountDetailsResponse } from '@hcengineering/account-client' import { accountPlugin } from './plugin' import type { @@ -222,6 +222,68 @@ export async function listAccountsAdmin ( return { total, accounts } } +export async function getAccountDetails ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { accountUuid: AccountUuid } +): Promise { + const { extra } = decodeTokenVerbose(ctx, token) + if (extra?.admin !== 'true') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const account = await db.account.findOne({ uuid: params.accountUuid }) + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + } + + const socialIds = await db.socialId.find({ personUuid: params.accountUuid }) + const workspaces = await db.getAccountWorkspaces(params.accountUuid) + const roleMap = await db.getWorkspaceRoles(params.accountUuid) + + const workspaceMemberships = workspaces.map((w) => ({ + workspaceUuid: w.uuid, + workspaceName: w.name, + workspaceUrl: w.url, + role: roleMap.get(w.uuid) ?? AccountRole.User + })) + + const recentAuditEntries = (await db.adminAuditLog.findByTarget(params.accountUuid, 20)).map((e) => ({ + tsMs: e.tsMs, + adminFirstName: '', + adminLastName: '', + action: e.action, + details: e.details + })) + + const primaryEmail = socialIds.find((s) => s.type === SocialIdType.EMAIL)?.value ?? '' + const adminEmails = new Set( + (process.env.ADMIN_EMAILS ?? '') + .split(',') + .map((e) => e.trim()) + .filter(Boolean) + ) + + return { + uuid: account.uuid, + firstName: (account as any).firstName ?? '', + lastName: (account as any).lastName ?? '', + status: account.disabledAt != null ? 'disabled' : 'active', + disabledAt: account.disabledAt ?? null, + lastActivityAt: account.lastActivityAt ?? null, + isAdmin: primaryEmail !== '' && adminEmails.has(primaryEmail), + socialIds: socialIds.map((s) => ({ + type: s.type, + value: s.value, + verified: (s as any).verifiedOn != null + })), + workspaceMemberships, + recentAuditEntries + } +} + export async function performWorkspaceOperation ( ctx: MeasureContext, db: AccountDB, @@ -1250,6 +1312,7 @@ export type AccountServiceMethods = | 'findPersonBySocialKey' | 'listAccounts' | 'listAccountsAdmin' + | 'getAccountDetails' | 'findFullSocialIds' | 'getSubscriptionByProviderId' | 'upsertSubscription' @@ -1287,6 +1350,7 @@ export function getServiceMethods (): Partial Date: Sat, 23 May 2026 15:38:28 +0000 Subject: [PATCH 012/177] feat(account): setWorkspaceMemberRole endpoint (admin, last-Owner guard) 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 --- .../__tests__/setWorkspaceMemberRole.test.ts | 88 +++++++++++++++++++ server/account/src/operations.ts | 47 ++++++++++ 2 files changed, 135 insertions(+) create mode 100644 server/account/src/__tests__/setWorkspaceMemberRole.test.ts diff --git a/server/account/src/__tests__/setWorkspaceMemberRole.test.ts b/server/account/src/__tests__/setWorkspaceMemberRole.test.ts new file mode 100644 index 00000000000..c63fb6272ad --- /dev/null +++ b/server/account/src/__tests__/setWorkspaceMemberRole.test.ts @@ -0,0 +1,88 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { MeasureContext, AccountRole, WorkspaceMemberInfo } from '@hcengineering/core' +import { PlatformError } from '@hcengineering/platform' + +const ADMIN_TOKEN = 'admin-token' +const USER_TOKEN = 'user-token' +const TARGET = 'target-uuid' as any +const WS = 'ws-uuid' as any + +jest.mock('@hcengineering/server-token', () => ({ + decodeTokenVerbose: (ctx: any, token: string) => { + if (token === ADMIN_TOKEN) return { account: 'admin-uuid', extra: { admin: 'true' } } + if (token === USER_TOKEN) return { account: 'user-uuid', extra: {} } + throw new Error('bad token') + }, + TokenError: class extends Error {} +})) + +const ctx = { newChild: () => ctx, info: () => {} } as unknown as MeasureContext + +function mockDb (currentRole: AccountRole | null, members: WorkspaceMemberInfo[] = []): any { + return { + getWorkspaceRole: async () => currentRole, + getWorkspaceMembers: async () => members, + updateWorkspaceRole: async () => undefined, + adminAuditLog: { insert: async () => undefined } + } +} + +import { setWorkspaceMemberRole } from '../operations' + +describe('setWorkspaceMemberRole', () => { + it('rejects non-admin caller', async () => { + await expect( + setWorkspaceMemberRole(ctx, mockDb(null), null, USER_TOKEN, { + accountUuid: TARGET, + workspaceUuid: WS, + newRole: AccountRole.User + }) + ).rejects.toThrow(PlatformError) + }) + + it('rejects when target is not a member of the workspace', async () => { + await expect( + setWorkspaceMemberRole(ctx, mockDb(null), null, ADMIN_TOKEN, { + accountUuid: TARGET, + workspaceUuid: WS, + newRole: AccountRole.User + }) + ).rejects.toThrow(PlatformError) + }) + + it('rejects demoting the last Owner', async () => { + const members: WorkspaceMemberInfo[] = [{ person: TARGET, role: AccountRole.Owner }] + await expect( + setWorkspaceMemberRole(ctx, mockDb(AccountRole.Owner, members), null, ADMIN_TOKEN, { + accountUuid: TARGET, + workspaceUuid: WS, + newRole: AccountRole.User + }) + ).rejects.toThrow(PlatformError) + }) + + it('allows demoting an Owner when other Owners exist', async () => { + const members: WorkspaceMemberInfo[] = [ + { person: TARGET, role: AccountRole.Owner }, + { person: 'other-uuid' as any, role: AccountRole.Owner } + ] + const res = await setWorkspaceMemberRole(ctx, mockDb(AccountRole.Owner, members), null, ADMIN_TOKEN, { + accountUuid: TARGET, + workspaceUuid: WS, + newRole: AccountRole.User + }) + expect(res).toEqual({ ok: true }) + }) + + it('allows changing role when target is already non-Owner', async () => { + const res = await setWorkspaceMemberRole(ctx, mockDb(AccountRole.User), null, ADMIN_TOKEN, { + accountUuid: TARGET, + workspaceUuid: WS, + newRole: AccountRole.Maintainer + }) + expect(res).toEqual({ ok: true }) + }) +}) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 06dc6a72d17..268f01c2578 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -3301,6 +3301,49 @@ export async function getWorkspaceUsersWithPermission ( return await db.getWorkspaceUsersWithPermission(workspace, permission) } +// ===================================================================== +// Admin user management (V27) — admin-gated mutation endpoints +// ===================================================================== + +export async function setWorkspaceMemberRole ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { accountUuid: AccountUuid, workspaceUuid: WorkspaceUuid, newRole: AccountRole } +): Promise<{ ok: true }> { + const { account: adminUuid, extra } = decodeTokenVerbose(ctx, token) + if (extra?.admin !== 'true') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const currentRole = await db.getWorkspaceRole(params.accountUuid, params.workspaceUuid) + if (currentRole == null) { + throw new PlatformError( + new Status(Severity.ERROR, platform.status.AccountNotFound, { account: params.accountUuid }) + ) + } + + if (currentRole === AccountRole.Owner && params.newRole !== AccountRole.Owner) { + const members = await db.getWorkspaceMembers(params.workspaceUuid) + const otherOwners = members.filter((m) => m.role === AccountRole.Owner && m.person !== params.accountUuid) + if (otherOwners.length === 0) { + throw new PlatformError(new Status(Severity.ERROR, 'last_owner_in_workspace' as any, {})) + } + } + + await db.updateWorkspaceRole(params.accountUuid, params.workspaceUuid, params.newRole) + await db.adminAuditLog.insert({ + adminAccount: adminUuid as AccountUuid, + targetAccount: params.accountUuid, + action: 'role_change', + workspaceUuid: params.workspaceUuid, + details: { oldRole: currentRole, newRole: params.newRole } + }) + + return { ok: true } +} + export type AccountMethods = | AccountServiceMethods | 'login' @@ -3378,6 +3421,7 @@ export type AccountMethods = | 'hasWorkspacePermission' | 'getWorkspacePermissions' | 'getWorkspaceUsersWithPermission' + | 'setWorkspaceMemberRole' /** * @public @@ -3445,6 +3489,9 @@ export function getMethods (hasSignUp: boolean = true): Partial Date: Sat, 23 May 2026 15:39:40 +0000 Subject: [PATCH 013/177] feat(account): removeWorkspaceMember endpoint (idempotent, last-Owner 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 --- .../__tests__/removeWorkspaceMember.test.ts | 75 +++++++++++++++++++ server/account/src/operations.ts | 39 ++++++++++ 2 files changed, 114 insertions(+) create mode 100644 server/account/src/__tests__/removeWorkspaceMember.test.ts diff --git a/server/account/src/__tests__/removeWorkspaceMember.test.ts b/server/account/src/__tests__/removeWorkspaceMember.test.ts new file mode 100644 index 00000000000..ad275220291 --- /dev/null +++ b/server/account/src/__tests__/removeWorkspaceMember.test.ts @@ -0,0 +1,75 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { MeasureContext, AccountRole, WorkspaceMemberInfo } from '@hcengineering/core' +import { PlatformError } from '@hcengineering/platform' + +const ADMIN_TOKEN = 'admin-token' +const TARGET = 'target-uuid' as any +const WS = 'ws-uuid' as any + +jest.mock('@hcengineering/server-token', () => ({ + decodeTokenVerbose: (ctx: any, token: string) => { + if (token === ADMIN_TOKEN) return { account: 'admin-uuid', extra: { admin: 'true' } } + throw new Error('bad token') + }, + TokenError: class extends Error {} +})) + +const ctx = { newChild: () => ctx, info: () => {} } as unknown as MeasureContext + +function mockDb (currentRole: AccountRole | null, members: WorkspaceMemberInfo[] = []): any { + return { + getWorkspaceRole: async () => currentRole, + getWorkspaceMembers: async () => members, + unassignWorkspace: async () => undefined, + adminAuditLog: { insert: async () => undefined } + } +} + +import { removeWorkspaceMember } from '../operations' + +describe('removeWorkspaceMember', () => { + it('returns wasMember=true when target was in workspace', async () => { + const members: WorkspaceMemberInfo[] = [ + { person: TARGET, role: AccountRole.User }, + { person: 'other-uuid' as any, role: AccountRole.Owner } + ] + const res = await removeWorkspaceMember(ctx, mockDb(AccountRole.User, members), null, ADMIN_TOKEN, { + accountUuid: TARGET, + workspaceUuid: WS + }) + expect(res).toEqual({ ok: true, wasMember: true }) + }) + + it('returns wasMember=false when target was NOT in workspace', async () => { + const res = await removeWorkspaceMember(ctx, mockDb(null), null, ADMIN_TOKEN, { + accountUuid: TARGET, + workspaceUuid: WS + }) + expect(res).toEqual({ ok: true, wasMember: false }) + }) + + it('rejects removal of the last Owner', async () => { + const members: WorkspaceMemberInfo[] = [{ person: TARGET, role: AccountRole.Owner }] + await expect( + removeWorkspaceMember(ctx, mockDb(AccountRole.Owner, members), null, ADMIN_TOKEN, { + accountUuid: TARGET, + workspaceUuid: WS + }) + ).rejects.toThrow(PlatformError) + }) + + it('allows removing an Owner when other Owners exist', async () => { + const members: WorkspaceMemberInfo[] = [ + { person: TARGET, role: AccountRole.Owner }, + { person: 'other-uuid' as any, role: AccountRole.Owner } + ] + const res = await removeWorkspaceMember(ctx, mockDb(AccountRole.Owner, members), null, ADMIN_TOKEN, { + accountUuid: TARGET, + workspaceUuid: WS + }) + expect(res).toEqual({ ok: true, wasMember: true }) + }) +}) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 268f01c2578..80c069111d4 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -3344,6 +3344,43 @@ export async function setWorkspaceMemberRole ( return { ok: true } } +export async function removeWorkspaceMember ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { accountUuid: AccountUuid, workspaceUuid: WorkspaceUuid } +): Promise<{ ok: true, wasMember: boolean }> { + const { account: adminUuid, extra } = decodeTokenVerbose(ctx, token) + if (extra?.admin !== 'true') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const currentRole = await db.getWorkspaceRole(params.accountUuid, params.workspaceUuid) + if (currentRole == null) { + return { ok: true, wasMember: false } + } + + if (currentRole === AccountRole.Owner) { + const members = await db.getWorkspaceMembers(params.workspaceUuid) + const otherOwners = members.filter((m) => m.role === AccountRole.Owner && m.person !== params.accountUuid) + if (otherOwners.length === 0) { + throw new PlatformError(new Status(Severity.ERROR, 'last_owner_in_workspace' as any, {})) + } + } + + await db.unassignWorkspace(params.accountUuid, params.workspaceUuid) + await db.adminAuditLog.insert({ + adminAccount: adminUuid as AccountUuid, + targetAccount: params.accountUuid, + action: 'remove_member', + workspaceUuid: params.workspaceUuid, + details: { priorRole: currentRole } + }) + + return { ok: true, wasMember: true } +} + export type AccountMethods = | AccountServiceMethods | 'login' @@ -3422,6 +3459,7 @@ export type AccountMethods = | 'getWorkspacePermissions' | 'getWorkspaceUsersWithPermission' | 'setWorkspaceMemberRole' + | 'removeWorkspaceMember' /** * @public @@ -3491,6 +3529,7 @@ export function getMethods (hasSignUp: boolean = true): Partial Date: Sat, 23 May 2026 15:41:17 +0000 Subject: [PATCH 014/177] =?UTF-8?q?feat(account):=20triggerPasswordReset?= =?UTF-8?q?=20endpoint=20(strict=20reject=20for=20OIDC-only=20=E2=80=94=20?= =?UTF-8?q?Option=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/triggerPasswordReset.test.ts | 61 +++++++++++++++++++ server/account/src/operations.ts | 45 ++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 server/account/src/__tests__/triggerPasswordReset.test.ts diff --git a/server/account/src/__tests__/triggerPasswordReset.test.ts b/server/account/src/__tests__/triggerPasswordReset.test.ts new file mode 100644 index 00000000000..07173801521 --- /dev/null +++ b/server/account/src/__tests__/triggerPasswordReset.test.ts @@ -0,0 +1,61 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { MeasureContext } from '@hcengineering/core' +import { PlatformError } from '@hcengineering/platform' + +const ADMIN_TOKEN = 'admin-token' +const TARGET = 'target-uuid' as any + +jest.mock('@hcengineering/server-token', () => ({ + decodeTokenVerbose: (ctx: any, token: string) => { + if (token === ADMIN_TOKEN) return { account: 'admin-uuid', extra: { admin: 'true' } } + throw new Error('bad token') + }, + TokenError: class extends Error {} +})) + +const ctx = { newChild: () => ctx, info: () => {}, warn: () => {}, error: () => {} } as unknown as MeasureContext + +import { triggerPasswordReset } from '../operations' + +describe('triggerPasswordReset', () => { + it('rejects when target has no email identity', async () => { + const db = { + socialId: { find: async () => [] }, + account: { findOne: async () => ({ uuid: TARGET, hash: null }) }, + adminAuditLog: { insert: async () => {} } + } as any + await expect(triggerPasswordReset(ctx, db, null, ADMIN_TOKEN, { accountUuid: TARGET })).rejects.toThrow( + PlatformError + ) + }) + + it('rejects when target is OIDC-only with no password', async () => { + const db = { + socialId: { + find: async () => [ + { personUuid: TARGET, type: 'email', value: 'user@example.com' }, + { personUuid: TARGET, type: 'oidc', value: 'abc123' } + ] + }, + account: { findOne: async () => ({ uuid: TARGET, hash: null }) }, + adminAuditLog: { insert: async () => {} } + } as any + await expect(triggerPasswordReset(ctx, db, null, ADMIN_TOKEN, { accountUuid: TARGET })).rejects.toThrow( + PlatformError + ) + }) + + it('succeeds for user with email + password', async () => { + const db = { + socialId: { find: async () => [{ personUuid: TARGET, type: 'email', value: 'user@example.com' }] }, + account: { findOne: async () => ({ uuid: TARGET, hash: Buffer.from('x') }) }, + adminAuditLog: { insert: async () => {} } + } as any + const res = await triggerPasswordReset(ctx, db, null, ADMIN_TOKEN, { accountUuid: TARGET }) + expect(res.ok).toBe(true) + expect(res.emailSentTo).toBe('user@example.com') + }) +}) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 80c069111d4..c99c178a65e 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -3381,6 +3381,49 @@ export async function removeWorkspaceMember ( return { ok: true, wasMember: true } } +export async function triggerPasswordReset ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { accountUuid: AccountUuid } +): Promise<{ ok: true, emailSentTo: string }> { + const { account: adminUuid, extra } = decodeTokenVerbose(ctx, token) + if (extra?.admin !== 'true') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const socials = await db.socialId.find({ personUuid: params.accountUuid }) + const emailSocial = socials.find((s) => s.type === SocialIdType.EMAIL) + if (emailSocial == null) { + throw new PlatformError(new Status(Severity.ERROR, 'user_has_no_email' as any, {})) + } + + const account = await db.account.findOne({ uuid: params.accountUuid }) + if (account?.hash == null) { + throw new PlatformError(new Status(Severity.ERROR, 'user_has_no_password' as any, {})) + } + + try { + await requestPasswordReset(ctx, db, branding, '', { email: emailSocial.value }) + } catch (err) { + ctx.warn('Password reset email send failed; audit still recorded', { + err, + accountUuid: params.accountUuid + }) + } + + await db.adminAuditLog.insert({ + adminAccount: adminUuid as AccountUuid, + targetAccount: params.accountUuid, + action: 'trigger_password_reset', + workspaceUuid: null, + details: { emailSentTo: emailSocial.value } + }) + + return { ok: true, emailSentTo: emailSocial.value } +} + export type AccountMethods = | AccountServiceMethods | 'login' @@ -3460,6 +3503,7 @@ export type AccountMethods = | 'getWorkspaceUsersWithPermission' | 'setWorkspaceMemberRole' | 'removeWorkspaceMember' + | 'triggerPasswordReset' /** * @public @@ -3530,6 +3574,7 @@ export function getMethods (hasSignUp: boolean = true): Partial Date: Sat, 23 May 2026 15:42:22 +0000 Subject: [PATCH 015/177] feat(core): WorkspaceEvent.AccountDisabled + QueueAccountLifecycleMessage 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 --- foundations/core/packages/core/src/tx.ts | 3 ++- .../server/packages/core/src/queue/types.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/foundations/core/packages/core/src/tx.ts b/foundations/core/packages/core/src/tx.ts index 493c29b3607..0ae961f5031 100644 --- a/foundations/core/packages/core/src/tx.ts +++ b/foundations/core/packages/core/src/tx.ts @@ -53,7 +53,8 @@ export enum WorkspaceEvent { SecurityChange, MaintenanceNotification, BulkUpdate, - LastTx + LastTx, + AccountDisabled } /** diff --git a/foundations/server/packages/core/src/queue/types.ts b/foundations/server/packages/core/src/queue/types.ts index fd19d9b2f07..311a762d512 100644 --- a/foundations/server/packages/core/src/queue/types.ts +++ b/foundations/server/packages/core/src/queue/types.ts @@ -1,4 +1,4 @@ -import type { MeasureContext, WorkspaceUuid } from '@hcengineering/core' +import type { AccountUuid, MeasureContext, WorkspaceUuid } from '@hcengineering/core' export enum QueueTopic { // Topic with partitions to split workspace transactions into @@ -80,3 +80,16 @@ export interface PlatformQueueProducer { getQueue: () => PlatformQueue } + +/** + * Lifecycle event broadcast on the account.lifecycle topic when an admin + * disables or re-enables an account. Consumed by TSessionManager to fan + * out TxWorkspaceEvent.AccountDisabled to all workspaces hosting that + * account, which the client uses to force-logout the affected user. + */ +export interface QueueAccountLifecycleMessage { + accountUuid: AccountUuid + event: 'disabled' | 'enabled' + timestamp: number + reason?: string +} From eb545022dd93d606796519c6749820e667c8f8ba Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:45:07 +0000 Subject: [PATCH 016/177] feat(account): disableAccount endpoint (queue producer DI + token-version 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 --- .../src/__tests__/disableAccount.test.ts | 86 ++++++++++++++ server/account/src/operations.ts | 112 +++++++++++++++++- server/account/src/types.ts | 13 ++ 3 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 server/account/src/__tests__/disableAccount.test.ts diff --git a/server/account/src/__tests__/disableAccount.test.ts b/server/account/src/__tests__/disableAccount.test.ts new file mode 100644 index 00000000000..b50fdebabc4 --- /dev/null +++ b/server/account/src/__tests__/disableAccount.test.ts @@ -0,0 +1,86 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { MeasureContext } from '@hcengineering/core' +import { PlatformError } from '@hcengineering/platform' + +const ADMIN_TOKEN = 'admin-token' +const SELF_TOKEN = 'self-token' +const TARGET = '44444444-4444-4444-4444-444444444444' as any + +jest.mock('@hcengineering/server-token', () => ({ + decodeTokenVerbose: (ctx: any, token: string) => { + if (token === ADMIN_TOKEN) return { account: 'admin-uuid', extra: { admin: 'true' } } + if (token === SELF_TOKEN) return { account: TARGET, extra: { admin: 'true' } } + throw new Error('bad token') + }, + TokenError: class extends Error {} +})) + +const ctx = { newChild: () => ctx, info: () => {}, warn: () => {}, error: () => {} } as unknown as MeasureContext + +const mockDb = (account: any): any => ({ + account: { + findOne: async () => account, + update: async () => undefined + }, + socialId: { find: async () => [{ personUuid: TARGET, type: 'email', value: 'other@example.com' }] }, + adminAuditLog: { insert: async () => undefined } +}) + +import { disableAccount } from '../operations' + +describe('disableAccount', () => { + beforeEach(() => { + process.env.ADMIN_EMAILS = 'admin@example.com' + }) + + it('rejects when target is self', async () => { + const producer = { send: async () => undefined } as any + await expect( + disableAccount( + ctx, + mockDb({ uuid: TARGET, tokenVersion: 0 }), + null, + { accountLifecycleProducer: producer }, + SELF_TOKEN, + { accountUuid: TARGET } + ) + ).rejects.toThrow(PlatformError) + }) + + it('produces a lifecycle event on success', async () => { + const sentEvents: any[] = [] + const producer = { + send: async (...args: any[]) => { + sentEvents.push(args) + } + } as any + const res = await disableAccount( + ctx, + mockDb({ uuid: TARGET, tokenVersion: 0 }), + null, + { accountLifecycleProducer: producer }, + ADMIN_TOKEN, + { accountUuid: TARGET } + ) + expect(res).toEqual({ ok: true }) + expect(sentEvents.length).toBe(1) + const msgs = sentEvents[0][2] + expect(msgs[0].event).toBe('disabled') + expect(msgs[0].accountUuid).toBe(TARGET) + }) + + it('still succeeds when producer is undefined (token-version fallback)', async () => { + const res = await disableAccount( + ctx, + mockDb({ uuid: TARGET, tokenVersion: 0 }), + null, + {}, + ADMIN_TOKEN, + { accountUuid: TARGET } + ) + expect(res).toEqual({ ok: true }) + }) +}) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index c99c178a65e..0bd35912d79 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -37,7 +37,7 @@ import { type IntegrationKind } from '@hcengineering/core' import platform, { getMetadata, PlatformError, Severity, Status, translate } from '@hcengineering/platform' -import { decodeToken, decodeTokenVerbose, generateToken, type PermissionsGrant } from '@hcengineering/server-token' +import { decodeToken, decodeTokenVerbose, generateToken, type PermissionsGrant, TokenError } from '@hcengineering/server-token' import { isAdminEmail } from './admin' import { accountPlugin } from './plugin' @@ -47,6 +47,7 @@ import { type MailboxSecret, type AccountDB, type AccountMethodHandler, + type AccountMethodDeps, type LoginInfo, type LoginInfoWithWorkspaces, type Mailbox, @@ -3424,6 +3425,77 @@ export async function triggerPasswordReset ( return { ok: true, emailSentTo: emailSocial.value } } +function isLastAdmin (targetEmail: string): boolean { + const adminEmails = (process.env.ADMIN_EMAILS ?? '') + .split(',') + .map((e) => e.trim()) + .filter(Boolean) + return adminEmails.length === 1 && adminEmails[0] === targetEmail +} + +export async function disableAccount ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + deps: AccountMethodDeps, + token: string, + params: { accountUuid: AccountUuid } +): Promise<{ ok: true }> { + const { account: adminUuid, extra } = decodeTokenVerbose(ctx, token) + if (extra?.admin !== 'true') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + if (adminUuid === params.accountUuid) { + throw new PlatformError(new Status(Severity.ERROR, 'cannot_self_disable' as any, {})) + } + + const socials = await db.socialId.find({ personUuid: params.accountUuid }) + const targetEmail = socials.find((s) => s.type === SocialIdType.EMAIL)?.value + if (targetEmail != null && isLastAdmin(targetEmail)) { + throw new PlatformError(new Status(Severity.ERROR, 'last_admin' as any, {})) + } + + const account = await db.account.findOne({ uuid: params.accountUuid }) + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + } + const newVersion = (account.tokenVersion ?? 0) + 1 + await db.account.update( + { uuid: params.accountUuid }, + { disabledAt: Date.now(), tokenVersion: newVersion } + ) + await db.adminAuditLog.insert({ + adminAccount: adminUuid as AccountUuid, + targetAccount: params.accountUuid, + action: 'disable', + workspaceUuid: null, + details: { reason: 'manual_admin_action' } + }) + + if (deps.accountLifecycleProducer !== undefined) { + try { + await deps.accountLifecycleProducer.send( + ctx, + systemAccountUuid as unknown as WorkspaceUuid, + [ + { + accountUuid: params.accountUuid, + event: 'disabled', + timestamp: Date.now(), + reason: 'manual_admin_action' + } + ], + params.accountUuid + ) + } catch (err) { + ctx.warn('failed to emit account.lifecycle event; relying on token-version fallback', { err }) + } + } + + return { ok: true } +} + export type AccountMethods = | AccountServiceMethods | 'login' @@ -3504,11 +3576,46 @@ export type AccountMethods = | 'setWorkspaceMemberRole' | 'removeWorkspaceMember' | 'triggerPasswordReset' + | 'disableAccount' /** * @public */ -export function getMethods (hasSignUp: boolean = true): Partial> { +function wrapWithDeps< + F extends ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + deps: AccountMethodDeps, + ...args: any[] + ) => Promise +> (method: F, deps: AccountMethodDeps | undefined): AccountMethodHandler { + return async function (ctx, db, branding, request, token, meta) { + return await method(ctx, db, branding, deps ?? {}, token, { ...request.params }, meta) + .then((result) => ({ id: request.id, result })) + .catch((err: Error) => { + const status = + err instanceof PlatformError + ? err.status + : new Status(Severity.ERROR, platform.status.InternalServerError, {}) + if (err instanceof TokenError) { + return { error: new Status(Severity.ERROR, platform.status.Unauthorized, {}) } + } + if (status.code === platform.status.InternalServerError) { + Analytics.handleError(err) + ctx.error('Error while processing account method', { method: method.name, status, origErr: err }) + } else { + ctx.error('Error while processing account method', { method: method.name, status }) + } + return { error: status } + }) + } +} + +export function getMethods ( + hasSignUp: boolean = true, + deps?: AccountMethodDeps +): Partial> { return { /* OPERATIONS */ login: wrap(login), @@ -3575,6 +3682,7 @@ export function getMethods (hasSignUp: boolean = true): Partial, P socialIds: SocialId[] workspaces: Omit[] } + +/** + * Optional deps the account pod injects into a subset of method handlers. + * Currently the only consumer is disableAccount, which uses the + * accountLifecycleProducer to broadcast force-logout signals across pods. + * When undefined, disableAccount still bumps tokenVersion (which gives + * a slower but correct fallback path). + */ +export interface AccountMethodDeps { + accountLifecycleProducer?: { + send: (ctx: any, workspace: any, msgs: any[], partitionKey?: string) => Promise + } +} From b21372211db4d3d8825c6376627d0ac3e74f7fe1 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:46:02 +0000 Subject: [PATCH 017/177] feat(account): enableAccount endpoint (clear disabledAt + bump tokenVersion) 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 --- .../src/__tests__/enableAccount.test.ts | 39 +++++++++++++++++++ server/account/src/operations.ts | 31 +++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 server/account/src/__tests__/enableAccount.test.ts diff --git a/server/account/src/__tests__/enableAccount.test.ts b/server/account/src/__tests__/enableAccount.test.ts new file mode 100644 index 00000000000..6eb4a9cfa6f --- /dev/null +++ b/server/account/src/__tests__/enableAccount.test.ts @@ -0,0 +1,39 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +import { MeasureContext } from '@hcengineering/core' + +const ADMIN_TOKEN = 'admin-token' +const TARGET = '55555555-5555-5555-5555-555555555555' as any + +jest.mock('@hcengineering/server-token', () => ({ + decodeTokenVerbose: (ctx: any, token: string) => { + if (token === ADMIN_TOKEN) return { account: 'admin-uuid', extra: { admin: 'true' } } + throw new Error('bad token') + }, + TokenError: class extends Error {} +})) + +const ctx = { newChild: () => ctx, error: () => {} } as unknown as MeasureContext + +import { enableAccount } from '../operations' + +describe('enableAccount', () => { + it('clears disabledAt and bumps tokenVersion', async () => { + let updatedTo: any = null + const db = { + account: { + findOne: async () => ({ uuid: TARGET, disabledAt: 1700000000000, tokenVersion: 5 }), + update: async (q: any, ops: any) => { + updatedTo = ops + } + }, + adminAuditLog: { insert: async () => {} } + } as any + const res = await enableAccount(ctx, db, null, ADMIN_TOKEN, { accountUuid: TARGET }) + expect(res).toEqual({ ok: true }) + expect(updatedTo.disabledAt).toBeNull() + expect(updatedTo.tokenVersion).toBe(6) + }) +}) diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 0bd35912d79..7b1209b43ae 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -3496,6 +3496,35 @@ export async function disableAccount ( return { ok: true } } +export async function enableAccount ( + ctx: MeasureContext, + db: AccountDB, + branding: Branding | null, + token: string, + params: { accountUuid: AccountUuid } +): Promise<{ ok: true }> { + const { account: adminUuid, extra } = decodeTokenVerbose(ctx, token) + if (extra?.admin !== 'true') { + throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})) + } + + const account = await db.account.findOne({ uuid: params.accountUuid }) + if (account == null) { + throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})) + } + const newVersion = (account.tokenVersion ?? 0) + 1 + await db.account.update({ uuid: params.accountUuid }, { disabledAt: null, tokenVersion: newVersion }) + await db.adminAuditLog.insert({ + adminAccount: adminUuid as AccountUuid, + targetAccount: params.accountUuid, + action: 'enable', + workspaceUuid: null, + details: null + }) + + return { ok: true } +} + export type AccountMethods = | AccountServiceMethods | 'login' @@ -3577,6 +3606,7 @@ export type AccountMethods = | 'removeWorkspaceMember' | 'triggerPasswordReset' | 'disableAccount' + | 'enableAccount' /** * @public @@ -3683,6 +3713,7 @@ export function getMethods ( removeWorkspaceMember: wrap(removeWorkspaceMember), triggerPasswordReset: wrap(triggerPasswordReset), disableAccount: wrapWithDeps(disableAccount, deps), + enableAccount: wrap(enableAccount), /* READ OPERATIONS */ getRegionInfo: wrap(getRegionInfo), From 2bef21b01b36bf6727f8c6cafe52c09cf0dbb94d Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:48:58 +0000 Subject: [PATCH 018/177] feat(account-service): serveAccount(deps) + account-pod queue producer 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 --- common/config/rush/pnpm-lock.yaml | 3 +++ pods/account/package.json | 3 ++- pods/account/src/__start.ts | 35 +++++++++++++++++++++++++++-- server/account-service/src/index.ts | 10 +++++++-- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 50d93a129d5..a4c15623a4d 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -30149,6 +30149,9 @@ importers: '@hcengineering/core': specifier: workspace:^0.7.26 version: link:../../foundations/core/packages/core + '@hcengineering/kafka': + specifier: workspace:^0.7.18 + version: link:../../foundations/server/packages/kafka '@hcengineering/platform': specifier: workspace:^0.7.20 version: link:../../foundations/core/packages/platform diff --git a/pods/account/package.json b/pods/account/package.json index e79ca0688f6..659823b7798 100644 --- a/pods/account/package.json +++ b/pods/account/package.json @@ -65,6 +65,7 @@ "@hcengineering/server-token": "workspace:^0.7.18", "@hcengineering/server-core": "workspace:^0.7.19", "@hcengineering/analytics": "workspace:^0.7.19", - "@hcengineering/analytics-service": "workspace:^0.7.19" + "@hcengineering/analytics-service": "workspace:^0.7.19", + "@hcengineering/kafka": "workspace:^0.7.18" } } diff --git a/pods/account/src/__start.ts b/pods/account/src/__start.ts index c629131d534..7d0a0282c7f 100644 --- a/pods/account/src/__start.ts +++ b/pods/account/src/__start.ts @@ -5,7 +5,14 @@ import { serveAccount } from '@hcengineering/account-service' import { Analytics } from '@hcengineering/analytics' import { configureAnalytics, createOpenTelemetryMetricsContext, SplitLogger } from '@hcengineering/analytics-service' import { newMetrics } from '@hcengineering/core' -import { initStatisticsContext, loadBrandingMap } from '@hcengineering/server-core' +import { + initStatisticsContext, + loadBrandingMap, + type PlatformQueue, + type PlatformQueueProducer, + type QueueAccountLifecycleMessage +} from '@hcengineering/server-core' +import { getPlatformQueue } from '@hcengineering/kafka' import { join } from 'path' configureAnalytics('account', process.env.VERSION ?? '0.7.0') @@ -27,4 +34,28 @@ const metricsContext = initStatisticsContext('account', { const brandingPath = process.env.BRANDING_PATH -serveAccount(metricsContext, loadBrandingMap(brandingPath), () => {}) +const queueConfig = process.env.QUEUE_CONFIG +let queue: PlatformQueue | undefined +let accountLifecycleProducer: PlatformQueueProducer | undefined + +if (queueConfig != null && queueConfig.trim() !== '') { + try { + const region = process.env.REGION + queue = getPlatformQueue('account', region) + accountLifecycleProducer = queue.getProducer( + metricsContext.newChild('account-lifecycle-producer', {}), + 'account.lifecycle' + ) + } catch (err) { + metricsContext.warn('account.lifecycle queue producer unavailable; relying on token-version fallback', { err }) + queue = undefined + accountLifecycleProducer = undefined + } +} + +serveAccount(metricsContext, loadBrandingMap(brandingPath), { accountLifecycleProducer }, () => { + void accountLifecycleProducer?.close() + if (queue !== undefined) { + void queue.shutdown() + } +}) diff --git a/server/account-service/src/index.ts b/server/account-service/src/index.ts index 188d2caf814..cbdb6320408 100644 --- a/server/account-service/src/index.ts +++ b/server/account-service/src/index.ts @@ -4,6 +4,7 @@ import account, { type AccountMethods, + type AccountMethodDeps, type Meta, type ClientNetworkPosition, EndpointKind, @@ -43,7 +44,12 @@ const KEEP_ALIVE_HEADERS = { /** * @public */ -export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap, onClose?: () => void): void { +export function serveAccount ( + measureCtx: MeasureContext, + brandings: BrandingMap, + deps?: AccountMethodDeps, + onClose?: () => void +): void { console.log('Starting account service with brandings: ', brandings) const ACCOUNT_PORT = parseInt(process.env.ACCOUNT_PORT ?? '3000') const dbUrl = process.env.DB_URL @@ -135,7 +141,7 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap setMetadata(serverToken.metadata.Service, undefined) const hasSignUp = process.env.DISABLE_SIGNUP !== 'true' - const methods = getMethods(hasSignUp) + const methods = getMethods(hasSignUp, deps) const dbNs = process.env.DB_NS const accountsDb = getAccountDB(dbUrl, dbNs) From be838f4c6784e71d2957d793752b3472fc1dde51 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:50:35 +0000 Subject: [PATCH 019/177] feat(server): TSessionManager.accountLifecycleConsumer broadcasts AccountDisabled 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 --- .../packages/server/src/sessionManager.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/foundations/server/packages/server/src/sessionManager.ts b/foundations/server/packages/server/src/sessionManager.ts index 55dfa351f84..bf5001b9aeb 100644 --- a/foundations/server/packages/server/src/sessionManager.ts +++ b/foundations/server/packages/server/src/sessionManager.ts @@ -77,6 +77,7 @@ import { type QueueUserMessage, QueueWorkspaceEvent, type QueueWorkspaceMessage, + type QueueAccountLifecycleMessage, type Session, type SessionHealth, type SessionManager, @@ -125,6 +126,7 @@ export class TSessionManager implements SessionManager { workspaceProducer: PlatformQueueProducer usersProducer: PlatformQueueProducer workspaceConsumer: ConsumerHandle + accountLifecycleConsumer: ConsumerHandle now: number = Date.now() @@ -176,6 +178,47 @@ export class TSessionManager implements SessionManager { } ) + this.accountLifecycleConsumer = this.queue.createConsumer( + ctx.newChild('account-lifecycle-consume', {}, { span: false }), + 'account.lifecycle', + generateId(), + async (ctx, msg) => { + const m = msg.value + if (m.event !== 'disabled') return + + const matching = Array.from(this.sessions.entries()).filter( + ([, entry]) => entry.session.getUser() === m.accountUuid + ) + if (matching.length === 0) return + + const tx: TxWorkspaceEvent = { + _id: generateId(), + _class: core.class.TxWorkspaceEvent, + event: WorkspaceEvent.AccountDisabled, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + objectSpace: core.space.DerivedTx, + space: core.space.DerivedTx, + createdBy: core.account.System, + params: { reason: m.reason ?? 'manual_admin_action' } + } + + const sessionIdMap: Record = {} + for (const [, entry] of matching) { + sessionIdMap[entry.session.sessionId] = [tx] + } + this.broadcastSessions(ctx, sessionIdMap) + + for (const [, entry] of matching) { + try { + await entry.socket.close() + } catch (err) { + ctx.warn('failed to close socket for disabled account', { err }) + } + } + } + ) + this.ticksContext = ctx.newChild('ticks', {}, { span: false }) } From 8507f5e21f62983c1c0948805a50bd9a67e9f76e Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:55:00 +0000 Subject: [PATCH 020/177] feat(client-resources+login-resources): detect AccountDisabled Tx + force-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 --- common/config/rush/pnpm-lock.yaml | 3 ++ .../client-resources/src/connection.ts | 35 +++++++++++++++++++ .../packages/client-resources/src/index.ts | 4 +-- plugins/login-resources/package.json | 3 +- plugins/login-resources/src/index.ts | 9 +++++ plugins/login-resources/src/utils.ts | 12 +++++++ 6 files changed, 63 insertions(+), 3 deletions(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a4c15623a4d..e11dc1d0c50 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -22904,6 +22904,9 @@ importers: '@hcengineering/analytics-providers': specifier: workspace:^0.7.0 version: link:../../packages/analytics-providers + '@hcengineering/client-resources': + specifier: workspace:^0.7.0 + version: link:../../foundations/core/packages/client-resources '@hcengineering/core': specifier: workspace:^0.7.26 version: link:../../foundations/core/packages/core diff --git a/foundations/core/packages/client-resources/src/connection.ts b/foundations/core/packages/client-resources/src/connection.ts index 0309ad7abe6..b6dd799ec21 100644 --- a/foundations/core/packages/client-resources/src/connection.ts +++ b/foundations/core/packages/client-resources/src/connection.ts @@ -53,6 +53,8 @@ import core, { TxApplyIf, TxHandler, TxResult, + type TxWorkspaceEvent, + WorkspaceEvent, type WorkspaceUuid } from '@hcengineering/core' import platform, { getMetadata, PlatformError, Severity, Status } from '@hcengineering/platform' @@ -64,6 +66,25 @@ const pingTimeout = 10 * SECOND const hangTimeout = 5 * 60 * SECOND const dialTimeout = 30 * SECOND +// Module-private force-logout handler registry. Set by login-resources via the +// re-exported setForceLogoutHandler. Called from the Tx-dispatch when an +// AccountDisabled TxWorkspaceEvent arrives. +let forceLogoutHandlerInternal: ((reason: string) => void) | null = null + +export function setForceLogoutHandler (handler: (reason: string) => void): void { + forceLogoutHandlerInternal = handler +} + +function notifyForceLogout (reason: string): void { + if (forceLogoutHandlerInternal != null) { + try { + forceLogoutHandlerInternal(reason) + } catch (err) { + console.error('force-logout handler threw', err) + } + } +} + class RequestPromise { startTime: number = Date.now() handleTime?: (diff: number, result: any, serverTime: number, queue: number, toRecieve: number) => void @@ -118,6 +139,8 @@ class Connection implements ClientConnection { private helloReceived: boolean = false + private forcedLogoutReason: string | null = null + private account: Account | undefined onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise @@ -495,6 +518,15 @@ class Connection implements ClientConnection { this.opt?.onUpgrade?.() return } + if ( + tx?._class === core.class.TxWorkspaceEvent && + (tx as TxWorkspaceEvent).event === WorkspaceEvent.AccountDisabled + ) { + const params = (tx as TxWorkspaceEvent).params as { reason?: string } | undefined + const reason = params?.reason ?? 'account_disabled' + this.forcedLogoutReason = reason + notifyForceLogout(reason) + } } this.handlers.forEach((handler) => { handler(...txArr) @@ -635,6 +667,9 @@ class Connection implements ClientConnection { wsocket.close() return } + if (this.forcedLogoutReason != null) { + return + } this.scheduleOpen(this.ctx, true) } wsocket.onopen = () => { diff --git a/foundations/core/packages/client-resources/src/index.ts b/foundations/core/packages/client-resources/src/index.ts index 9d126e89878..f81bc0d6199 100644 --- a/foundations/core/packages/client-resources/src/index.ts +++ b/foundations/core/packages/client-resources/src/index.ts @@ -40,9 +40,9 @@ import core, { ClientConnectEvent } from '@hcengineering/core' import platform, { Severity, Status, getMetadata, getPlugins, setPlatformStatus } from '@hcengineering/platform' -import { connect } from './connection' +import { connect, setForceLogoutHandler } from './connection' -export { connect } +export { connect, setForceLogoutHandler } let dbRequest: IDBOpenDBRequest | undefined let dbPromise: Promise = Promise.resolve(undefined) diff --git a/plugins/login-resources/package.json b/plugins/login-resources/package.json index 97ddd21f2de..9a68b7449db 100644 --- a/plugins/login-resources/package.json +++ b/plugins/login-resources/package.json @@ -51,6 +51,7 @@ "@hcengineering/theme": "workspace:^0.7.0", "@hcengineering/analytics": "workspace:^0.7.19", "@hcengineering/account-client": "workspace:^0.7.25", - "@hcengineering/analytics-providers": "workspace:^0.7.0" + "@hcengineering/analytics-providers": "workspace:^0.7.0", + "@hcengineering/client-resources": "workspace:^0.7.0" } } diff --git a/plugins/login-resources/src/index.ts b/plugins/login-resources/src/index.ts index 8e7a89377d6..2b58e8bece2 100644 --- a/plugins/login-resources/src/index.ts +++ b/plugins/login-resources/src/index.ts @@ -84,3 +84,12 @@ export interface BottomAction { } export * from './utils' + +import { setForceLogoutHandler } from '@hcengineering/client-resources' +import { forceLogoutReason } from './utils' + +// Bridge the connection-layer force-logout signal into the Svelte store +// consumed by LoginApp.svelte / ForceLogoutModal.svelte. +setForceLogoutHandler((reason: string) => { + forceLogoutReason.set(reason) +}) diff --git a/plugins/login-resources/src/utils.ts b/plugins/login-resources/src/utils.ts index 42e5735df91..0f460f63fc2 100644 --- a/plugins/login-resources/src/utils.ts +++ b/plugins/login-resources/src/utils.ts @@ -1145,3 +1145,15 @@ export function getAccountDisplayName (loginInfo: LoginInfo | null | undefined): return loginInfo.account } + +import { writable, type Writable } from 'svelte/store' + +/** + * Cross-component force-logout signal. Set by client-resources via the + * setForceLogoutHandler bridge wired in index.ts. Read by LoginApp.svelte + * to display the ForceLogoutModal. + * + * null = no force-logout active + * string = the reason ('account_disabled', 'manual_admin_action', etc.) + */ +export const forceLogoutReason: Writable = writable(null) From f79169e617bc2e4af61b3a308db8706db0bfda4e Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 15:57:49 +0000 Subject: [PATCH 021/177] feat(login-resources): ForceLogoutModal component + mount in LoginApp 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 --- plugins/login-assets/lang/de.json | 5 +- plugins/login-assets/lang/en.json | 3 + .../src/components/ForceLogoutModal.svelte | 55 +++++++++++++++++++ .../src/components/LoginApp.svelte | 6 ++ plugins/login/src/index.ts | 5 +- 5 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 plugins/login-resources/src/components/ForceLogoutModal.svelte diff --git a/plugins/login-assets/lang/de.json b/plugins/login-assets/lang/de.json index 55d71e84e12..768007a1873 100644 --- a/plugins/login-assets/lang/de.json +++ b/plugins/login-assets/lang/de.json @@ -102,6 +102,9 @@ "SendSetupLink": "Setup-Link senden", "SSOPasswordEmailSent": "Überprüfen Sie Ihre E-Mail auf einen Link zum Festlegen des Passworts.", "SSONoEmailLinked": "Keine E-Mail-Adresse mit Ihrem Konto verknüpft. Fügen Sie eine unter Kontoeinstellungen → Identitäten verwalten hinzu.", - "CreateSampleProjects": "Beispielprojekte und Demo-Inhalte erstellen" + "CreateSampleProjects": "Beispielprojekte und Demo-Inhalte erstellen", + "AccountDisabledTitle": "Konto deaktiviert", + "AccountDisabledBody": "Ihr Konto wurde von einem Administrator deaktiviert. Bitte wenden Sie sich an Ihren Administrator, falls Sie dies fuer fehlerhaft halten.", + "SignOut": "Abmelden" } } diff --git a/plugins/login-assets/lang/en.json b/plugins/login-assets/lang/en.json index 0d4a6fd1bc4..c8ca3ce36f7 100644 --- a/plugins/login-assets/lang/en.json +++ b/plugins/login-assets/lang/en.json @@ -101,6 +101,9 @@ "EnterTwoFactorCode": "Enter two-factor authentication code", "TwoFactorCode": "Two-factor authentication code", "Verify": "Verify", + "AccountDisabledTitle": "Account disabled", + "AccountDisabledBody": "Your account has been disabled by an administrator. Please contact your administrator if you believe this is in error.", + "SignOut": "Sign out", "CreateSampleProjects": "Create sample projects and demo content" } } diff --git a/plugins/login-resources/src/components/ForceLogoutModal.svelte b/plugins/login-resources/src/components/ForceLogoutModal.svelte new file mode 100644 index 00000000000..6b1e32b2298 --- /dev/null +++ b/plugins/login-resources/src/components/ForceLogoutModal.svelte @@ -0,0 +1,55 @@ + + + + + + diff --git a/plugins/login-resources/src/components/LoginApp.svelte b/plugins/login-resources/src/components/LoginApp.svelte index 2af6f572bd3..8bc2bf8d963 100644 --- a/plugins/login-resources/src/components/LoginApp.svelte +++ b/plugins/login-resources/src/components/LoginApp.svelte @@ -54,6 +54,8 @@ import loginBack2xWebp from '../../img/login_back_2x.webp' import AdminWorkspaces from './AdminWorkspaces.svelte' import ChangePassword from './ChangePassword.svelte' + import ForceLogoutModal from './ForceLogoutModal.svelte' + import { forceLogoutReason } from '../utils' export let page: Pages = 'signup' @@ -122,6 +124,10 @@ onMount(chooseToken) +{#if $forceLogoutReason != null} + +{/if} + {#if page === 'admin'} {:else} diff --git a/plugins/login/src/index.ts b/plugins/login/src/index.ts index 4182d93ed9f..3ee1a4e76a0 100644 --- a/plugins/login/src/index.ts +++ b/plugins/login/src/index.ts @@ -102,7 +102,10 @@ export default plugin(loginId, { TwoFactorAuth: '' as IntlString, EnterTwoFactorCode: '' as IntlString, TwoFactorCode: '' as IntlString, - Verify: '' as IntlString + Verify: '' as IntlString, + AccountDisabledTitle: '' as IntlString, + AccountDisabledBody: '' as IntlString, + SignOut: '' as IntlString }, function: { SendInvite: '' as Resource<(email: string, role: AccountRole) => Promise>, From 2b0ed151c28ede141e0b65c6489b8aaec6afdf25 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 16:00:18 +0000 Subject: [PATCH 022/177] feat(login-resources): /login/admin/users sub-route + AdminUsers placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/AdminUsers.svelte | 21 +++++++++++++++++++ .../src/components/LoginApp.svelte | 10 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 plugins/login-resources/src/components/AdminUsers.svelte diff --git a/plugins/login-resources/src/components/AdminUsers.svelte b/plugins/login-resources/src/components/AdminUsers.svelte new file mode 100644 index 00000000000..6bb94a853da --- /dev/null +++ b/plugins/login-resources/src/components/AdminUsers.svelte @@ -0,0 +1,21 @@ + + + +
+

Admin: Users

+

Coming soon — full user-management UI under construction.

+
+ + diff --git a/plugins/login-resources/src/components/LoginApp.svelte b/plugins/login-resources/src/components/LoginApp.svelte index 8bc2bf8d963..a59b63ee4cb 100644 --- a/plugins/login-resources/src/components/LoginApp.svelte +++ b/plugins/login-resources/src/components/LoginApp.svelte @@ -53,9 +53,13 @@ import loginBackWebp from '../../img/login_back.webp' import loginBack2xWebp from '../../img/login_back_2x.webp' import AdminWorkspaces from './AdminWorkspaces.svelte' + import AdminUsers from './AdminUsers.svelte' import ChangePassword from './ChangePassword.svelte' import ForceLogoutModal from './ForceLogoutModal.svelte' import { forceLogoutReason } from '../utils' + import { location as locationStore } from '@hcengineering/ui' + + $: subPath = $locationStore.path[2] export let page: Pages = 'signup' @@ -129,7 +133,11 @@ {/if} {#if page === 'admin'} - + {#if subPath === 'users'} + + {:else} + + {/if} {:else}
Date: Sat, 23 May 2026 16:01:54 +0000 Subject: [PATCH 023/177] feat(account-client): interface + RPC stubs for admin user management 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 --- .../packages/account-client/src/client.ts | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index 0a15fa45ed4..1015a4f96c8 100644 --- a/foundations/core/packages/account-client/src/client.ts +++ b/foundations/core/packages/account-client/src/client.ts @@ -57,7 +57,10 @@ import type { UserProfile, WorkspaceConfiguration, WorkspaceLoginInfo, - WorkspaceOperation + WorkspaceOperation, + ListAccountsAdminParams, + AccountListRow, + AccountDetailsResponse } from './types' import { getClientTimezone, isNetworkError } from './utils' @@ -267,6 +270,22 @@ export interface AccountClient { generate2faSecret: () => Promise<{ secret: string, url: string }> enable2fa: (secret: string, code: string) => Promise disable2fa: (code: string) => Promise + + // Admin user management (V27) + listAccountsAdmin: (params: ListAccountsAdminParams) => Promise<{ total: number, accounts: AccountListRow[] }> + getAccountDetails: (accountUuid: AccountUuid) => Promise + setWorkspaceMemberRole: ( + accountUuid: AccountUuid, + workspaceUuid: WorkspaceUuid, + newRole: AccountRole + ) => Promise<{ ok: true }> + removeWorkspaceMember: ( + accountUuid: AccountUuid, + workspaceUuid: WorkspaceUuid + ) => Promise<{ ok: true, wasMember: boolean }> + triggerPasswordReset: (accountUuid: AccountUuid) => Promise<{ ok: true, emailSentTo: string }> + disableAccount: (accountUuid: AccountUuid) => Promise<{ ok: true }> + enableAccount: (accountUuid: AccountUuid) => Promise<{ ok: true }> } /** @public */ @@ -1386,6 +1405,56 @@ class AccountClientImpl implements AccountClient { await this.rpc(request) } + + async listAccountsAdmin ( + params: ListAccountsAdminParams + ): Promise<{ total: number, accounts: AccountListRow[] }> { + const request = { method: 'listAccountsAdmin' as const, params } + return await this.rpc(request) + } + + async getAccountDetails (accountUuid: AccountUuid): Promise { + const request = { method: 'getAccountDetails' as const, params: { accountUuid } } + return await this.rpc(request) + } + + async setWorkspaceMemberRole ( + accountUuid: AccountUuid, + workspaceUuid: WorkspaceUuid, + newRole: AccountRole + ): Promise<{ ok: true }> { + const request = { + method: 'setWorkspaceMemberRole' as const, + params: { accountUuid, workspaceUuid, newRole } + } + return await this.rpc(request) + } + + async removeWorkspaceMember ( + accountUuid: AccountUuid, + workspaceUuid: WorkspaceUuid + ): Promise<{ ok: true, wasMember: boolean }> { + const request = { + method: 'removeWorkspaceMember' as const, + params: { accountUuid, workspaceUuid } + } + return await this.rpc(request) + } + + async triggerPasswordReset (accountUuid: AccountUuid): Promise<{ ok: true, emailSentTo: string }> { + const request = { method: 'triggerPasswordReset' as const, params: { accountUuid } } + return await this.rpc(request) + } + + async disableAccount (accountUuid: AccountUuid): Promise<{ ok: true }> { + const request = { method: 'disableAccount' as const, params: { accountUuid } } + return await this.rpc(request) + } + + async enableAccount (accountUuid: AccountUuid): Promise<{ ok: true }> { + const request = { method: 'enableAccount' as const, params: { accountUuid } } + return await this.rpc(request) + } } function withRetry Promise> ( From 2f5f39107eacbf015f151716b41e221c6cc8a00f Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 16:03:56 +0000 Subject: [PATCH 024/177] =?UTF-8?q?feat(login-resources):=20AdminUsers=20p?= =?UTF-8?q?age=20=E2=80=94=20filter=20bar=20+=20sortable=20table=20+=20pag?= =?UTF-8?q?ination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/AdminUsers.svelte | 93 ++++++++++++++++++- .../admin-users/AdminUsersDrawer.svelte | 56 +++++++++++ .../admin-users/AdminUsersFilterBar.svelte | 78 ++++++++++++++++ .../admin-users/AdminUsersPagination.svelte | 52 +++++++++++ .../admin-users/AdminUsersRow.svelte | 73 +++++++++++++++ .../admin-users/AdminUsersTable.svelte | 78 ++++++++++++++++ 6 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 plugins/login-resources/src/components/admin-users/AdminUsersDrawer.svelte create mode 100644 plugins/login-resources/src/components/admin-users/AdminUsersFilterBar.svelte create mode 100644 plugins/login-resources/src/components/admin-users/AdminUsersPagination.svelte create mode 100644 plugins/login-resources/src/components/admin-users/AdminUsersRow.svelte create mode 100644 plugins/login-resources/src/components/admin-users/AdminUsersTable.svelte diff --git a/plugins/login-resources/src/components/AdminUsers.svelte b/plugins/login-resources/src/components/AdminUsers.svelte index 6bb94a853da..f1fcc3b4c07 100644 --- a/plugins/login-resources/src/components/AdminUsers.svelte +++ b/plugins/login-resources/src/components/AdminUsers.svelte @@ -2,20 +2,109 @@ // Copyright © 2026 Hardcore Engineering Inc. -->
-

Admin: Users

-

Coming soon — full user-management UI under construction.

+
+

Users

+
+ + + + + + + + {#if selectedUuid != null} + + {/if}
diff --git a/plugins/login-resources/src/components/admin-users/AdminUsersDrawer.svelte b/plugins/login-resources/src/components/admin-users/AdminUsersDrawer.svelte new file mode 100644 index 00000000000..c128e988610 --- /dev/null +++ b/plugins/login-resources/src/components/admin-users/AdminUsersDrawer.svelte @@ -0,0 +1,56 @@ + + + + + + diff --git a/plugins/login-resources/src/components/admin-users/AdminUsersFilterBar.svelte b/plugins/login-resources/src/components/admin-users/AdminUsersFilterBar.svelte new file mode 100644 index 00000000000..ac66439a616 --- /dev/null +++ b/plugins/login-resources/src/components/admin-users/AdminUsersFilterBar.svelte @@ -0,0 +1,78 @@ + + + +
+ + + +
+ + diff --git a/plugins/login-resources/src/components/admin-users/AdminUsersPagination.svelte b/plugins/login-resources/src/components/admin-users/AdminUsersPagination.svelte new file mode 100644 index 00000000000..c4781d01012 --- /dev/null +++ b/plugins/login-resources/src/components/admin-users/AdminUsersPagination.svelte @@ -0,0 +1,52 @@ + + + + + + diff --git a/plugins/login-resources/src/components/admin-users/AdminUsersRow.svelte b/plugins/login-resources/src/components/admin-users/AdminUsersRow.svelte new file mode 100644 index 00000000000..f0b78059250 --- /dev/null +++ b/plugins/login-resources/src/components/admin-users/AdminUsersRow.svelte @@ -0,0 +1,73 @@ + + + + + + {account.firstName} {account.lastName} + {#if account.isAdmin} + Admin + {/if} + + {account.primaryEmail ?? '—'} + + {#each account.authMethods as m} + {m} + {/each} + + {account.workspaceCount} + + {formatLastActivity(account.lastActivityAt)} + + + {account.status} + + + + diff --git a/plugins/login-resources/src/components/admin-users/AdminUsersTable.svelte b/plugins/login-resources/src/components/admin-users/AdminUsersTable.svelte new file mode 100644 index 00000000000..cd3228499c3 --- /dev/null +++ b/plugins/login-resources/src/components/admin-users/AdminUsersTable.svelte @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + {#if loading} + + {:else if accounts.length === 0} + + {:else} + {#each accounts as account (account.uuid)} + onRowClick(account.uuid)} /> + {/each} + {/if} + +
setSort('name')}>Name{sortIndicator('name')}EmailAuth setSort('workspace_count')}>Workspaces{sortIndicator('workspace_count')} setSort('last_activity')}>Last activity{sortIndicator('last_activity')}Status
Loading...
No users match your filters.
+ + From aeae3e1cab35df72cb580eb28456593be89055cd Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sat, 23 May 2026 16:04:56 +0000 Subject: [PATCH 025/177] =?UTF-8?q?feat(login-resources):=20AdminUsersDraw?= =?UTF-8?q?er=20=E2=80=94=20detail=20view=20+=20admin=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../admin-users/AdminUsersDrawer.svelte | 267 ++++++++++++++++-- 1 file changed, 250 insertions(+), 17 deletions(-) diff --git a/plugins/login-resources/src/components/admin-users/AdminUsersDrawer.svelte b/plugins/login-resources/src/components/admin-users/AdminUsersDrawer.svelte index c128e988610..20968f4810c 100644 --- a/plugins/login-resources/src/components/admin-users/AdminUsersDrawer.svelte +++ b/plugins/login-resources/src/components/admin-users/AdminUsersDrawer.svelte @@ -1,28 +1,194 @@ +