From 13ab53ab8d8dacabe0b16be65016653914344cc6 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 10:09:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(ST-1135):=20UA-redirect=20bij=20login=20?= =?UTF-8?q?=E2=80=94=20phone=20naar=20/m/*=20(T-322/T-323/T-324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- __tests__/actions/auth.test.ts | 85 ++++++++++++++++++++++++++++++++ __tests__/lib/user-agent.test.ts | 37 ++++++++++++++ actions/auth.ts | 8 +++ lib/user-agent.ts | 8 +++ 4 files changed, 138 insertions(+) create mode 100644 __tests__/actions/auth.test.ts create mode 100644 __tests__/lib/user-agent.test.ts create mode 100644 lib/user-agent.ts diff --git a/__tests__/actions/auth.test.ts b/__tests__/actions/auth.test.ts new file mode 100644 index 0000000..424e31b --- /dev/null +++ b/__tests__/actions/auth.test.ts @@ -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') + }) +}) diff --git a/__tests__/lib/user-agent.test.ts b/__tests__/lib/user-agent.test.ts new file mode 100644 index 0000000..f322b5c --- /dev/null +++ b/__tests__/lib/user-agent.test.ts @@ -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) + }) +}) diff --git a/actions/auth.ts b/actions/auth.ts index fc4a163..575178e 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { registerUser, verifyUser } from '@/lib/auth' import { SessionData, sessionOptions } from '@/lib/session' import { checkRateLimit } from '@/lib/rate-limit' +import { isPhoneUA } from '@/lib/user-agent' async function getClientIp(): Promise { const h = await headers() @@ -74,6 +75,13 @@ export async function loginAction(_prevState: unknown, formData: FormData) { session.isDemo = user.is_demo 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') } diff --git a/lib/user-agent.ts b/lib/user-agent.ts new file mode 100644 index 0000000..6fc1836 --- /dev/null +++ b/lib/user-agent.ts @@ -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') +}