From 625221f9eedaa451f4bfa506dbd181a1a9bdc4c0 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 27 Apr 2026 22:50:42 +0200 Subject: [PATCH] feat(ST-1005): add pairing server actions + mobile confirmation page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/pairing.ts (Server Actions, volgt docs/patterns/server-action.md): - getPairingForApproval(pairingId, mobileSecret): auth + Zod + lookup + status + expiry + verifyToken-check; retourneert UA/IP/username voor de bevestigingspagina. Demo MAG aanroepen (read-only). - approvePairing: zelfde checks PLUS demo-blokkade (session.isDemo). Update status pending→approved, zet user_id + approved_at, bumpt expires_at +5min. Postgres-trigger emit pg_notify automatisch — desktop-SSE pikt het op. - cancelPairing: status pending→cancelled. Demo mag annuleren. - Tagged-union return-type uit loadPendingPairing voor schone discriminatie. app/(app)/m/pair/page.tsx (Server Component, achter (app)/layout-guard): - Geen searchParams uitlezen — page leest URL niet. Alleen statische uitleg + PairConfirmation client-island. app/(app)/m/pair/pair-confirmation.tsx (Client Component): - useEffect parseert window.location.hash voor #id=…&s=… (server ziet de fragment nooit) - Roept getPairingForApproval om UA/IP/username op te halen - Toont kaart "Inloggen als op dit apparaat?" met UA + IP + expliciete waarschuwing tegen phishing-QR; Bevestig/Annuleer-knoppen - Na approve: window.history.replaceState wist de hash zodat back/forward de secret niet meer onthult; transitioneert naar success-state - queueMicrotask voor synchrone setState om React-Compiler "cascading renders" warning te vermijden Tests __tests__/actions/pairing.test.ts (11 cases): - getPairingForApproval: ok + 5 fail-paths (geen sessie, approved, verlopen, verkeerd secret, ongeldige cuid) - approvePairing: happy + demo-block + verkeerd secret (geen DB-write) - cancelPairing: happy + demo mag annuleren Quality gates: lint 0 errors, tsc clean, vitest 132/132. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/pairing.test.ts | 171 ++++++++++++++++++++++++ actions/pairing.ts | 141 ++++++++++++++++++++ app/(app)/m/pair/page.tsx | 26 ++++ app/(app)/m/pair/pair-confirmation.tsx | 174 +++++++++++++++++++++++++ 4 files changed, 512 insertions(+) create mode 100644 __tests__/actions/pairing.test.ts create mode 100644 actions/pairing.ts create mode 100644 app/(app)/m/pair/page.tsx create mode 100644 app/(app)/m/pair/pair-confirmation.tsx diff --git a/__tests__/actions/pairing.test.ts b/__tests__/actions/pairing.test.ts new file mode 100644 index 0000000..47b1db0 --- /dev/null +++ b/__tests__/actions/pairing.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + loginPairing: { + findUnique: vi.fn(), + update: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { hashToken } from '@/lib/auth/pairing' +import { + getPairingForApproval, + approvePairing, + cancelPairing, +} from '@/actions/pairing' + +const mockPrisma = prisma as unknown as { + loginPairing: { + findUnique: ReturnType + update: ReturnType + } + user: { findUnique: ReturnType } +} + +const VALID_PAIRING_ID = 'cmohmk0a' + 'g008bs417mzik8x9w'.padEnd(17, 'a').slice(0, 17) +const VALID_SECRET = 'A'.repeat(43) // ≥40 chars voor Zod min(40) +const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } + +beforeEach(() => { + vi.clearAllMocks() + mockPrisma.user.findUnique.mockResolvedValue({ username: 'lars' }) +}) + +function pendingPairing(secret = VALID_SECRET) { + return { + status: 'pending' as const, + expires_at: new Date(Date.now() + 60_000), + secret_hash: hashToken(secret), + desktop_ua: 'TestUA/1.0', + desktop_ip: '198.51.100.1', + } +} + +describe('actions/pairing', () => { + describe('getPairingForApproval', () => { + it('ok-pad: pending + correct secret → desktop-info + username', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing()) + + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ + ok: true, + desktop_ua: 'TestUA/1.0', + desktop_ip: '198.51.100.1', + username: 'lars', + }) + }) + + it('faalt zonder sessie', async () => { + mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Niet ingelogd' }) + }) + + it('faalt op al-approved pairing', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + ...pendingPairing(), + status: 'approved', + }) + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Pairing al afgehandeld' }) + }) + + it('faalt op verlopen pairing', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue({ + ...pendingPairing(), + expires_at: new Date(Date.now() - 1000), + }) + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Pairing verlopen' }) + }) + + it('faalt op verkeerd secret', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt')) + const res = await getPairingForApproval(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' }) + }) + + it('faalt op ongeldige cuid (Zod)', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + const res = await getPairingForApproval('niet-cuid', VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Ongeldige invoer' }) + expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled() + }) + }) + + describe('approvePairing', () => { + it('happy-pad: status pending→approved, user_id gezet, expires_at +5min', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing()) + mockPrisma.loginPairing.update.mockResolvedValue({}) + + const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: true }) + expect(mockPrisma.loginPairing.update).toHaveBeenCalledTimes(1) + const arg = mockPrisma.loginPairing.update.mock.calls[0][0] + expect(arg.where).toEqual({ id: VALID_PAIRING_ID }) + expect(arg.data.status).toBe('approved') + expect(arg.data.user_id).toBe('user-1') + const dt = new Date(arg.data.expires_at).getTime() - Date.now() + expect(dt).toBeGreaterThan(295_000) + expect(dt).toBeLessThan(305_000) + }) + + it('demo-user wordt geblokkeerd, geen DB-write', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' }) + expect(mockPrisma.loginPairing.findUnique).not.toHaveBeenCalled() + expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled() + }) + + it('faalt op verkeerd secret zonder DB-write', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing('echt')) + const res = await approvePairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: false, error: 'Ongeldig pairing-geheim' }) + expect(mockPrisma.loginPairing.update).not.toHaveBeenCalled() + }) + }) + + describe('cancelPairing', () => { + it('happy-pad: status pending→cancelled', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing()) + mockPrisma.loginPairing.update.mockResolvedValue({}) + + const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: true }) + const arg = mockPrisma.loginPairing.update.mock.calls[0][0] + expect(arg.data.status).toBe('cancelled') + }) + + it('demo-user mag annuleren', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + mockPrisma.loginPairing.findUnique.mockResolvedValue(pendingPairing()) + mockPrisma.loginPairing.update.mockResolvedValue({}) + + const res = await cancelPairing(VALID_PAIRING_ID, VALID_SECRET) + expect(res).toEqual({ ok: true }) + }) + }) +}) diff --git a/actions/pairing.ts b/actions/pairing.ts new file mode 100644 index 0000000..6f25c8a --- /dev/null +++ b/actions/pairing.ts @@ -0,0 +1,141 @@ +'use server' + +// ST-1005: Server Actions voor de mobiele zijde van de QR-pairing-flow (M10). +// +// Aangeroepen door de Client Component op /m/pair zodra die het #id=…&s=… +// fragment uit de URL heeft geparsed. De mobiele gebruiker is hier al +// geauthenticeerd via de bestaande iron-session (de page zit achter de +// (app)/layout.tsx-guard). +// +// Volgt docs/patterns/server-action.md: getSession + Zod + demo-guard +// (uitsluitend op approvePairing — read-only en cancel mag iedereen). + +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { verifyToken } from '@/lib/auth/pairing' + +const APPROVED_TTL_MS = 5 * 60 * 1000 + +const inputSchema = z.object({ + pairingId: z.string().cuid(), + mobileSecret: z.string().min(40), // 32 bytes base64url ≈ 43 chars +}) + +type ActionFail = { ok: false; error: string } +type ApprovalView = { + ok: true + desktop_ua: string | null + desktop_ip: string | null + username: string +} + +type PendingPairing = { + status: string + expires_at: Date + secret_hash: string + desktop_ua: string | null + desktop_ip: string | null +} + +type LoadResult = + | { kind: 'error'; error: string } + | { kind: 'ok'; pairing: PendingPairing } + +async function loadPendingPairing( + pairingId: string, + mobileSecret: string, +): Promise { + const pairing = await prisma.loginPairing.findUnique({ + where: { id: pairingId }, + select: { + status: true, + expires_at: true, + secret_hash: true, + desktop_ua: true, + desktop_ip: true, + }, + }) + if (!pairing) return { kind: 'error', error: 'Pairing niet gevonden' } + if (pairing.expires_at < new Date()) return { kind: 'error', error: 'Pairing verlopen' } + if (pairing.status !== 'pending') return { kind: 'error', error: 'Pairing al afgehandeld' } + if (!verifyToken(mobileSecret, pairing.secret_hash)) { + return { kind: 'error', error: 'Ongeldig pairing-geheim' } + } + return { kind: 'ok', pairing } +} + +export async function getPairingForApproval( + pairingId: string, + mobileSecret: string, +): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + + const parsed = inputSchema.safeParse({ pairingId, mobileSecret }) + if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } + + const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret) + if (result.kind === 'error') return { ok: false, error: result.error } + + const me = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { username: true }, + }) + return { + ok: true, + desktop_ua: result.pairing.desktop_ua, + desktop_ip: result.pairing.desktop_ip, + username: me?.username ?? '', + } +} + +export async function approvePairing( + pairingId: string, + mobileSecret: string, +): Promise<{ ok: true } | ActionFail> { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } + + const parsed = inputSchema.safeParse({ pairingId, mobileSecret }) + if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } + + const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret) + if (result.kind === 'error') return { ok: false, error: result.error } + + await prisma.loginPairing.update({ + where: { id: parsed.data.pairingId }, + data: { + status: 'approved', + user_id: session.userId, + approved_at: new Date(), + expires_at: new Date(Date.now() + APPROVED_TTL_MS), + }, + }) + // Postgres-trigger emit pg_notify('scrum4me_pairing', …) automatisch — de + // desktop-SSE in ST-1004 vangt het op. Geen revalidatePath nodig: deze page + // heeft geen server-state om te ververversen, en de mobiele tab gaat naar + // de "klaar"-state direct in de Client Component. + return { ok: true } +} + +export async function cancelPairing( + pairingId: string, + mobileSecret: string, +): Promise<{ ok: true } | ActionFail> { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + + const parsed = inputSchema.safeParse({ pairingId, mobileSecret }) + if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } + + const result = await loadPendingPairing(parsed.data.pairingId, parsed.data.mobileSecret) + if (result.kind === 'error') return { ok: false, error: result.error } + + await prisma.loginPairing.update({ + where: { id: parsed.data.pairingId }, + data: { status: 'cancelled' }, + }) + return { ok: true } +} diff --git a/app/(app)/m/pair/page.tsx b/app/(app)/m/pair/page.tsx new file mode 100644 index 0000000..ee665c2 --- /dev/null +++ b/app/(app)/m/pair/page.tsx @@ -0,0 +1,26 @@ +// ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10). +// +// Server Component achter de bestaande (app)/layout.tsx auth-guard — onbekende +// mobielen worden eerst naar /login gestuurd. Bewust géén searchParams +// uitlezen: het mobileSecret zit in het URL-fragment (#id=…&s=…), wat alleen +// client-side leesbaar is. De Client Component PairConfirmation parseert +// location.hash en doet de Server Action-calls. + +import { PairConfirmation } from './pair-confirmation' + +export const metadata = { + title: 'Inloggen op desktop', +} + +export default function PairPage() { + return ( +
+

