'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' } // Cancel is een DB-write — onder de demo-write-block-regel. 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: 'cancelled' }, }) return { ok: true } }