mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Adjust session history
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -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í",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"Reset": "リセット",
|
||||
"Security": "セキュリティ",
|
||||
"SecurityTabSessions": "セッション履歴",
|
||||
"SessionHistory": "セッション履歴",
|
||||
"TwoFactorAuth": "二要素認証",
|
||||
"TwoFactorAuthDescription": "二要素認証はアカウントにセキュリティの追加レイヤーを追加します",
|
||||
"EnableTwoFactorAuth": "二要素認証を有効にする",
|
||||
|
||||
@@ -232,6 +232,7 @@
|
||||
"ShowInTitle": "제목에 표시",
|
||||
"SpaceMembersOnly": "스페이스 멤버 전용",
|
||||
"Security": "보안",
|
||||
"SessionHistory": "세션 기록",
|
||||
"TwoFactorAuth": "2단계 인증",
|
||||
"TwoFactorAuthDescription": "2단계 인증은 계정에 추가적인 보안 계층을 더해 줍니다",
|
||||
"EnableTwoFactorAuth": "2단계 인증 활성화",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"SpaceMembersOnly": "Только участники пространства",
|
||||
"Security": "Безопасность",
|
||||
"SecurityTabSessions": "История сессий",
|
||||
"SessionHistory": "История сессий",
|
||||
"TwoFactorAuth": "Двухфакторная аутентификация",
|
||||
"TwoFactorAuthDescription": "Двухфакторная аутентификация добавляет дополнительный уровень безопасности к вашей учетной записи",
|
||||
"EnableTwoFactorAuth": "Включить двухфакторную аутентификацию",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"Reset": "重置",
|
||||
"Security": "安全",
|
||||
"SecurityTabSessions": "会话历史",
|
||||
"SessionHistory": "会话历史",
|
||||
"TwoFactorAuth": "双因素认证",
|
||||
"TwoFactorAuthDescription": "双因素认证为您的帐户增加额外的安全层",
|
||||
"EnableTwoFactorAuth": "启用双因素认证",
|
||||
|
||||
@@ -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<SecurityLoginHistoryEvent> & Pick<SecurityLoginHistoryEvent, 'id' | 'eventTime'>
|
||||
): 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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<FocusHandler {manager} />
|
||||
@@ -195,6 +207,12 @@
|
||||
{/if}
|
||||
<SocialIdsEditor rating={personRating} />
|
||||
<div class="footer">
|
||||
<Button
|
||||
icon={view.icon.Timeline}
|
||||
label={setting.string.SessionHistory}
|
||||
kind="secondary"
|
||||
on:click={openSessionHistory}
|
||||
/>
|
||||
<Button
|
||||
icon={setting.icon.Signout}
|
||||
label={setting.string.Leave}
|
||||
@@ -226,6 +244,9 @@
|
||||
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,23 +18,51 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
defineSeparators,
|
||||
getCurrentResolvedLocation,
|
||||
Header,
|
||||
navigate,
|
||||
NavItem,
|
||||
resolvedLocationStore,
|
||||
Scroller,
|
||||
Separator,
|
||||
twoPanelsSeparators
|
||||
} from '@hcengineering/ui'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
import settingsRes from '../plugin'
|
||||
import SessionHistorySettings from './SessionHistorySettings.svelte'
|
||||
import TwoFactorSettings from './TwoFactorSettings.svelte'
|
||||
|
||||
let securityTab: 'twoFactor' | 'sessions' = 'twoFactor'
|
||||
type SecurityTab = 'twoFactor' | 'sessions'
|
||||
|
||||
function tabFromPath (segment: string | undefined): SecurityTab {
|
||||
return segment === 'sessions' ? 'sessions' : 'twoFactor'
|
||||
}
|
||||
|
||||
let securityTab: SecurityTab = tabFromPath(getCurrentResolvedLocation().path[4])
|
||||
|
||||
onDestroy(
|
||||
resolvedLocationStore.subscribe((loc) => {
|
||||
const next = tabFromPath(loc.path[4])
|
||||
if (next !== securityTab) {
|
||||
securityTab = next
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
function selectTab (tab: SecurityTab): void {
|
||||
if (securityTab === tab) return
|
||||
securityTab = tab
|
||||
const loc = getCurrentResolvedLocation()
|
||||
loc.path[4] = tab
|
||||
loc.path.length = 5
|
||||
navigate(loc)
|
||||
}
|
||||
|
||||
defineSeparators('securitySettings', twoPanelsSeparators)
|
||||
</script>
|
||||
|
||||
<div class="hulyComponent">
|
||||
<div class="hulyComponent w-full">
|
||||
<Header adaptive={'disabled'}>
|
||||
<Breadcrumb icon={setting.icon.Password} label={setting.string.Security} size={'large'} isCurrent />
|
||||
</Header>
|
||||
@@ -46,7 +74,7 @@
|
||||
label={setting.string.TwoFactorAuth}
|
||||
selected={securityTab === 'twoFactor'}
|
||||
on:click={() => {
|
||||
securityTab = 'twoFactor'
|
||||
selectTab('twoFactor')
|
||||
}}
|
||||
/>
|
||||
<NavItem
|
||||
@@ -54,7 +82,7 @@
|
||||
label={settingsRes.string.SecurityTabSessions}
|
||||
selected={securityTab === 'sessions'}
|
||||
on:click={() => {
|
||||
securityTab = 'sessions'
|
||||
selectTab('sessions')
|
||||
}}
|
||||
/>
|
||||
</Scroller>
|
||||
|
||||
@@ -14,13 +14,20 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import type { SecurityLoginHistoryEvent } from '@hcengineering/account-client'
|
||||
import type { SecurityAuthMethod, SecurityLoginHistoryEvent } from '@hcengineering/account-client'
|
||||
import { MessageBox } from '@hcengineering/presentation'
|
||||
import { Button, Label, showPopup } from '@hcengineering/ui'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
import settingsRes from '../plugin'
|
||||
import { formatLocation, getShortUserAgent, maskIpAddress, shouldShowNotMeAction } from '../securityLoginActivity'
|
||||
import {
|
||||
coalesceLoginHistory,
|
||||
formatLocation,
|
||||
getShortUserAgent,
|
||||
maskIpAddress,
|
||||
shouldShowNotMeAction,
|
||||
type SecurityLoginHistoryGroup
|
||||
} from '../securityLoginActivity'
|
||||
import { getAccountClient } from '../utils'
|
||||
|
||||
/** Narrow account client for security APIs (params match server contract). */
|
||||
@@ -28,12 +35,14 @@
|
||||
getMySecurityLoginHistory: (params?: { limit?: number, redact?: boolean }) => Promise<SecurityLoginHistoryEvent[]>
|
||||
reportSecurityLoginConcern: (params?: { loginEventId?: string }) => Promise<void>
|
||||
}
|
||||
const recentActivityLimit = 20
|
||||
const recentActivityLimit = 50
|
||||
let loginHistory: SecurityLoginHistoryEvent[] = []
|
||||
let loginHistoryLoading = false
|
||||
let loginHistoryLoaded = false
|
||||
let loginHistoryError = false
|
||||
|
||||
$: groups = coalesceLoginHistory(loginHistory)
|
||||
|
||||
async function loadRecentLoginActivity (): Promise<void> {
|
||||
loginHistoryLoading = true
|
||||
loginHistoryError = false
|
||||
@@ -48,123 +57,331 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotMeAction (event?: SecurityLoginHistoryEvent): void {
|
||||
function handleNotMeAction (group?: SecurityLoginHistoryGroup): void {
|
||||
showPopup(MessageBox, {
|
||||
label: settingsRes.string.NotMeDialogTitle,
|
||||
message: settingsRes.string.NotMeDialogMessage,
|
||||
okLabel: settingsRes.string.NotMeDialogAction,
|
||||
action: async () => {
|
||||
await accountClient.reportSecurityLoginConcern(event !== undefined ? { loginEventId: event.id } : {})
|
||||
if (group === undefined) {
|
||||
await accountClient.reportSecurityLoginConcern({})
|
||||
Analytics.handleEvent('Settings:RecentLoginActivityNotMe', {
|
||||
eventId: 'header-action',
|
||||
authMethod: 'unknown'
|
||||
})
|
||||
return
|
||||
}
|
||||
// When the user reports a coalesced row, every underlying
|
||||
// event is part of the concern.
|
||||
await Promise.allSettled(
|
||||
group.ids.map((loginEventId) => accountClient.reportSecurityLoginConcern({ loginEventId }))
|
||||
)
|
||||
Analytics.handleEvent('Settings:RecentLoginActivityNotMe', {
|
||||
eventId: event?.id ?? 'header-action',
|
||||
authMethod: event?.authMethod ?? 'unknown'
|
||||
eventId: group.event.id,
|
||||
authMethod: group.event.authMethod,
|
||||
groupSize: group.count
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
function formatCompactDate (eventTime: number): string {
|
||||
return compactFormatter.format(new Date(eventTime))
|
||||
}
|
||||
|
||||
function formatFullDate (eventTime: number): string {
|
||||
return new Date(eventTime).toLocaleString()
|
||||
}
|
||||
|
||||
function formatGroupRange (group: SecurityLoginHistoryGroup): string {
|
||||
if (group.count <= 1) return formatFullDate(group.lastEventTime)
|
||||
return `${formatFullDate(group.firstEventTime)} – ${formatFullDate(group.lastEventTime)}`
|
||||
}
|
||||
|
||||
function formatAuthMethod (method: SecurityAuthMethod): string {
|
||||
if (method === 'otp') return 'OTP'
|
||||
return method.charAt(0).toUpperCase() + method.slice(1)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadRecentLoginActivity()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex-col gap-2 max-w-240">
|
||||
<div class="flex-between">
|
||||
<h3 class="text-lg font-medium"><Label label={settingsRes.string.RecentLoginActivityTitle} /></h3>
|
||||
<section class="session-history w-full">
|
||||
<header class="session-history__header">
|
||||
<h3 class="session-history__title">
|
||||
<Label label={settingsRes.string.RecentLoginActivityTitle} />
|
||||
</h3>
|
||||
<Button
|
||||
label={settingsRes.string.NotMeAction}
|
||||
kind="secondary"
|
||||
kind={'ghost'}
|
||||
size={'small'}
|
||||
on:click={() => {
|
||||
handleNotMeAction()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if loginHistoryLoading}
|
||||
<div class="login-placeholder"><Label label={settingsRes.string.RecentLoginActivityLoading} /></div>
|
||||
<div class="login-placeholder"><Label label={settingsRes.string.RecentLoginActivityLoading} /></div>
|
||||
<div class="login-placeholder"><Label label={settingsRes.string.RecentLoginActivityLoading} /></div>
|
||||
</header>
|
||||
|
||||
{#if loginHistoryLoading && !loginHistoryLoaded}
|
||||
<ul class="session-history__list">
|
||||
{#each Array(3) as _}
|
||||
<li class="session-history__row session-history__row--placeholder" />
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if loginHistoryError}
|
||||
<div class="login-empty">
|
||||
<div class="session-history__state">
|
||||
<div><Label label={settingsRes.string.RecentLoginActivityError} /></div>
|
||||
<Button
|
||||
label={settingsRes.string.RecentLoginActivityRetry}
|
||||
kind="secondary"
|
||||
kind={'secondary'}
|
||||
size={'small'}
|
||||
on:click={() => {
|
||||
void loadRecentLoginActivity()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else if loginHistoryLoaded && loginHistory.length === 0}
|
||||
<div class="login-empty"><Label label={settingsRes.string.RecentLoginActivityEmpty} /></div>
|
||||
{:else if loginHistoryLoaded && groups.length === 0}
|
||||
<div class="session-history__state">
|
||||
<Label label={settingsRes.string.RecentLoginActivityEmpty} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-col gap-2">
|
||||
{#each loginHistory as loginEvent (loginEvent.id)}
|
||||
<div class="login-activity-row">
|
||||
<div class="flex-between gap-2">
|
||||
<span class="text-sm">{new Date(loginEvent.eventTime).toLocaleString()}</span>
|
||||
<span class:login-success={loginEvent.success} class:login-failed={!loginEvent.success}>
|
||||
{#if loginEvent.success}
|
||||
<Label label={settingsRes.string.RecentLoginActivitySuccess} />
|
||||
{:else}
|
||||
<Label label={settingsRes.string.RecentLoginActivityFailure} />
|
||||
{/if}
|
||||
<ul class="session-history__list">
|
||||
{#each groups as group (group.id)}
|
||||
<li class="session-history__row" class:session-history__row--failed={!group.event.success}>
|
||||
<span
|
||||
class="session-history__status"
|
||||
class:session-history__status--success={group.event.success}
|
||||
class:session-history__status--failed={!group.event.success}
|
||||
role="img"
|
||||
>
|
||||
<span class="session-history__sr-only">
|
||||
<Label
|
||||
label={group.event.success
|
||||
? settingsRes.string.RecentLoginActivitySuccess
|
||||
: settingsRes.string.RecentLoginActivityFailure}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<div class="session-history__body">
|
||||
<div class="session-history__line session-history__line--head">
|
||||
<time
|
||||
class="session-history__time"
|
||||
datetime={new Date(group.lastEventTime).toISOString()}
|
||||
title={formatFullDate(group.lastEventTime)}
|
||||
>
|
||||
{formatCompactDate(group.lastEventTime)}
|
||||
</time>
|
||||
<span class="session-history__sep" aria-hidden="true">·</span>
|
||||
<span class="session-history__method">{formatAuthMethod(group.event.authMethod)}</span>
|
||||
{#if group.count > 1}
|
||||
<span class="session-history__count" title={formatGroupRange(group)}>×{group.count}</span>
|
||||
{/if}
|
||||
{#if !group.event.success}
|
||||
<span class="session-history__failed-label">
|
||||
<Label label={settingsRes.string.RecentLoginActivityFailure} />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="session-history__line session-history__line--network">
|
||||
<span>{maskIpAddress(group.event.ip)}</span>
|
||||
<span class="session-history__sep" aria-hidden="true">·</span>
|
||||
<span>{formatLocation({ city: group.event.city, country: group.event.country })}</span>
|
||||
</div>
|
||||
<div class="session-history__line session-history__line--device" title={group.event.userAgent ?? ''}>
|
||||
{getShortUserAgent(group.event.userAgent)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm flex-row-center gap-1">
|
||||
<Label label={settingsRes.string.RecentLoginActivityMethod} />: {loginEvent.authMethod}
|
||||
</div>
|
||||
<div class="text-sm flex-row-center gap-1">
|
||||
<Label label={settingsRes.string.RecentLoginActivityIp} />: {maskIpAddress(loginEvent.ip)}
|
||||
</div>
|
||||
<div class="text-sm flex-row-center gap-1">
|
||||
<Label label={settingsRes.string.RecentLoginActivityLocation} />:
|
||||
{formatLocation({ city: loginEvent.city, country: loginEvent.country })}
|
||||
</div>
|
||||
<div class="text-sm flex-row-center gap-1">
|
||||
<Label label={settingsRes.string.RecentLoginActivityDevice} />:
|
||||
{getShortUserAgent(loginEvent.userAgent)}
|
||||
</div>
|
||||
{#if shouldShowNotMeAction(loginEvent)}
|
||||
<div class="mt-2">
|
||||
{#if shouldShowNotMeAction(group.event)}
|
||||
<div class="session-history__action">
|
||||
<Button
|
||||
label={settingsRes.string.NotMeAction}
|
||||
kind="secondary"
|
||||
kind={'secondary'}
|
||||
size={'small'}
|
||||
on:click={() => {
|
||||
handleNotMeAction(loginEvent)
|
||||
handleNotMeAction(group)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</div>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
.login-activity-row {
|
||||
border: 1px solid var(--theme-divider-color, var(--divider-color));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
.session-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
|
||||
.login-placeholder {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
.session-history__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--theme-divider-color, var(--divider-color));
|
||||
}
|
||||
|
||||
.session-history__title {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-caption-color, var(--caption-color));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-history__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.session-history__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.25rem;
|
||||
border-bottom: 1px solid var(--theme-divider-color, var(--divider-color));
|
||||
}
|
||||
.session-history__row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.session-history__row--placeholder {
|
||||
height: 4.5rem;
|
||||
background: var(--theme-button-default, var(--button-default));
|
||||
opacity: 0.35;
|
||||
border-radius: 0.375rem;
|
||||
border-bottom: none;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-empty {
|
||||
color: var(--caption-color);
|
||||
.session-history__status {
|
||||
flex: 0 0 auto;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--theme-text-secondary, var(--content-color));
|
||||
}
|
||||
.session-history__status--success {
|
||||
background: var(--theme-positive-color, #2e7d32);
|
||||
}
|
||||
.session-history__status--failed {
|
||||
background: var(--theme-danger-color, #d32f2f);
|
||||
}
|
||||
|
||||
.session-history__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.session-history__line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.session-history__line--head {
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-caption-color, var(--caption-color));
|
||||
}
|
||||
|
||||
.login-success {
|
||||
color: var(--theme-positive-color, #2e7d32);
|
||||
.session-history__line--network {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-content-color, var(--content-color));
|
||||
}
|
||||
|
||||
.login-failed {
|
||||
.session-history__line--device {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-darker-color, var(--dark-color));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.session-history__time {
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.session-history__method {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-history__sep {
|
||||
color: var(--theme-darker-color, var(--dark-color));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.session-history__count {
|
||||
color: var(--theme-caption-color, var(--caption-color));
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: var(--theme-button-default, var(--button-default));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
.session-history__failed-label {
|
||||
color: var(--theme-danger-color, #d32f2f);
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
.session-history__row--failed .session-history__time,
|
||||
.session-history__row--failed .session-history__method {
|
||||
color: var(--theme-danger-color, #d32f2f);
|
||||
}
|
||||
|
||||
.session-history__action {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.session-history__state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
color: var(--theme-caption-color, var(--caption-color));
|
||||
font-size: 0.875rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.session-history__sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="hulyComponent">
|
||||
<div class="hulyComponent w-full">
|
||||
<div class="flex-col p-6 gap-8 max-w-2xl">
|
||||
<div class="flex flex-between">
|
||||
<Label label={setting.string.TwoFactorAuthDescription} />
|
||||
|
||||
@@ -47,3 +47,64 @@ export function getShortUserAgent (userAgent?: string): string {
|
||||
export function shouldShowNotMeAction (event: Pick<SecurityLoginHistoryEvent, 'success'>): boolean {
|
||||
return event.success
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of consecutive login events that share the same observable
|
||||
* attributes (auth method, success, IP, location, user agent). Used to
|
||||
* collapse noisy runs — most commonly `authMethod: 'session'` events
|
||||
* recorded on every workspace switch — into a single row in the UI.
|
||||
*/
|
||||
export interface SecurityLoginHistoryGroup {
|
||||
id: string
|
||||
event: SecurityLoginHistoryEvent
|
||||
count: number
|
||||
firstEventTime: number
|
||||
lastEventTime: number
|
||||
ids: string[]
|
||||
}
|
||||
|
||||
function sameSignature (a: SecurityLoginHistoryEvent, b: SecurityLoginHistoryEvent): boolean {
|
||||
return (
|
||||
a.authMethod === b.authMethod &&
|
||||
a.success === b.success &&
|
||||
(a.ip ?? '') === (b.ip ?? '') &&
|
||||
(a.country ?? '') === (b.country ?? '') &&
|
||||
(a.city ?? '') === (b.city ?? '') &&
|
||||
(a.userAgent ?? '') === (b.userAgent ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for events safe to collapse into a previous identical row.
|
||||
*/
|
||||
function isCoalescable (event: SecurityLoginHistoryEvent): boolean {
|
||||
return event.success && event.authMethod === 'session'
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses consecutive same-signature events into a single group.
|
||||
* Input is expected to be ordered newest-first (matching the server
|
||||
* response).
|
||||
*/
|
||||
export function coalesceLoginHistory (events: SecurityLoginHistoryEvent[]): SecurityLoginHistoryGroup[] {
|
||||
const groups: SecurityLoginHistoryGroup[] = []
|
||||
for (const event of events) {
|
||||
const last = groups[groups.length - 1]
|
||||
if (last !== undefined && isCoalescable(event) && isCoalescable(last.event) && sameSignature(last.event, event)) {
|
||||
last.count += 1
|
||||
last.firstEventTime = Math.min(last.firstEventTime, event.eventTime)
|
||||
last.lastEventTime = Math.max(last.lastEventTime, event.eventTime)
|
||||
last.ids.push(event.id)
|
||||
continue
|
||||
}
|
||||
groups.push({
|
||||
id: event.id,
|
||||
event,
|
||||
count: 1,
|
||||
firstEventTime: event.eventTime,
|
||||
lastEventTime: event.eventTime,
|
||||
ids: [event.id]
|
||||
})
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
@@ -326,6 +326,7 @@ export default plugin(settingId, {
|
||||
DeleteMailbox: '' as IntlString,
|
||||
MailboxDeleteConfirmation: '' as IntlString,
|
||||
Security: '' as IntlString,
|
||||
SessionHistory: '' as IntlString,
|
||||
TwoFactorAuth: '' as IntlString,
|
||||
TwoFactorAuthDescription: '' as IntlString,
|
||||
EnableTwoFactorAuth: '' as IntlString,
|
||||
|
||||
@@ -3404,12 +3404,7 @@ function maskIpForApiResponse (ip?: string): string | undefined {
|
||||
|
||||
function redactSecurityLoginEventRow (row: SecurityLoginEvent): SecurityLoginEvent {
|
||||
const ua = row.userAgent?.trim() ?? ''
|
||||
const shortUa =
|
||||
ua === ''
|
||||
? undefined
|
||||
: ua.length <= UA_REDACT_LEN
|
||||
? ua
|
||||
: `${ua.slice(0, UA_REDACT_LEN - 1)}…`
|
||||
const shortUa = ua === '' ? undefined : ua.length <= UA_REDACT_LEN ? ua : `${ua.slice(0, UA_REDACT_LEN - 1)}…`
|
||||
return {
|
||||
...row,
|
||||
ip: maskIpForApiResponse(row.ip),
|
||||
@@ -3518,7 +3513,12 @@ export async function getWorkspaceSecurityLoginHistory (
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
assertSecurityLoginTelemetryRateLimit(account, 'getWorkspaceSecurityLoginHistory', 'SECURITY_LOGIN_HISTORY_READ_RPM', 120)
|
||||
assertSecurityLoginTelemetryRateLimit(
|
||||
account,
|
||||
'getWorkspaceSecurityLoginHistory',
|
||||
'SECURITY_LOGIN_HISTORY_READ_RPM',
|
||||
120
|
||||
)
|
||||
|
||||
const { since, until, success, ip } = params
|
||||
let accountUuid = params.accountUuid
|
||||
|
||||
@@ -20,7 +20,12 @@ function parsePositiveInt (raw: string | undefined, fallback: number): number {
|
||||
* In-process sliding-window rate limiter (per account + RPC name).
|
||||
* Multi-instance deployments only get per-process limits unless replaced with shared storage.
|
||||
*/
|
||||
export function assertSecurityLoginTelemetryRateLimit (accountKey: string, rpcName: string, envVar: string, fallbackRpm: number): void {
|
||||
export function assertSecurityLoginTelemetryRateLimit (
|
||||
accountKey: string,
|
||||
rpcName: string,
|
||||
envVar: string,
|
||||
fallbackRpm: number
|
||||
): void {
|
||||
const maxPerMinute = parsePositiveInt(process.env[envVar], fallbackRpm)
|
||||
const key = `${accountKey}:${rpcName}`
|
||||
const now = Date.now()
|
||||
|
||||
@@ -35,7 +35,9 @@ export class NoopPolicyEngine implements SecurityPolicyEngine {
|
||||
const { event, recentHistory } = input
|
||||
const anomalyCodes = new Set<string>()
|
||||
|
||||
const sameIpFailures = recentHistory.filter((entry) => !entry.success && entry.ip != null && event.ip != null && entry.ip === event.ip)
|
||||
const sameIpFailures = recentHistory.filter(
|
||||
(entry) => !entry.success && entry.ip != null && event.ip != null && entry.ip === event.ip
|
||||
)
|
||||
if (!event.success && sameIpFailures.length >= 4) {
|
||||
anomalyCodes.add('repeated_failed_attempts_from_ip')
|
||||
}
|
||||
@@ -129,7 +131,9 @@ export async function resolveSecurityPolicyEngine (ctx: MeasureContext): Promise
|
||||
| undefined
|
||||
|
||||
if (typeof createEngine !== 'function') {
|
||||
ctx.warn('SECURITY_POLICY_MODULE loaded but createSecurityPolicyEngine is missing, fallback to noop', { moduleName })
|
||||
ctx.warn('SECURITY_POLICY_MODULE loaded but createSecurityPolicyEngine is missing, fallback to noop', {
|
||||
moduleName
|
||||
})
|
||||
cachedPolicyEngine = new NoopPolicyEngine()
|
||||
return cachedPolicyEngine
|
||||
}
|
||||
|
||||
@@ -1741,10 +1741,7 @@ export async function purgeExpiredSecurityLoginEvents (
|
||||
if (rawLower === '0' || rawLower === 'off' || rawLower === 'false') {
|
||||
return
|
||||
}
|
||||
const days =
|
||||
rawTrim !== undefined && rawTrim !== ''
|
||||
? parseInt(rawTrim, 10)
|
||||
: DEFAULT_SECURITY_LOGIN_RETENTION_DAYS
|
||||
const days = rawTrim !== undefined && rawTrim !== '' ? parseInt(rawTrim, 10) : DEFAULT_SECURITY_LOGIN_RETENTION_DAYS
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user