From edfccfc341da1f696d9ee2d30a06fd424f4b59aa Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Thu, 28 May 2026 11:22:03 +0700 Subject: [PATCH] Adjust session history Signed-off-by: Artem Savchenko --- plugins/setting-assets/lang/cs.json | 1 + plugins/setting-assets/lang/de.json | 1 + plugins/setting-assets/lang/en.json | 1 + plugins/setting-assets/lang/es.json | 1 + plugins/setting-assets/lang/fr.json | 1 + plugins/setting-assets/lang/it.json | 1 + plugins/setting-assets/lang/ja.json | 1 + plugins/setting-assets/lang/ko.json | 1 + plugins/setting-assets/lang/pt-br.json | 1 + plugins/setting-assets/lang/pt.json | 1 + plugins/setting-assets/lang/ru.json | 1 + plugins/setting-assets/lang/tr.json | 1 + plugins/setting-assets/lang/zh.json | 1 + .../__tests__/securityLoginActivity.test.ts | 112 ++++++ .../src/components/Profile.svelte | 27 +- .../src/components/SecuritySettings.svelte | 36 +- .../components/SessionHistorySettings.svelte | 345 ++++++++++++++---- .../src/components/TwoFactorSettings.svelte | 2 +- .../src/securityLoginActivity.ts | 61 ++++ plugins/setting/src/index.ts | 1 + server/account/src/operations.ts | 14 +- .../src/securityLoginTelemetryRateLimit.ts | 7 +- server/account/src/securityPolicy.ts | 8 +- server/account/src/utils.ts | 5 +- 24 files changed, 545 insertions(+), 86 deletions(-) diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index ec23543e3b..b81f4812a6 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -231,6 +231,7 @@ "Reset": "Resetovat", "Security": "Zabezpečení", "SecurityTabSessions": "Historie relací", + "SessionHistory": "Historie relací", "TwoFactorAuth": "Dvoufaktorové ověřování", "TwoFactorAuthDescription": "Dvoufaktorové ověřování přidává další vrstvu zabezpečení k vašemu účtu", "EnableTwoFactorAuth": "Povolit dvoufaktorové ověřování", diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index b91d2ce45e..ca49ea0c4a 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -233,6 +233,7 @@ "Reset": "Zurücksetzen", "Security": "Sicherheit", "SecurityTabSessions": "Sitzungsverlauf", + "SessionHistory": "Sitzungsverlauf", "TwoFactorAuth": "Zweistufige Authentifizierung", "TwoFactorAuthDescription": "Zweistufige Authentifizierung fügt eine zusätzliche Sicherheitsebene zu Ihrem Konto hinzu", "EnableTwoFactorAuth": "Zweistufige Authentifizierung aktivieren", diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index d8f70eb5c7..2137334700 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -233,6 +233,7 @@ "SpaceMembersOnly": "Space members only", "Security": "Security", "SecurityTabSessions": "Session history", + "SessionHistory": "Session history", "TwoFactorAuth": "Two-factor authentication", "TwoFactorAuthDescription": "Two-factor authentication adds an extra layer of security to your account", "EnableTwoFactorAuth": "Enable two-factor authentication", diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index b2e95f8a9a..d5fab361ce 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -224,6 +224,7 @@ "Reset": "Reiniciar", "Security": "Seguridad", "SecurityTabSessions": "Historial de sesiones", + "SessionHistory": "Historial de sesiones", "TwoFactorAuth": "Autenticación de dos factores", "TwoFactorAuthDescription": "La autenticación de dos factores añade una capa adicional de seguridad a tu cuenta", "EnableTwoFactorAuth": "Habilitar autenticación de dos factores", diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 32b31f82bf..1223383c7e 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -233,6 +233,7 @@ "Reset": "Réinitialiser", "Security": "Sécurité", "SecurityTabSessions": "Historique des sessions", + "SessionHistory": "Historique des sessions", "TwoFactorAuth": "Authentification à deux facteurs", "TwoFactorAuthDescription": "L'authentification à deux facteurs ajoute une couche de sécurité supplémentaire à votre compte", "EnableTwoFactorAuth": "Activer l'authentification à deux facteurs", diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index 654c5671de..fe6a2ad45e 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -233,6 +233,7 @@ "Reset": "Reset", "Security": "Sicurezza", "SecurityTabSessions": "Cronologia sessioni", + "SessionHistory": "Cronologia sessioni", "TwoFactorAuth": "Autenticazione a due fattori", "TwoFactorAuthDescription": "L'autenticazione a due fattori aggiunge un ulteriore livello di sicurezza al tuo account", "EnableTwoFactorAuth": "Abilita autenticazione a due fattori", diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index 71863b7a34..1114025f3e 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -233,6 +233,7 @@ "Reset": "リセット", "Security": "セキュリティ", "SecurityTabSessions": "セッション履歴", + "SessionHistory": "セッション履歴", "TwoFactorAuth": "二要素認証", "TwoFactorAuthDescription": "二要素認証はアカウントにセキュリティの追加レイヤーを追加します", "EnableTwoFactorAuth": "二要素認証を有効にする", diff --git a/plugins/setting-assets/lang/ko.json b/plugins/setting-assets/lang/ko.json index 313725b921..3e73e3c6f2 100644 --- a/plugins/setting-assets/lang/ko.json +++ b/plugins/setting-assets/lang/ko.json @@ -232,6 +232,7 @@ "ShowInTitle": "제목에 표시", "SpaceMembersOnly": "스페이스 멤버 전용", "Security": "보안", + "SessionHistory": "세션 기록", "TwoFactorAuth": "2단계 인증", "TwoFactorAuthDescription": "2단계 인증은 계정에 추가적인 보안 계층을 더해 줍니다", "EnableTwoFactorAuth": "2단계 인증 활성화", diff --git a/plugins/setting-assets/lang/pt-br.json b/plugins/setting-assets/lang/pt-br.json index c99b95330f..a85c2e4e85 100644 --- a/plugins/setting-assets/lang/pt-br.json +++ b/plugins/setting-assets/lang/pt-br.json @@ -224,6 +224,7 @@ "Reset": "Reiniciar", "Security": "Segurança", "SecurityTabSessions": "Histórico de sessões", + "SessionHistory": "Histórico de sessões", "TwoFactorAuth": "Autenticação de dois fatores", "TwoFactorAuthDescription": "A autenticação de dois fatores adiciona uma camada extra de segurança à sua conta", "EnableTwoFactorAuth": "Ativar autenticação de dois fatores", diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index 691800b628..cceeb492df 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -224,6 +224,7 @@ "Reset": "Reiniciar", "Security": "Segurança", "SecurityTabSessions": "Histórico de sessões", + "SessionHistory": "Histórico de sessões", "TwoFactorAuth": "Autenticação de dois fatores", "TwoFactorAuthDescription": "A autenticação de dois fatores adiciona uma camada extra de segurança à sua conta", "EnableTwoFactorAuth": "Ativar autenticação de dois fatores", diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index 9cb795d1d6..a580bfa2fe 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -233,6 +233,7 @@ "SpaceMembersOnly": "Только участники пространства", "Security": "Безопасность", "SecurityTabSessions": "История сессий", + "SessionHistory": "История сессий", "TwoFactorAuth": "Двухфакторная аутентификация", "TwoFactorAuthDescription": "Двухфакторная аутентификация добавляет дополнительный уровень безопасности к вашей учетной записи", "EnableTwoFactorAuth": "Включить двухфакторную аутентификацию", diff --git a/plugins/setting-assets/lang/tr.json b/plugins/setting-assets/lang/tr.json index ec7cd3f259..8b54a3f444 100644 --- a/plugins/setting-assets/lang/tr.json +++ b/plugins/setting-assets/lang/tr.json @@ -233,6 +233,7 @@ "Reset": "Sıfırla", "Security": "Güvenlik", "SecurityTabSessions": "Oturum geçmişi", + "SessionHistory": "Oturum geçmişi", "TwoFactorAuth": "İki faktörlü kimlik doğrulama", "TwoFactorAuthDescription": "İki faktörlü kimlik doğrulama hesabınıza ek bir güvenlik katmanı ekler", "EnableTwoFactorAuth": "İki faktörlü kimlik doğrulamayı etkinleştir", diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index f3bd4c9d23..19231a5582 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -233,6 +233,7 @@ "Reset": "重置", "Security": "安全", "SecurityTabSessions": "会话历史", + "SessionHistory": "会话历史", "TwoFactorAuth": "双因素认证", "TwoFactorAuthDescription": "双因素认证为您的帐户增加额外的安全层", "EnableTwoFactorAuth": "启用双因素认证", diff --git a/plugins/setting-resources/src/__tests__/securityLoginActivity.test.ts b/plugins/setting-resources/src/__tests__/securityLoginActivity.test.ts index 62e88f6611..675f37b4c3 100644 --- a/plugins/setting-resources/src/__tests__/securityLoginActivity.test.ts +++ b/plugins/setting-resources/src/__tests__/securityLoginActivity.test.ts @@ -6,13 +6,27 @@ // obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 // +import type { SecurityLoginHistoryEvent } from '@hcengineering/account-client' import { + coalesceLoginHistory, formatLocation, getShortUserAgent, maskIpAddress, shouldShowNotMeAction } from '../securityLoginActivity' +function makeEvent ( + partial: Partial & Pick +): SecurityLoginHistoryEvent { + return { + accountUuid: 'acc-1' as SecurityLoginHistoryEvent['accountUuid'], + success: true, + authMethod: 'session', + createdOn: partial.eventTime, + ...partial + } +} + describe('securityLoginActivity helpers', () => { it('masks IPv4 addresses for profile display', () => { expect(maskIpAddress('192.168.12.200')).toBe('192.168.***.***') @@ -49,3 +63,101 @@ describe('securityLoginActivity helpers', () => { expect(shouldShowNotMeAction({ success: false })).toBe(false) }) }) + +describe('coalesceLoginHistory', () => { + it('returns an empty array for no input', () => { + expect(coalesceLoginHistory([])).toEqual([]) + }) + + it('groups consecutive same-signature events into one entry with a count', () => { + const events: SecurityLoginHistoryEvent[] = [ + makeEvent({ id: '3', eventTime: 300, ip: '1.1.1.1', userAgent: 'Chrome' }), + makeEvent({ id: '2', eventTime: 200, ip: '1.1.1.1', userAgent: 'Chrome' }), + makeEvent({ id: '1', eventTime: 100, ip: '1.1.1.1', userAgent: 'Chrome' }) + ] + const groups = coalesceLoginHistory(events) + expect(groups).toHaveLength(1) + expect(groups[0].count).toBe(3) + expect(groups[0].ids).toEqual(['3', '2', '1']) + expect(groups[0].firstEventTime).toBe(100) + expect(groups[0].lastEventTime).toBe(300) + // representative event is the first one encountered (newest-first ordering). + expect(groups[0].event.id).toBe('3') + }) + + it('starts a new group when any signature field changes', () => { + const events: SecurityLoginHistoryEvent[] = [ + makeEvent({ id: '4', eventTime: 400, ip: '1.1.1.1', userAgent: 'Chrome' }), + makeEvent({ id: '3', eventTime: 300, ip: '2.2.2.2', userAgent: 'Chrome' }), + makeEvent({ id: '2', eventTime: 200, ip: '2.2.2.2', userAgent: 'Firefox' }), + makeEvent({ id: '1', eventTime: 100, ip: '2.2.2.2', userAgent: 'Firefox', success: false }) + ] + const groups = coalesceLoginHistory(events) + expect(groups.map((g) => g.count)).toEqual([1, 1, 1, 1]) + expect(groups.map((g) => g.id)).toEqual(['4', '3', '2', '1']) + }) + + it('does not merge non-consecutive matches', () => { + const events: SecurityLoginHistoryEvent[] = [ + makeEvent({ id: '3', eventTime: 300, ip: '1.1.1.1', userAgent: 'Chrome' }), + makeEvent({ id: '2', eventTime: 200, ip: '2.2.2.2', userAgent: 'Chrome' }), + makeEvent({ id: '1', eventTime: 100, ip: '1.1.1.1', userAgent: 'Chrome' }) + ] + const groups = coalesceLoginHistory(events) + expect(groups).toHaveLength(3) + }) + + it('treats undefined and empty string fields as equivalent', () => { + const events: SecurityLoginHistoryEvent[] = [ + makeEvent({ id: '2', eventTime: 200, ip: undefined, city: '' }), + makeEvent({ id: '1', eventTime: 100, ip: '', city: undefined }) + ] + const groups = coalesceLoginHistory(events) + expect(groups).toHaveLength(1) + expect(groups[0].count).toBe(2) + }) + + it('does not collapse password events even when signature matches', () => { + const events: SecurityLoginHistoryEvent[] = [ + makeEvent({ id: '3', eventTime: 300, authMethod: 'password', ip: '1.1.1.1', userAgent: 'Chrome' }), + makeEvent({ id: '2', eventTime: 200, authMethod: 'password', ip: '1.1.1.1', userAgent: 'Chrome' }), + makeEvent({ id: '1', eventTime: 100, authMethod: 'password', ip: '1.1.1.1', userAgent: 'Chrome' }) + ] + const groups = coalesceLoginHistory(events) + // Every real authentication keeps its own row so redacted-IP + // collisions across distinct sources don't get hidden. + expect(groups).toHaveLength(3) + expect(groups.every((g) => g.count === 1)).toBe(true) + }) + + it('does not collapse otp or token events', () => { + const events: SecurityLoginHistoryEvent[] = [ + makeEvent({ id: '4', eventTime: 400, authMethod: 'otp', ip: '1.1.1.1' }), + makeEvent({ id: '3', eventTime: 300, authMethod: 'otp', ip: '1.1.1.1' }), + makeEvent({ id: '2', eventTime: 200, authMethod: 'token', ip: '1.1.1.1' }), + makeEvent({ id: '1', eventTime: 100, authMethod: 'token', ip: '1.1.1.1' }) + ] + expect(coalesceLoginHistory(events)).toHaveLength(4) + }) + + it('does not collapse failed events even when authMethod is session', () => { + const events: SecurityLoginHistoryEvent[] = [ + makeEvent({ id: '2', eventTime: 200, authMethod: 'session', success: false, ip: '1.1.1.1' }), + makeEvent({ id: '1', eventTime: 100, authMethod: 'session', success: false, ip: '1.1.1.1' }) + ] + expect(coalesceLoginHistory(events)).toHaveLength(2) + }) + + it('coalesces a session run but keeps surrounding password events separate', () => { + const events: SecurityLoginHistoryEvent[] = [ + makeEvent({ id: '5', eventTime: 500, authMethod: 'password', ip: '1.1.1.1' }), + makeEvent({ id: '4', eventTime: 400, authMethod: 'session', ip: '1.1.1.1' }), + makeEvent({ id: '3', eventTime: 300, authMethod: 'session', ip: '1.1.1.1' }), + makeEvent({ id: '2', eventTime: 200, authMethod: 'session', ip: '1.1.1.1' }), + makeEvent({ id: '1', eventTime: 100, authMethod: 'password', ip: '1.1.1.1' }) + ] + const groups = coalesceLoginHistory(events) + expect(groups.map((g) => g.count)).toEqual([1, 3, 1]) + expect(groups[1].ids).toEqual(['4', '3', '2']) + }) +}) diff --git a/plugins/setting-resources/src/components/Profile.svelte b/plugins/setting-resources/src/components/Profile.svelte index cf6f1173e5..dd45d01f90 100644 --- a/plugins/setting-resources/src/components/Profile.svelte +++ b/plugins/setting-resources/src/components/Profile.svelte @@ -19,6 +19,7 @@ import login, { loginId } from '@hcengineering/login' import platform, { getResource, PlatformError } from '@hcengineering/platform' import { AttributeEditor, createQuery, getClient, hasResource, MessageBox } from '@hcengineering/presentation' + import { settingId } from '@hcengineering/setting' import { Breadcrumb, Button, @@ -26,11 +27,13 @@ createFocusManager, EditBox, FocusHandler, + getCurrentResolvedLocation, Header, navigate, Scroller, showPopup } from '@hcengineering/ui' + import view from '@hcengineering/view' import { logIn, logOut } from '@hcengineering/workbench-resources' import rating, { type PersonRating } from '@hcengineering/rating' @@ -49,8 +52,6 @@ personRating = res[0] }) - $: console.log('SYS', personRating) - let firstName = '' let lastName = '' let initialized = false @@ -118,6 +119,17 @@ }) } } + + function openSessionHistory (): void { + const loc = getCurrentResolvedLocation() + loc.path[2] = settingId + loc.path[3] = 'security' + loc.path[4] = 'sessions' + loc.path.length = 5 + loc.fragment = undefined + loc.query = undefined + navigate(loc) + } @@ -195,6 +207,12 @@ {/if} - {#if loginHistoryLoading} - - - + + + {#if loginHistoryLoading && !loginHistoryLoaded} +
    + {#each Array(3) as _} +
  • + {/each} +
{:else if loginHistoryError} -