feat(ST-1135): UA-redirect bij login — phone naar /m/* (T-322/T-323/T-324)

- lib/user-agent.ts (nieuw): isPhoneUA() — Mobi-substring heuristiek
  (telefoons hebben Mobi, tablets/desktop niet)
- actions/auth.ts loginAction: leest user-agent header na session.save();
  phone-UA + actief product → /m/products/[id]/solo, zonder → /m/settings;
  tablet/desktop/null-UA → /dashboard (ongewijzigd)
- Tests: 7 helper-cases + 6 loginAction-paden incl. demo-user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 10:09:09 +02:00
parent 479a502dfd
commit 13ab53ab8d
4 changed files with 138 additions and 0 deletions

View file

@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { redirectMock, verifyUserMock, headerGetMock, sessionSaveMock } = vi.hoisted(() => ({
redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }),
verifyUserMock: vi.fn(),
headerGetMock: vi.fn(),
sessionSaveMock: vi.fn(),
}))
vi.mock('next/navigation', () => ({ redirect: redirectMock }))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({}),
headers: vi.fn().mockResolvedValue({ get: headerGetMock }),
}))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({
userId: '',
isDemo: false,
save: sessionSaveMock,
}),
}))
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: 't' } }))
vi.mock('@/lib/auth', () => ({
verifyUser: verifyUserMock,
registerUser: vi.fn(),
}))
vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) }))
import { loginAction } from '@/actions/auth'
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) Mobile/15E148 Safari/604.1'
const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) Safari/604.1'
const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/124.0.0.0 Safari/537.36'
function fd(username: string, password: string) {
const f = new FormData()
f.set('username', username)
f.set('password', password)
return f
}
beforeEach(() => {
redirectMock.mockClear()
verifyUserMock.mockReset()
headerGetMock.mockReset()
sessionSaveMock.mockReset()
})
describe('loginAction UA-redirect', () => {
it('phone-UA + actief product → /m/products/[id]/solo', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(IPHONE_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
})
it('phone-UA zonder actief product → /m/settings', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: null })
headerGetMock.mockReturnValue(IPHONE_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/settings')
})
it('tablet-UA (iPad) → /dashboard', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(IPAD_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
})
it('desktop-UA → /dashboard', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(DESKTOP_UA)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
})
it('geen UA-header → /dashboard', async () => {
verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' })
headerGetMock.mockReturnValue(null)
await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard')
})
it('demo-user op phone volgt dezelfde routing', async () => {
verifyUserMock.mockResolvedValue({ id: 'demo', is_demo: true, active_product_id: 'p1' })
headerGetMock.mockReturnValue(IPHONE_UA)
await expect(loginAction(undefined, fd('demo', 'demo123pw'))).rejects.toThrow('REDIRECT:/m/products/p1/solo')
})
})

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { isPhoneUA } from '@/lib/user-agent'
describe('isPhoneUA', () => {
it('iPhone Safari Mobile → true', () => {
const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1'
expect(isPhoneUA(ua)).toBe(true)
})
it('Android Chrome (phone) → true', () => {
const ua = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36'
expect(isPhoneUA(ua)).toBe(true)
})
it('iPad → false (geen Mobi)', () => {
const ua = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/604.1'
expect(isPhoneUA(ua)).toBe(false)
})
it('Android tablet (Galaxy Tab) → false', () => {
const ua = 'Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
expect(isPhoneUA(ua)).toBe(false)
})
it('Desktop Chrome → false', () => {
const ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
expect(isPhoneUA(ua)).toBe(false)
})
it('null → false', () => {
expect(isPhoneUA(null)).toBe(false)
})
it('lege string → false', () => {
expect(isPhoneUA('')).toBe(false)
})
})

View file

@ -7,6 +7,7 @@ import { z } from 'zod'
import { registerUser, verifyUser } from '@/lib/auth' import { registerUser, verifyUser } from '@/lib/auth'
import { SessionData, sessionOptions } from '@/lib/session' import { SessionData, sessionOptions } from '@/lib/session'
import { checkRateLimit } from '@/lib/rate-limit' import { checkRateLimit } from '@/lib/rate-limit'
import { isPhoneUA } from '@/lib/user-agent'
async function getClientIp(): Promise<string> { async function getClientIp(): Promise<string> {
const h = await headers() const h = await headers()
@ -74,6 +75,13 @@ export async function loginAction(_prevState: unknown, formData: FormData) {
session.isDemo = user.is_demo session.isDemo = user.is_demo
await session.save() await session.save()
// PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell.
// Tablets en desktop volgen het bestaande /dashboard-pad.
const ua = (await headers()).get('user-agent')
if (isPhoneUA(ua)) {
redirect(user.active_product_id ? `/m/products/${user.active_product_id}/solo` : '/m/settings')
}
redirect('/dashboard') redirect('/dashboard')
} }

8
lib/user-agent.ts Normal file
View file

@ -0,0 +1,8 @@
// PBI-11 / ST-1135: detecteert telefoon-UA's voor login-redirect.
// Heuristiek: 'Mobi' in de UA-string. Zit in Android Chrome en iPhone Safari
// Mobile, NIET in iPad of Android-tablet — exact wat we willen voor de
// `/m/*`-mobile-shell (alleen telefoons, geen tablets).
export function isPhoneUA(ua: string | null): boolean {
return ua !== null && ua.includes('Mobi')
}