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>
141 lines
4.5 KiB
TypeScript
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 }
|
|
}
|