diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 928464ae944..eadd0360db3 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -22907,6 +22907,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 @@ -30152,6 +30155,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 @@ -35782,6 +35788,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 @@ -35876,6 +35885,9 @@ importers: '@hcengineering/account': specifier: workspace:^0.7.0 version: link:../account + '@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/common/scripts/version.txt b/common/scripts/version.txt index b8caacae731..af1137434f7 100644 --- a/common/scripts/version.txt +++ b/common/scripts/version.txt @@ -1 +1 @@ -"0.7.422" +"0.7.423" diff --git a/dev/prod/src/index.ejs b/dev/prod/src/index.ejs index 96a11cc5810..e63caccae98 100644 --- a/dev/prod/src/index.ejs +++ b/dev/prod/src/index.ejs @@ -1,5 +1,5 @@ - + diff --git a/dev/prod/webpack.config.js b/dev/prod/webpack.config.js index 54298c063c1..a821ccda925 100644 --- a/dev/prod/webpack.config.js +++ b/dev/prod/webpack.config.js @@ -490,18 +490,24 @@ module.exports = [ hot: true, client: { logging: 'info', - overlay: { - errors: true, - warnings: false, - runtimeErrors: (error) => { - if (error.message.includes('ResizeObserver')) { - return false + overlay: false, + progress: false, + // When the dev server runs behind a reverse proxy terminating TLS on 443 + // (e.g. dev.example.com -> nginx -> :8080), the default client points + // at the internal 8080 port and the HMR WebSocket breaks. + // Set HULY_DEV_PUBLIC_HOST= to point the wss:// URL at the proxy. + ...(process.env.HULY_DEV_PUBLIC_HOST != null + ? { + webSocketURL: { + hostname: process.env.HULY_DEV_PUBLIC_HOST, + pathname: '/ws', + port: 443, + protocol: 'wss' + } } - return true - } - }, - progress: false + : {}) }, + webSocketServer: 'ws', proxy: proxy[clientType] } } diff --git a/foundations/core/packages/account-client/src/__tests__/csv.test.ts b/foundations/core/packages/account-client/src/__tests__/csv.test.ts new file mode 100644 index 00000000000..95b9f025422 --- /dev/null +++ b/foundations/core/packages/account-client/src/__tests__/csv.test.ts @@ -0,0 +1,36 @@ +import { csvEscape, csvLine } from '../util/csv' + +describe('csvEscape', () => { + it('returns empty string for null/undefined', () => { + expect(csvEscape(null)).toBe('') + expect(csvEscape(undefined)).toBe('') + }) + it('returns plain string unchanged when no quoting is needed', () => { + expect(csvEscape('hello')).toBe('hello') + expect(csvEscape(42)).toBe('42') + }) + it('quotes when the value contains a comma', () => { + expect(csvEscape('a, b')).toBe('"a, b"') + }) + it('quotes when the value contains a double quote and doubles it', () => { + expect(csvEscape('he said "hi"')).toBe('"he said ""hi"""') + }) + it('quotes when the value contains a newline', () => { + expect(csvEscape('line1\nline2')).toBe('"line1\nline2"') + }) + it('quotes when the value contains a carriage return', () => { + expect(csvEscape('line1\rline2')).toBe('"line1\rline2"') + }) + it('quotes when the value contains CRLF', () => { + expect(csvEscape('line1\r\nline2')).toBe('"line1\r\nline2"') + }) +}) + +describe('csvLine', () => { + it('joins values with commas and appends RFC-4180 CRLF', () => { + expect(csvLine(['a', 1, 'b'])).toBe('a,1,b\r\n') + }) + it('escapes each value individually', () => { + expect(csvLine(['a,b', 'c'])).toBe('"a,b",c\r\n') + }) +}) diff --git a/foundations/core/packages/account-client/src/client.ts b/foundations/core/packages/account-client/src/client.ts index 0a15fa45ed4..be6d927ae2b 100644 --- a/foundations/core/packages/account-client/src/client.ts +++ b/foundations/core/packages/account-client/src/client.ts @@ -57,7 +57,17 @@ import type { UserProfile, WorkspaceConfiguration, WorkspaceLoginInfo, - WorkspaceOperation + WorkspaceOperation, + ListAccountsAdminParams, + AccountListRow, + AccountDetailsResponse, + AddWorkspaceMemberParams, + WorkspaceMembersAdminResponse, + BulkResult, + CreateAccountParams, + CreateAccountResponse, + ListAuditAdminParams, + ListAuditAdminResponse } from './types' import { getClientTimezone, isNetworkError } from './utils' @@ -170,7 +180,6 @@ export interface AccountClient { getMailboxes: () => Promise deleteMailbox: (mailbox: string) => Promise listAccounts: (search?: string, skip?: number, limit?: number) => Promise - deleteAccount: (uuid: AccountUuid) => Promise workerHandshake: (region: string, version: Data, operation: WorkspaceOperation) => Promise getPendingWorkspace: ( @@ -267,6 +276,35 @@ 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 + listAuditAdmin: (params: ListAuditAdminParams) => 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 }> + addWorkspaceMember: (params: AddWorkspaceMemberParams) => Promise + getWorkspaceMembersAdmin: (workspaceUuid: WorkspaceUuid) => Promise + createAccountAdmin: (params: CreateAccountParams) => Promise + bulkAddToWorkspace: ( + accountUuids: AccountUuid[], + workspaceUuid: WorkspaceUuid, + role: AccountRole + ) => Promise + bulkRemoveFromWorkspace: (accountUuids: AccountUuid[], workspaceUuid: WorkspaceUuid) => Promise + bulkSetDisabled: (accountUuids: AccountUuid[], disabled: boolean) => Promise + bulkSendPasswordReset: (accountUuids: AccountUuid[]) => Promise + getAdminEmails: () => Promise<{ emails: string[] }> } /** @public */ @@ -1057,15 +1095,6 @@ class AccountClientImpl implements AccountClient { return await this.rpc(request) } - async deleteAccount (uuid: AccountUuid): Promise { - const request = { - method: 'deleteAccount' as const, - params: { uuid } - } - - await this.rpc(request) - } - async releaseSocialId ( personUuid: PersonUuid | undefined, type: SocialIdType, @@ -1386,6 +1415,109 @@ 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 listAuditAdmin (params: ListAuditAdminParams): Promise { + const request = { method: 'listAuditAdmin' as const, params } + 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) + } + + async addWorkspaceMember (params: AddWorkspaceMemberParams): Promise { + const request = { method: 'addWorkspaceMember' as const, params } + return await this.rpc(request) + } + + async getWorkspaceMembersAdmin (workspaceUuid: WorkspaceUuid): Promise { + const request = { method: 'getWorkspaceMembersAdmin' as const, params: { workspaceUuid } } + return await this.rpc(request) + } + + async createAccountAdmin (params: CreateAccountParams): Promise { + const request = { method: 'createAccountAdmin' as const, params } + return await this.rpc(request) + } + + async bulkAddToWorkspace ( + accountUuids: AccountUuid[], + workspaceUuid: WorkspaceUuid, + role: AccountRole + ): Promise { + const request = { + method: 'bulkAddToWorkspace' as const, + params: { accountUuids, workspaceUuid, role } + } + return await this.rpc(request) + } + + async bulkRemoveFromWorkspace (accountUuids: AccountUuid[], workspaceUuid: WorkspaceUuid): Promise { + const request = { + method: 'bulkRemoveFromWorkspace' as const, + params: { accountUuids, workspaceUuid } + } + return await this.rpc(request) + } + + async bulkSetDisabled (accountUuids: AccountUuid[], disabled: boolean): Promise { + const request = { method: 'bulkSetDisabled' as const, params: { accountUuids, disabled } } + return await this.rpc(request) + } + + async bulkSendPasswordReset (accountUuids: AccountUuid[]): Promise { + const request = { method: 'bulkSendPasswordReset' as const, params: { accountUuids } } + return await this.rpc(request) + } + + async getAdminEmails (): Promise<{ emails: string[] }> { + const request = { method: 'getAdminEmails' as const, params: {} } + return await this.rpc(request) + } } function withRetry Promise> ( diff --git a/foundations/core/packages/account-client/src/index.ts b/foundations/core/packages/account-client/src/index.ts index b487cf8270d..500e1ae0909 100644 --- a/foundations/core/packages/account-client/src/index.ts +++ b/foundations/core/packages/account-client/src/index.ts @@ -16,3 +16,4 @@ export * from './client' export * from './types' export * from './utils' +export { csvEscape, csvLine } from './util/csv' diff --git a/foundations/core/packages/account-client/src/types.ts b/foundations/core/packages/account-client/src/types.ts index c7c68a45a51..0b09cc801c6 100644 --- a/foundations/core/packages/account-client/src/types.ts +++ b/foundations/core/packages/account-client/src/types.ts @@ -243,3 +243,159 @@ 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' + // v4 admin-panel enhancements — per-column filter inputs (AND with the globals above) + emailContains?: string + nameContains?: string + statusIn?: Array<'active' | 'disabled'> + authMethodIn?: Array<'email_only' | 'oidc' | 'mixed' | 'none'> + workspaceUuidsIn?: WorkspaceUuid[] + workspaceCountRange?: { min?: number, max?: number } + lastActivityFilter?: { kind: 'range', fromMs?: number, toMs?: number } | { kind: 'never' } + // Active accounts with zero workspaces. Drives the "Orphan accounts" + // quick-filter button in AdminUsers. + orphan?: boolean + // Filter to admin accounts only (primaryEmail matches the configured + // admin-emails allowlist). Drives the Admins stat-pill quick-filter. + isAdmin?: boolean + sort?: { + field: 'name' | 'email' | 'auth' | 'workspace_count' | 'last_activity' | 'status' + direction: 'asc' | 'desc' + } + pagination: { limit: number, offset: number } // EXISTING required-nested, do NOT flatten +} + +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 + }> +} + +export interface AddWorkspaceMemberParams { + accountUuid: AccountUuid + workspaceUuid: WorkspaceUuid + role: AccountRole +} + +export interface WorkspaceMembersAdminResponse { + workspaceUuid: WorkspaceUuid + workspaceName: string + workspaceUrl: string + workspaceMode: string + members: Array<{ + accountUuid: AccountUuid + firstName: string + lastName: string + primaryEmail: string | null + role: AccountRole + lastActivityAt: number | null + status: 'active' | 'disabled' + isAdmin: boolean + }> +} + +export interface BulkResult { + succeeded: AccountUuid[] + failed: Array<{ accountUuid: AccountUuid, error: string }> +} + +// Audit log DTOs (Task 3-5) + +export interface AuditEntry { + id: string + tsMs: number + admin: { uuid: AccountUuid, firstName: string, lastName: string } + action: string + targetAccount?: { uuid: AccountUuid, firstName: string, lastName: string } + targetWorkspace?: { uuid: WorkspaceUuid, name: string, url: string } + details: any | null + // V29 — Bulk-action service calls stamp every row with one shared UUID so + // the admin UI can group "this is one operation". Undefined on single-action + // sites. Plan 1d Task 3. + batchId?: string +} + +export interface ListAuditAdminParams { + filter?: { + // Legacy UUID filters — kept for API compatibility. Prefer the + // name/email substring filters below for human-facing UIs. + adminUuid?: AccountUuid + action?: string + targetAccountUuid?: AccountUuid + targetWorkspaceUuid?: WorkspaceUuid + from?: number // ms + to?: number + // V30 — Audit-log filter UX redesign. Substring-match against the + // identifiers the admin actually sees in the table (name / email / + // workspace name) instead of UUIDs. + adminNameOrEmail?: string + targetNameOrUrl?: string + // Multi-select action filter. When present takes precedence over the + // legacy `action` exact-match. Empty array is treated as "no filter". + actionIn?: string[] + } + sort?: { + field: 'time' | 'admin' | 'action' | 'target' + direction: 'asc' | 'desc' + } + pagination?: { cursor?: string, limit?: number } +} + +export interface ListAuditAdminResponse { + entries: AuditEntry[] + nextCursor: string | null +} + +export interface CreateAccountParams { + firstName: string + lastName: string + email: string + passwordMode: 'invite' | 'set' + password?: string + initialWorkspace?: { workspaceUuid: WorkspaceUuid, role: AccountRole } +} + +export interface CreateAccountResponse { + account: AccountDetailsResponse + inviteEmailSent: boolean | null + initialWorkspaceAssigned: boolean | null +} diff --git a/foundations/core/packages/account-client/src/util/csv.ts b/foundations/core/packages/account-client/src/util/csv.ts new file mode 100644 index 00000000000..bc5f38ce407 --- /dev/null +++ b/foundations/core/packages/account-client/src/util/csv.ts @@ -0,0 +1,27 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// + +/** + * Escape a single value for CSV. Adds double-quote wrapping only when + * the value contains a comma, double quote, CR, or LF. CR/LF both + * trigger quoting because RFC 4180 permits CRLF terminators inside + * quoted fields and Excel-on-Windows treats a bare \r as a record + * separator. + */ +export function csvEscape (v: unknown): string { + const s = v == null ? '' : String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r') + ? '"' + s.replace(/"/g, '""') + '"' + : s +} + +/** + * Build a single CSV row from a list of values, terminating with \r\n + * per RFC 4180 §2.1. The CRLF terminator is what Excel-on-Windows and + * older spreadsheet apps expect; a bare LF was being silently coerced + * to one mangled row on some import paths. + */ +export function csvLine (cols: ReadonlyArray): string { + return cols.map(csvEscape).join(',') + '\r\n' +} diff --git a/foundations/core/packages/client-resources/src/__tests__/connection.test.ts b/foundations/core/packages/client-resources/src/__tests__/connection.test.ts index 7fb4f195be5..dd971b64209 100644 --- a/foundations/core/packages/client-resources/src/__tests__/connection.test.ts +++ b/foundations/core/packages/client-resources/src/__tests__/connection.test.ts @@ -20,9 +20,11 @@ import core, { type WorkspaceUuid, type PersonUuid, TxCreateDoc, - type Doc + type Doc, + WorkspaceEvent, + type TxWorkspaceEvent } from '@hcengineering/core' -import { connect } from '../connection' +import { connect, setForceLogoutHandler } from '../connection' // Mock CloseEvent for Node.js environment (used in MockWebSocket) // eslint-disable-next-line @typescript-eslint/no-extraneous-class @@ -372,3 +374,107 @@ describe('connect function', () => { await new Promise((resolve) => setTimeout(resolve, 50)) }) }) + +describe('force-logout via WorkspaceEvent.AccountDisabled', () => { + let connections: Array<{ close: () => Promise }> = [] + let mockWebSockets: MockWebSocket[] = [] + + afterEach(async () => { + setForceLogoutHandler(() => {}) + for (const conn of connections) await conn.close() + connections = [] + for (const ws of mockWebSockets) { + ws.clearAllTimers() + if (ws.readyState !== ClientSocketReadyState.CLOSED) ws.close() + } + mockWebSockets = [] + await new Promise((resolve) => setTimeout(resolve, 100)) + }) + + function makeAccountDisabledTx (reason = 'manual_admin_action'): TxWorkspaceEvent { + return { + _id: generateId(), + _class: core.class.TxWorkspaceEvent, + event: WorkspaceEvent.AccountDisabled, + modifiedBy: core.account.System, + modifiedOn: Date.now(), + space: core.space.DerivedTx, + objectSpace: core.space.DerivedTx, + createdBy: core.account.System, + params: { reason } + } as unknown as TxWorkspaceEvent + } + + it('invokes the registered handler when an AccountDisabled Tx arrives on the wire', async () => { + let receivedReason: string | null = null + setForceLogoutHandler((reason) => { + receivedReason = reason + }) + + const mockWs = new MockWebSocket('ws://localhost:3333') + mockWebSockets.push(mockWs) + const handler = jest.fn() + const client = connect('ws://localhost:3333', handler, 'ws' as WorkspaceUuid, 'u' as PersonUuid, { + socketFactory: () => mockWs as any + }) + connections.push(client) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // broadcastSessions in server sends { result: [tx] } — id is undefined for broadcasts. + const tx = makeAccountDisabledTx('manual_admin_action') + mockWs.simulateMessage(JSON.stringify({ result: [tx] })) + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(receivedReason).toBe('manual_admin_action') + }) + + it('skips reconnect after force-logout fires (close does NOT spawn a new socket)', async () => { + setForceLogoutHandler(() => {}) + + const mockWs = new MockWebSocket('ws://localhost:3333') + mockWebSockets.push(mockWs) + let socketCallCount = 0 + const handler = jest.fn() + const client = connect('ws://localhost:3333', handler, 'ws' as WorkspaceUuid, 'u' as PersonUuid, { + socketFactory: () => { + socketCallCount++ + return mockWs as any + } + }) + connections.push(client) + await new Promise((resolve) => setTimeout(resolve, 100)) + const baselineCalls = socketCallCount + + mockWs.simulateMessage(JSON.stringify({ result: [makeAccountDisabledTx()] })) + await new Promise((resolve) => setTimeout(resolve, 50)) + + mockWs.close() + await new Promise((resolve) => setTimeout(resolve, 200)) + + // scheduleOpen would have caused a second socketFactory call. Force-logout + // must suppress that. + expect(socketCallCount).toBe(baselineCalls) + }) + + it('defaults reason to "account_disabled" when params.reason is missing', async () => { + let receivedReason: string | null = null + setForceLogoutHandler((reason) => { + receivedReason = reason + }) + + const mockWs = new MockWebSocket('ws://localhost:3333') + mockWebSockets.push(mockWs) + const client = connect('ws://localhost:3333', jest.fn(), 'ws' as WorkspaceUuid, 'u' as PersonUuid, { + socketFactory: () => mockWs as any + }) + connections.push(client) + await new Promise((resolve) => setTimeout(resolve, 100)) + + const tx = makeAccountDisabledTx() as any + tx.params = undefined // no reason + mockWs.simulateMessage(JSON.stringify({ result: [tx] })) + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(receivedReason).toBe('account_disabled') + }) +}) diff --git a/foundations/core/packages/client-resources/src/connection.ts b/foundations/core/packages/client-resources/src/connection.ts index 0b3b44c7720..3f765b2663a 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/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 +} diff --git a/foundations/server/packages/server/src/__tests__/sessionManager.test.ts b/foundations/server/packages/server/src/__tests__/sessionManager.test.ts index 9f8c9c9bb4e..ef5b4671f47 100644 --- a/foundations/server/packages/server/src/__tests__/sessionManager.test.ts +++ b/foundations/server/packages/server/src/__tests__/sessionManager.test.ts @@ -178,9 +178,11 @@ describe('TSessionManager', () => { it('should setup queue producers and consumers', () => { expect(mockQueue.getProducer).toHaveBeenCalledTimes(2) - expect(mockQueue.createConsumer).toHaveBeenCalledTimes(1) + // workspace consumer + account lifecycle consumer + expect(mockQueue.createConsumer).toHaveBeenCalledTimes(2) expect(sessionManager.workspaceProducer).toBeDefined() expect(sessionManager.usersProducer).toBeDefined() + expect(sessionManager.accountLifecycleConsumer).toBeDefined() }) it('should start tick interval when doHandleTick is true', () => { diff --git a/foundations/server/packages/server/src/sessionManager.ts b/foundations/server/packages/server/src/sessionManager.ts index 55dfa351f84..0cec091c765 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 { + entry.socket.close() + } catch (err) { + ctx.warn('failed to close socket for disabled account', { err }) + } + } + } + ) + this.ticksContext = ctx.newChild('ticks', {}, { span: false }) } diff --git a/plugins/login-assets/lang/de.json b/plugins/login-assets/lang/de.json index 55d71e84e12..67da9297218 100644 --- a/plugins/login-assets/lang/de.json +++ b/plugins/login-assets/lang/de.json @@ -102,6 +102,13 @@ "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", + "AdminPanel": "Admin-Bereich", + "AdminUsers": "Benutzer", + "AdminWorkspaces": "Workspaces", + "BackToWorkbench": "Zurueck zum Workbench" } } diff --git a/plugins/login-assets/lang/en.json b/plugins/login-assets/lang/en.json index 0d4a6fd1bc4..9529b9e6426 100644 --- a/plugins/login-assets/lang/en.json +++ b/plugins/login-assets/lang/en.json @@ -101,6 +101,13 @@ "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", + "AdminPanel": "Admin panel", + "AdminUsers": "Users", + "AdminWorkspaces": "Workspaces", + "BackToWorkbench": "Back to workbench", "CreateSampleProjects": "Create sample projects and demo content" } } 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/__tests__/tzTooltip.test.ts b/plugins/login-resources/src/__tests__/tzTooltip.test.ts new file mode 100644 index 00000000000..cd925de9640 --- /dev/null +++ b/plugins/login-resources/src/__tests__/tzTooltip.test.ts @@ -0,0 +1,44 @@ +import { tzTooltip } from '../components/admin-audit/tzTooltip' + +describe('V8 — tzTooltip', () => { + // 2026-06-15 12:00:00 UTC = 14:00 in Europe/Vienna (DST) + const summerMs = Date.UTC(2026, 5, 15, 12, 0, 0) + // 2026-12-15 12:00:00 UTC = 13:00 in Europe/Vienna (non-DST) + const winterMs = Date.UTC(2026, 11, 15, 12, 0, 0) + + test('contains UTC line in canonical form', () => { + const out = tzTooltip(summerMs) + expect(out).toContain('UTC: 2026-06-15 12:00:00') + }) + + test('contains Local time prefix', () => { + const out = tzTooltip(summerMs) + expect(out).toMatch(/^Local time:/) + }) + + test('Intl unavailable → degraded "Local time:" without zone name', () => { + const originalIntl = (globalThis as any).Intl + ;(globalThis as any).Intl = undefined + try { + const out = tzTooltip(summerMs) + expect(out).toContain('Local time:') + expect(out).toContain('UTC:') + expect(out).not.toMatch(/UTC[+-]\d/) + } finally { + ;(globalThis as any).Intl = originalIntl + } + }) + + test('two-line shape (Local then UTC, separated by newline)', () => { + const out = tzTooltip(summerMs) + const lines = out.split('\n') + expect(lines).toHaveLength(2) + expect(lines[0]).toMatch(/^Local time:/) + expect(lines[1]).toMatch(/^UTC:/) + }) + + test('winter (non-DST) timestamp still produces UTC line', () => { + const out = tzTooltip(winterMs) + expect(out).toContain('UTC: 2026-12-15 12:00:00') + }) +}) diff --git a/plugins/login-resources/src/components/AdminAudit.svelte b/plugins/login-resources/src/components/AdminAudit.svelte new file mode 100644 index 00000000000..72c37bd0a71 --- /dev/null +++ b/plugins/login-resources/src/components/AdminAudit.svelte @@ -0,0 +1,843 @@ + + + + +
+
+ +
+ +
+ +
+ +
+
+ + +
+ Actions + +
+
+ +
+
+ Date range +
+
+ +
+ + + +
+
+
+ +
+
+
+ + {#if entries.length > PAGE_RENDER_CAP} +
+ Showing the first {PAGE_RENDER_CAP} of {entries.length} loaded entries (current sort). Apply a filter to narrow + the result set. +
+ {/if} + + + + + + + + + + + + + {#each entryGroups as g (g.batchId ?? g.entries[0].id)} + {#if g.entries.length > 1} + + + + {/if} + {#each g.entries as e (e.id)} + + + + + + + + {/each} + {/each} + {#if entries.length === 0 && !loading} + + {/if} + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
Details
+ Bulk action by {g.entries[0].admin.firstName} {g.entries[0].admin.lastName} + — {g.entries.length} entries · {g.entries[0].action} · + {new Date(g.entries[0].tsMs).toLocaleString()} +
{new Date(e.tsMs).toLocaleString()}{e.admin.firstName} {e.admin.lastName}{e.action} + {#if e.targetAccount != null} + {e.targetAccount.firstName} {e.targetAccount.lastName} + {:else if e.targetWorkspace != null} + {e.targetWorkspace.name || e.targetWorkspace.url} + {/if} + + {#if e.details != null} + {#if expandedDetails.has(e.id)} +
{JSON.stringify(e.details, null, 2)}
+ + {:else} + + {/if} + {/if} +
+ + {#if nextCursor != null} +
+
+ {/if} +
+
+
+
+
+ + diff --git a/plugins/login-resources/src/components/AdminUsers.svelte b/plugins/login-resources/src/components/AdminUsers.svelte new file mode 100644 index 00000000000..b78c7880772 --- /dev/null +++ b/plugins/login-resources/src/components/AdminUsers.svelte @@ -0,0 +1,962 @@ + + + + +
+
+ +
+ +
+ +
+
+
+ + + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + clearAllFilters() + } + }} + > + Total {total} + + + { + toggleStatusFilter('active') + }} + on:keydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleStatusFilter('active') + } + }} + > + Active {counts.active} + + + { + toggleStatusFilter('disabled') + }} + on:keydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleStatusFilter('disabled') + } + }} + > + Disabled {counts.disabled} + + + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleAdminFilter() + } + }} + > + Admins {counts.admins} + + {#if orphanCount > 0} + + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggleOrphanFilter() + } + }} + > + {#if columnFilters?.orphan?.orphan === true} + + {/if} + Orphan {orphanCount} + + {/if} +
+ {#if isAdminUser()} +
+ +
+ + + + { + const p = e.detail + columnFilters = p.filters ?? {} + if (p.sort != null) sort = p.sort + if (p.search != null) filter = { ...filter, search: p.search } + offset = 0 + void refresh() + }} + /> + {#if isAdminUser()} +
+ + {#if errorMessage} +
+
+ {/if} + + + + + + {#if total > limit} + + {/if} +
+
+
+
+
+ +{#if selectedUuid != null} + +{/if} + + diff --git a/plugins/login-resources/src/components/AdminWorkspaces.svelte b/plugins/login-resources/src/components/AdminWorkspaces.svelte index c096980caac..56a7d862755 100644 --- a/plugins/login-resources/src/components/AdminWorkspaces.svelte +++ b/plugins/login-resources/src/components/AdminWorkspaces.svelte @@ -1,7 +1,7 @@ {#if isAdmin} - -
-
-
-
Workspaces administration panel
-
- Enable deletion + +
+
+ + +
-
-
- Workspaces: {workspaces.length} active: {workspaces.filter((it) => isActiveMode(it.mode)).length} - upgrading: {workspaces.filter((it) => isUpgradingMode(it.mode)).length} -
- Backupable: {backupable.length} new: {backupable.reduce((p, it) => p + (it.backupInfo == null ? 1 : 0), 0)} - Active: {data?.workspaces.length ?? -1} -
- - Users: {data?.usersTotal}/{data?.connectionsTotal} - - -
- {#each byVersion.entries() as [k, v]} -
- {k}: {v.length} + Enable deletion + + + + +
+ +
+ +
+
+ Total + {workspaces.length}
- {/each} -
-
- {#each byRegion.entries() as [k, v]} -
- {k ?? ''}: {v.length} +
+ Active + {workspaces.filter((it) => isActiveMode(it.mode)).length}
- {/each} -
-
-
- -
- -
- Filters: -
- Show active workspaces: - -
-
- Show archived workspaces: - -
-
- Show deleted workspaces: - -
-
- Show other workspaces: - -
-
- Show attempts >=0 workspaces: - -
-
- Show selected region only: - -
-
- Show inactive workspaces: - -
-
- -
- Sorting order: {sortingRule} - ({ id: it[0], label: getEmbeddedLabel(it[1]) }))} - on:selected={(it) => { - sortingRule = it.detail - }} - /> -
- -
- Migration region selector: - ({ - id: it.region === '' ? '#' : it.region, - label: getEmbeddedLabel(it.name.length > 0 ? it.name : it.region + ' (hidden)') - }))} - on:selected={(it) => { - selectedRegionId = it.detail === '#' ? '' : it.detail - }} - /> -
- -
-
- -
- Filtere region selector: - ({ - id: it.region === '' ? '#' : it.region, - label: getEmbeddedLabel(it.name.length > 0 ? it.name : it.region + ' (hidden)') - }))} - on:selected={(it) => { - filterRegionId = it.detail === '#' ? '' : it.detail - }} - /> -
-
- -
- {#each Object.keys(dayRanges) as k} - {@const v = groupped.get(k) ?? []} - {@const hasMore = (groupped.get(k) ?? []).length > limit} - {@const activeV = v - .filter((it) => isActiveMode(it.mode) && it.region !== selectedRegionId) - .slice(0, limit)} - {@const activeAll = v.filter((it) => isActiveMode(it.mode))} - {@const archivedV = v.filter((it) => isArchivingMode(it.mode))} - {@const deletedV = v.filter((it) => isDeletingMode(it.mode))} - {@const maintenance = v.length - activeAll.length - archivedV.length - deletedV.length} - {@const grByRegion = groupByArray(v, (it) => regionTitles[it.region ?? ''])} - {#if v.length > 0} - 0}> - - - {k} - - {#if hasMore} - {limit} of {v.length} + {#if workspaces.filter((it) => isUpgradingMode(it.mode)).length > 0} +
+ Upgrading + {workspaces.filter((it) => isUpgradingMode(it.mode)).length} +
+ {/if} + {#if data != null} +
+ With active sessions + {data.workspaces.length} +
+ {/if} +
+ Users + {data?.usersTotal ?? 0} +
+
+ Connections + {data?.connectionsTotal ?? 0} +
+
+ Created 30d + {createdLast30dCount} +
+ {#if longRunningStatCount > 0} +
+ Long-running > 1h + {longRunningStatCount} +
+ {/if} +
+
+
+ Total Storage + {totalStorageFormatted} + {#if totalStorageMb === 0} + No backup data yet + {/if} +
+
+ + {#if byVersion.size > 0 || byRegion.size > 0} +
+ {#if byVersion.size > 0} +
+ By version: + {#each byVersion.entries() as [k, v]} + {k}{v.length} + {/each} +
+ {/if} + {#if byRegion.size > 0} +
+ By region: + {#each byRegion.entries() as [k, v]} + {k ?? '—'}{v.length} + {/each} +
+ {/if} +
+ {/if} + + + +
+
+ Workspaces + + {#if hasMore}{visibleWorkspaces.length} of {sortedWorkspaces.length}{:else}{sortedWorkspaces.length}{/if} + +
+
+ + + {/if} + {#if selectedActiveWorkspaces.length > 0} +
+
+ +
+ Showing {visibleWorkspaces.length} of {sortedWorkspaces.length} workspace{sortedWorkspaces.length !== 1 + ? 's' + : ''} +
+
+ +
+ +
+ 0 && + visibleWorkspaces.every((w) => selectedWorkspaceUuids.has(w.uuid))} + on:value={(e) => { + toggleAllWsSelection(e.detail) + }} + /> +
+ {#each [{ field: 'name', label: 'Name', filter: true }, { field: 'region', label: 'Region', filter: true }, { field: 'last_visit', label: 'Last visit', filter: true, num: true }, { field: 'mode', label: 'Mode', filter: true }, { field: 'attempts', label: 'Attempts', filter: true, num: true }, { field: 'progress', label: 'Progress', filter: false, num: true }, { field: 'backup_size', label: 'Storage', filter: true }, { field: 'backup_age', label: 'Backup age', filter: true }] as col} +
+ { + setSort(col.field) + }} + > + {#if sortField === col.field}{sortDir === 'asc' ? '↑' : '↓'}{/if}{col.label} + + {#if col.filter} + + {/if} +
+ {/each} +
Actions
+
+ + {#if visibleWorkspaces.length === 0} +
No workspaces match the current filters.
+ {:else} + {#each visibleWorkspaces as workspace, wsIdx (workspace.uuid)} + {@const wsName = workspace.name} + {@const lastUsageDays = Math.round((now - (workspace.lastVisit ?? 0)) / (1000 * 3600 * 24))} + {@const bIdx = backupIdx.get(workspace.uuid)} + {@const stats = statsByWorkspace.get(workspace.uuid ?? '')} + + +
{ + openWorkspace(workspace.uuid) + }} + > + +
+ { + toggleWsSelection(workspace.uuid, e.detail) + }} + /> +
+
+ {wsName} + {#if stats} + + {stats.sessions?.length ?? 0} + · + {sessionOpsByWs.get(workspace.uuid ?? '') ?? 0} + + {/if} +
+
+
+
{workspace.region ?? ''}
+
{lastUsageDays}d
+
+ {workspace.mode ?? '-'} +
+
+ {workspace.processingAttempts} + {#if workspace.processingAttempts > 0} +
+
+ {#if workspace.processingProgress !== 100 && workspace.processingProgress !== 0} + {workspace.processingProgress}% + {/if} +
+
+ {#if workspace.backupInfo != null} + {@const sz = Math.max( + workspace.backupInfo.backupSize, + workspace.backupInfo.dataSize + workspace.backupInfo.blobsSize + )} + {@const szGb = Math.round((sz * 100) / 1024) / 100} + {#if szGb > 0} + {szGb} GB {:else} - {v.length} + {Math.round(sz * 100) / 100} MB {/if} - {#if maintenance > 0} - - maitenance: {maintenance} + {#if bIdx != null} + [#{bIdx}] {/if} - {#if grByRegion.size > 1} - {#each grByRegion.entries() as [k, v]} -
- {k ?? ''}: {v.length} -
- {/each} + {:else} + + {/if} +
+
+ {#if workspace.backupInfo != null} + {@const hours = Math.round((now - workspace.backupInfo.lastBackup) / (1000 * 3600))} + {#if hours > 24} + {Math.round(hours / 24)}d ago + {:else} + {hours}h ago {/if} - - - - {#if hasMore} -
-
+ {:else} + {/if} -
- - {#if activeAll.length > 0} +
+
+ {#if workspace.mode === 'active'}
-
-
- {workspace.region ?? ''} -
-
- {lastUsageDays} days -
-
- {workspace.mode ?? '-'} -
-
- {workspace.processingAttempts} - {#if workspace.processingAttempts > 0} -
-
- {#if workspace.processingProgress !== 100 && workspace.processingProgress !== 0} - ({workspace.processingProgress}%) - {/if} -
-
- {#if workspace.backupInfo != null} - {@const sz = Math.max( - workspace.backupInfo.backupSize, - workspace.backupInfo.dataSize + workspace.backupInfo.blobsSize - )} - {@const szGb = Math.round((sz * 100) / 1024) / 100} - {#if szGb > 0} - {Math.round((sz * 100) / 1024) / 100}Gb - {:else} - {Math.round(sz * 100) / 100}Mb - {/if} - {/if} - {#if bIdx != null} - [#{bIdx}] - {/if} -
-
- {#if workspace.backupInfo != null} - {@const hours = Math.round((now - workspace.backupInfo.lastBackup) / (1000 * 3600))} - - {#if hours > 24} - {Math.round(hours / 24)} days - {:else} - {hours} hours - {/if} - {/if} -
-
- {#if workspace.mode === 'active'} -
- - {/each} - - {/if} - {/each} -
-
-
-
- -
-
Accounts administration panel
-
- Enable deletion - -
-
-
- -
- -
-
- -
- -
- {#each accounts as account} - -
-
- {account.firstName} - {account.lastName} + {#if superAdminMode && !isDeletingMode(workspace.mode) && !isArchivingMode(workspace.mode)} +
-
{account.uuid}
-
+ {/each} + {/if} +
-
-
Social IDs: {account.socialIds.length}
- {#each account.socialIds as socialId} -
{socialId.type}:{socialId.value}
- {/each} -
-
-
Workspaces: {account.workspaces.length}
- {#each account.workspaces as workspace} -
{workspace.name} {workspace.url} {workspace.uuid} {workspace.dataId}
- {/each} -
-
- {#if accountSuperAdminMode} -
- - {/each} + {#if hasMore} +
+
+ {/if}
- - + + {#if selectedWorkspaceUuid != null} + + {/if} {/if} + + 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..4554f8ca9f5 100644 --- a/plugins/login-resources/src/components/LoginApp.svelte +++ b/plugins/login-resources/src/components/LoginApp.svelte @@ -25,7 +25,8 @@ getCurrentLocation, location, setMetadataLocalStorage, - themeStore + themeStore, + location as locationStore } from '@hcengineering/ui' import workbench from '@hcengineering/workbench' import { onDestroy, onMount } from 'svelte' @@ -53,7 +54,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 AdminAudit from './AdminAudit.svelte' import ChangePassword from './ChangePassword.svelte' + import ForceLogoutModal from './ForceLogoutModal.svelte' + import { forceLogoutReason } from '../utils' + + $: subPath = $locationStore.path[2] export let page: Pages = 'signup' @@ -122,8 +129,19 @@ onMount(chooseToken) +{#if $forceLogoutReason != null} + +{/if} + {#if page === 'admin'} - + {#if subPath === 'users'} + + {:else if subPath === 'audit'} + + {:else} + + {/if} + {:else}