diff --git a/web/src/features/profile/components/__tests__/login-session-utils.test.ts b/web/src/features/profile/components/__tests__/login-session-utils.test.ts new file mode 100644 index 00000000..c75df750 --- /dev/null +++ b/web/src/features/profile/components/__tests__/login-session-utils.test.ts @@ -0,0 +1,98 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import type { TFunction } from 'i18next' + +import { loginMethodLabel, sessionDevice } from '../login-session-utils' + +const translate = ((key: string) => key) as TFunction + +describe('login session presentation', () => { + test('labels built-in and provider OAuth login methods', () => { + assert.equal(loginMethodLabel('password', translate), 'Password') + assert.equal( + loginMethodLabel('2fa', translate), + 'Two-factor Authentication' + ) + assert.equal(loginMethodLabel('oauth:github', translate), 'OAuth · GitHub') + assert.equal( + loginMethodLabel('oauth:custom-provider', translate), + 'OAuth · custom-provider' + ) + }) + + test('labels iPad Safari as iOS when its user agent also mentions Mac OS X', () => { + const userAgent = + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' + + assert.equal( + sessionDevice(userAgent, 'Unknown device', 'Browser'), + 'Safari · iOS' + ) + }) + + test('labels a touch-capable current iPad session as iOS when its desktop user agent says Macintosh', () => { + const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' + + assert.equal( + sessionDevice(userAgent, 'Unknown device', 'Browser', 5), + 'Safari · iOS' + ) + }) + + test('keeps touch-capable Windows Chrome sessions identifiable', () => { + const userAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' + + assert.equal( + sessionDevice(userAgent, 'Unknown device', 'Browser', 10), + 'Chrome · Windows' + ) + }) + + test('keeps Android Chrome sessions identifiable when their user agent mentions Linux', () => { + const userAgent = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36' + + assert.equal( + sessionDevice(userAgent, 'Unknown device', 'Browser', 5), + 'Chrome · Android' + ) + }) + + test('keeps genuine macOS Safari sessions identifiable', () => { + const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' + + assert.equal( + sessionDevice(userAgent, 'Unknown device', 'Browser'), + 'Safari · macOS' + ) + }) + + test('falls back to the unknown-device label for an empty user agent', () => { + assert.equal( + sessionDevice('', 'Unknown device', 'Browser'), + 'Unknown device' + ) + }) +}) diff --git a/web/src/features/profile/components/login-session-item.tsx b/web/src/features/profile/components/login-session-item.tsx index f3b8de54..a0aa1167 100644 --- a/web/src/features/profile/components/login-session-item.tsx +++ b/web/src/features/profile/components/login-session-item.tsx @@ -34,6 +34,10 @@ interface LoginSessionItemProps { export function LoginSessionItem({ session, onRevoke }: LoginSessionItemProps) { const { t } = useTranslation() + const maxTouchPoints = + session.current && typeof navigator !== 'undefined' + ? navigator.maxTouchPoints + : 0 return (
@@ -46,7 +50,8 @@ export function LoginSessionItem({ session, onRevoke }: LoginSessionItemProps) { {sessionDevice( session.user_agent, t('Unknown device'), - t('Browser') + t('Browser'), + maxTouchPoints )}

{session.current && {t('Current')}} diff --git a/web/src/features/profile/components/login-session-utils.test.ts b/web/src/features/profile/components/login-session-utils.test.ts deleted file mode 100644 index ec0b7161..00000000 --- a/web/src/features/profile/components/login-session-utils.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* -Copyright (C) 2023-2026 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' - -import type { TFunction } from 'i18next' - -import { loginMethodLabel, sessionDevice } from './login-session-utils' - -const translate = ((key: string) => key) as TFunction - -describe('login session presentation', () => { - test('labels built-in and provider OAuth login methods', () => { - assert.equal(loginMethodLabel('password', translate), 'Password') - assert.equal( - loginMethodLabel('2fa', translate), - 'Two-factor Authentication' - ) - assert.equal(loginMethodLabel('oauth:github', translate), 'OAuth · GitHub') - assert.equal( - loginMethodLabel('oauth:custom-provider', translate), - 'OAuth · custom-provider' - ) - }) - - test('derives a stable browser and operating-system label', () => { - assert.equal( - sessionDevice( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit Safari/605.1.15', - 'Unknown device', - 'Browser' - ), - 'Safari · macOS' - ) - assert.equal( - sessionDevice('', 'Unknown device', 'Browser'), - 'Unknown device' - ) - }) -}) diff --git a/web/src/features/profile/components/login-session-utils.ts b/web/src/features/profile/components/login-session-utils.ts index bb7fcbfd..2b327d09 100644 --- a/web/src/features/profile/components/login-session-utils.ts +++ b/web/src/features/profile/components/login-session-utils.ts @@ -21,7 +21,8 @@ import type { TFunction } from 'i18next' export function sessionDevice( userAgent: string, unknownDevice: string, - browserLabel: string + browserLabel: string, + maxTouchPoints = 0 ): string { if (!userAgent) return unknownDevice let browser = browserLabel @@ -31,12 +32,15 @@ export function sessionDevice( else if (userAgent.includes('Safari/')) browser = 'Safari' let system = '' - if (userAgent.includes('Windows')) system = 'Windows' - else if (userAgent.includes('Mac OS')) system = 'macOS' - else if (userAgent.includes('Android')) system = 'Android' - else if (userAgent.includes('iPhone') || userAgent.includes('iPad')) { + const isIPad = + userAgent.includes('iPad') || + (userAgent.includes('Macintosh') && maxTouchPoints > 1) + if (userAgent.includes('iPhone') || isIPad) { system = 'iOS' - } else if (userAgent.includes('Linux')) system = 'Linux' + } else if (userAgent.includes('Android')) system = 'Android' + else if (userAgent.includes('Windows')) system = 'Windows' + else if (userAgent.includes('Mac OS')) system = 'macOS' + else if (userAgent.includes('Linux')) system = 'Linux' return system ? `${browser} · ${system}` : browser }