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>
This commit is contained in:
parent
2a0c6a512d
commit
625221f9ee
4 changed files with 512 additions and 0 deletions
171
__tests__/actions/pairing.test.ts
Normal file
171
__tests__/actions/pairing.test.ts
Normal file
|
|
@ -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<typeof vi.fn>
|
||||||
|
update: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
user: { findUnique: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
141
actions/pairing.ts
Normal file
141
actions/pairing.ts
Normal file
|
|
@ -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<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 }
|
||||||
|
}
|
||||||
26
app/(app)/m/pair/page.tsx
Normal file
26
app/(app)/m/pair/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<main className="container mx-auto max-w-md py-12">
|
||||||
|
<h1 className="text-2xl font-semibold">Inloggen op desktop</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Bevestig hieronder dat je wilt inloggen op het apparaat dat de QR-code
|
||||||
|
toont.
|
||||||
|
</p>
|
||||||
|
<PairConfirmation />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
174
app/(app)/m/pair/pair-confirmation.tsx
Normal file
174
app/(app)/m/pair/pair-confirmation.tsx
Normal file
|
|
@ -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<State>({ 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 (
|
||||||
|
<div className="text-muted-foreground mt-6 text-sm" aria-live="polite">
|
||||||
|
Pairing controleren…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind === 'invalid') {
|
||||||
|
return (
|
||||||
|
<div className="bg-destructive/10 text-destructive-foreground mt-6 rounded-md p-4">
|
||||||
|
<p className="font-medium">Kan deze QR-code niet gebruiken</p>
|
||||||
|
<p className="text-sm">{state.error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind === 'approved') {
|
||||||
|
return (
|
||||||
|
<div className="bg-primary/10 mt-6 rounded-md p-4">
|
||||||
|
<p className="font-medium">Klaar — je kunt deze tab sluiten.</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Het apparaat met de QR-code is nu ingelogd als <strong>{state.username}</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind === 'cancelled') {
|
||||||
|
return (
|
||||||
|
<div className="bg-muted text-muted-foreground mt-6 rounded-md p-4">
|
||||||
|
<p className="font-medium">Geannuleerd</p>
|
||||||
|
<p className="text-sm">Er is geen sessie aangemaakt op het andere apparaat.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card mt-6 rounded-md border p-4">
|
||||||
|
<p>
|
||||||
|
Wil je inloggen als <strong>{state.username}</strong> op dit apparaat?
|
||||||
|
</p>
|
||||||
|
<dl className="text-muted-foreground mt-3 space-y-1 text-sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="w-16 shrink-0">Browser:</dt>
|
||||||
|
<dd className="font-mono text-xs">{state.desktop_ua ?? 'onbekend'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="w-16 shrink-0">IP:</dt>
|
||||||
|
<dd className="font-mono text-xs">{state.desktop_ip ?? 'onbekend'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p className="text-muted-foreground mt-3 text-xs">
|
||||||
|
Bevestig alleen als je deze QR-code zelf op een eigen scherm ziet — geen
|
||||||
|
screenshot of foto van iemand anders.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Button onClick={onApprove} disabled={pending} className="flex-1">
|
||||||
|
Bevestig
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={pending}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Annuleer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue