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
}