From 95a5c17a6c002761eff9f24b6d511c82bc1c925d Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Fri, 11 Sep 2026 02:30:19 +0200 Subject: [PATCH] Allow admins to link a viewer device by entering its pairing code. EOF Co-authored-by: Cursor --- apps/admin-ui/src/app/viewers/page.tsx | 87 +++++++++++++++++++++++++- apps/master-api/src/viewer/routes.ts | 17 +++++ apps/master-api/src/viewer/service.ts | 14 +++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx index fb10493..88aee49 100644 --- a/apps/admin-ui/src/app/viewers/page.tsx +++ b/apps/admin-ui/src/app/viewers/page.tsx @@ -156,6 +156,7 @@ export default function ViewersPage() { const [iptvLocalMsg, setIptvLocalMsg] = useState>({}); const [iptvLocalErr, setIptvLocalErr] = useState>({}); const [addonInstallUrls, setAddonInstallUrls] = useState>({}); + const [claimCodes, setClaimCodes] = useState>({}); const load = useCallback(() => { fetch("/api/v1/admin/viewers", { credentials: "include" }) @@ -519,6 +520,35 @@ export default function ViewersPage() { } } + async function claimDeviceCode(viewerId: string) { + const raw = (claimCodes[viewerId] ?? "").trim(); + if (!raw) { + setErr("Voer de koppelcode van het apparaat in"); + return; + } + setActionId(`claim-${viewerId}`); + setErr(null); + try { + const res = await fetch(`/api/v1/admin/viewers/${viewerId}/devices/claim`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: raw }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Code koppelen mislukt"); + return; + } + const name = data.deviceName ? ` (${data.deviceName})` : ""; + setMsg(`Apparaat gekoppeld${name} — de app/TV rondt zelf af`); + setClaimCodes((prev) => ({ ...prev, [viewerId]: "" })); + load(); + } finally { + setActionId(null); + } + } + async function unlockLogin(v: Viewer) { setActionId(`unlock-${v.id}`); setErr(null); @@ -577,8 +607,8 @@ export default function ViewersPage() {

Mensen

Gebruikers

- Unified accounts voor app, IPTV en Stremio-addons. Koppelen via{" "} - /link met een code. + Unified accounts voor app, IPTV en Stremio-addons. Apparaten koppelen via code hieronder + of via /link.

+ +
@@ -1529,6 +1594,24 @@ export default function ViewersPage() { .devices-fold .detail-copy { margin: 0.25rem 0 0.5rem; } + .claim-code-row { + margin-top: 0.65rem; + padding-top: 0.55rem; + border-top: 1px solid var(--border-dim); + } + .claim-code-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + } + .claim-code-form input { + flex: 1 1 10rem; + min-width: 8rem; + font-family: var(--font-mono); + letter-spacing: 0.06em; + text-transform: uppercase; + } .device-rows { list-style: none; margin: 0.35rem 0 0; diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 2572cea..6431c6f 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -285,6 +285,23 @@ export function registerViewerRoutes( } ); + /** Admin koppelt openstaande TV/app-code aan deze kijker (zonder gebruikerswachtwoord). */ + app.post( + "/api/v1/admin/viewers/:id/devices/claim", + { + preHandler: requireAdmin, + config: { + rateLimit: { max: 30, timeWindow: "1 minute" }, + }, + }, + async (request) => { + const { id } = request.params as { id: string }; + const body = (request.body || {}) as { code?: string }; + if (!body.code?.trim()) throw new AppError("INVALID_REQUEST", "code verplicht", 400); + return viewers.adminClaimDeviceCode(id, body.code); + } + ); + // ——— Device link (TV) ——— app.post( "/api/v1/client/device/code", diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 6e39c59..c268790 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -306,6 +306,20 @@ export class ViewerService { * In-app goedkeuren: viewer is al ingelogd (Bearer) en bevestigt een TV/device-code. */ async approveDeviceCode(viewerId: string, codeRaw: string) { + return this.claimLinkCodeForViewer(viewerId, codeRaw); + } + + /** + * Admin koppelt een openstaande app/TV-code aan een kijker (zonder diens wachtwoord). + * Zelfde effect als /link claim of in-app approve — het apparaat pollt daarna succesvol. + */ + async adminClaimDeviceCode(viewerId: string, codeRaw: string) { + const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } }); + if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404); + return this.claimLinkCodeForViewer(viewerId, codeRaw); + } + + private async claimLinkCodeForViewer(viewerId: string, codeRaw: string) { const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } }); if (!viewer || !viewer.enabled || !viewer.appAccess) { throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);