Scrum4Me/actions/pairing.ts
Madhura68 625221f9ee feat(ST-1005): add pairing server actions + mobile confirmation page
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 <username> 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) <noreply@anthropic.com>
2026-04-27 22:50:42 +02:00

141 lines
4.5 KiB
TypeScript

'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<LoadResult> {
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<ApprovalView | 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 }
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 }
}