Inloggen op desktop

+

+ Bevestig hieronder dat je wilt inloggen op het apparaat dat de QR-code + toont. +

+ +
+ ) +} diff --git a/app/(app)/m/pair/pair-confirmation.tsx b/app/(app)/m/pair/pair-confirmation.tsx new file mode 100644 index 0000000..fc5c42e --- /dev/null +++ b/app/(app)/m/pair/pair-confirmation.tsx @@ -0,0 +1,174 @@ +'use client' + +// ST-1005: Mobiele bevestigings-island voor de QR-pairing-flow (M10). +// +// De QR-URL is /m/pair#id=…&s=… — de fragment wordt door browsers nooit naar +// de server gestuurd, dus alleen client-side leesbaar via location.hash. Hier +// halen we 'm op, doen via Server Action de bevestigings-roundtrip, en wissen +// de hash zodra de approve gelukt is zodat back/forward de secret niet meer +// onthult. + +import { useEffect, useState, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { toast } from 'sonner' +import { + getPairingForApproval, + approvePairing, + cancelPairing, +} from '@/actions/pairing' + +type State = + | { kind: 'loading' } + | { kind: 'invalid'; error: string } + | { + kind: 'ready' + pairingId: string + mobileSecret: string + desktop_ua: string | null + desktop_ip: string | null + username: string + } + | { kind: 'approved'; username: string } + | { kind: 'cancelled' } + +function parseHash(): { id: string; s: string } | null { + if (typeof window === 'undefined') return null + const raw = window.location.hash.replace(/^#/, '') + if (!raw) return null + const params = new URLSearchParams(raw) + const id = params.get('id') + const s = params.get('s') + return id && s ? { id, s } : null +} + +function clearHash() { + if (typeof window === 'undefined') return + window.history.replaceState(null, '', window.location.pathname + window.location.search) +} + +export function PairConfirmation() { + const [state, setState] = useState({ kind: 'loading' }) + const [pending, startTransition] = useTransition() + + useEffect(() => { + const parsed = parseHash() + if (!parsed) { + queueMicrotask(() => { + setState({ kind: 'invalid', error: 'Ongeldige of ontbrekende pairing-link' }) + }) + return + } + void getPairingForApproval(parsed.id, parsed.s).then((res) => { + if (!res.ok) { + setState({ kind: 'invalid', error: res.error }) + return + } + setState({ + kind: 'ready', + pairingId: parsed.id, + mobileSecret: parsed.s, + desktop_ua: res.desktop_ua, + desktop_ip: res.desktop_ip, + username: res.username, + }) + }) + }, []) + + function onApprove() { + if (state.kind !== 'ready') return + startTransition(async () => { + const res = await approvePairing(state.pairingId, state.mobileSecret) + if (!res.ok) { + toast.error(res.error) + return + } + clearHash() + setState({ kind: 'approved', username: state.username }) + }) + } + + function onCancel() { + if (state.kind !== 'ready') return + startTransition(async () => { + const res = await cancelPairing(state.pairingId, state.mobileSecret) + if (!res.ok) { + toast.error(res.error) + return + } + clearHash() + setState({ kind: 'cancelled' }) + }) + } + + if (state.kind === 'loading') { + return ( +
+ Pairing controleren… +
+ ) + } + + if (state.kind === 'invalid') { + return ( +
+

Kan deze QR-code niet gebruiken

+

{state.error}

+
+ ) + } + + if (state.kind === 'approved') { + return ( +
+

Klaar — je kunt deze tab sluiten.

+

+ Het apparaat met de QR-code is nu ingelogd als {state.username}. +

+
+ ) + } + + if (state.kind === 'cancelled') { + return ( +
+

Geannuleerd

+

Er is geen sessie aangemaakt op het andere apparaat.

+
+ ) + } + + return ( +
+

+ Wil je inloggen als {state.username} op dit apparaat? +

+
+
+
Browser:
+
{state.desktop_ua ?? 'onbekend'}
+
+
+
IP:
+
{state.desktop_ip ?? 'onbekend'}
+
+
+

+ Bevestig alleen als je deze QR-code zelf op een eigen scherm ziet — geen + screenshot of foto van iemand anders. +

+
+ + +
+
+ ) +}