fix(web): detect iPad login sessions correctly

This commit is contained in:
CaIon
2026-07-27 21:19:42 +08:00
parent a043eef559
commit b27b2b1d6f
4 changed files with 114 additions and 63 deletions
@@ -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 <https://www.gnu.org/licenses/>.
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'
)
})
})
@@ -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 (
<div className='flex flex-col gap-3 py-4 sm:flex-row sm:items-center'>
@@ -46,7 +50,8 @@ export function LoginSessionItem({ session, onRevoke }: LoginSessionItemProps) {
{sessionDevice(
session.user_agent,
t('Unknown device'),
t('Browser')
t('Browser'),
maxTouchPoints
)}
</p>
{session.current && <Badge variant='secondary'>{t('Current')}</Badge>}
@@ -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 <https://www.gnu.org/licenses/>.
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'
)
})
})
@@ -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
}