Twee P1's uit code-review:
(1) pair/stream race: de findUnique die de pairing-status leest gebeurde vóór
LISTEN actief was. Als de mobiel approvet tussen die query en LISTEN: pg_notify
fired in dat venster gaat verloren (Postgres queuet niet voor abonnees die
nog niet listen) én was de eerder gelezen status stale. De catch-up state-
event emitte dus 'pending' terwijl de DB inmiddels 'approved' was, en de
desktop bleef hangen tot expiry.
Tweede findUnique toegevoegd ná LISTEN actief is: het venster sluit, omdat
elke approve na dat punt via de notify-handler doorkomt. Aanvullend op de
eerdere client-side fix die 'state' events nu ook routeert (commit d6e71f9).
(2) cancelPairing demo-block: cancel was een DB-write zonder demo-guard,
in tegenspraak met de "demo = 403 op writes"-regel. Demo-blokkade
toegevoegd; bestaande test omgedraaid naar 'wordt geblokkeerd, geen DB-write'.
Quality gates: lint 0 errors, tsc clean, vitest 139/139.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
143 lines
4.7 KiB
TypeScript
143 lines
4.7 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' }
|
|
// 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 }
|
|
}
|