Allow admins to link a viewer device by entering its pairing code.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-11 02:30:19 +02:00
parent 7869cd6661
commit 95a5c17a6c
3 changed files with 116 additions and 2 deletions

View file

@ -156,6 +156,7 @@ export default function ViewersPage() {
const [iptvLocalMsg, setIptvLocalMsg] = useState<Record<string, string | null>>({}); const [iptvLocalMsg, setIptvLocalMsg] = useState<Record<string, string | null>>({});
const [iptvLocalErr, setIptvLocalErr] = useState<Record<string, string | null>>({}); const [iptvLocalErr, setIptvLocalErr] = useState<Record<string, string | null>>({});
const [addonInstallUrls, setAddonInstallUrls] = useState<Record<string, string | null>>({}); const [addonInstallUrls, setAddonInstallUrls] = useState<Record<string, string | null>>({});
const [claimCodes, setClaimCodes] = useState<Record<string, string>>({});
const load = useCallback(() => { const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" }) 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) { async function unlockLogin(v: Viewer) {
setActionId(`unlock-${v.id}`); setActionId(`unlock-${v.id}`);
setErr(null); setErr(null);
@ -577,8 +607,8 @@ export default function ViewersPage() {
<p className="page-kicker">Mensen</p> <p className="page-kicker">Mensen</p>
<h1>Gebruikers</h1> <h1>Gebruikers</h1>
<p className="page-lead muted"> <p className="page-lead muted">
Unified accounts voor app, IPTV en Stremio-addons. Koppelen via{" "} Unified accounts voor app, IPTV en Stremio-addons. Apparaten koppelen via code hieronder
<Link href="/link">/link</Link> met een code. of via <Link href="/link">/link</Link>.
</p> </p>
</div> </div>
<button type="button" className="btn-primary" onClick={() => setShowCreate((v) => !v)}> <button type="button" className="btn-primary" onClick={() => setShowCreate((v) => !v)}>
@ -783,6 +813,41 @@ export default function ViewersPage() {
))} ))}
</ul> </ul>
)} )}
<div className="claim-code-row">
<p className="muted detail-copy">
Openstaande code van TV/app (zonder gebruikerswachtwoord).
</p>
<div className="claim-code-form">
<input
type="text"
inputMode="text"
autoComplete="off"
spellCheck={false}
placeholder="ABCD-EFGH"
value={claimCodes[v.id] ?? ""}
onChange={(e) =>
setClaimCodes((prev) => ({
...prev,
[v.id]: e.target.value.toUpperCase(),
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void claimDeviceCode(v.id);
}
}}
/>
<button
type="button"
className="btn-primary"
disabled={actionId === `claim-${v.id}` || !(claimCodes[v.id] ?? "").trim()}
onClick={() => void claimDeviceCode(v.id)}
>
{actionId === `claim-${v.id}` ? "Koppelen…" : "Code koppelen"}
</button>
</div>
</div>
</details> </details>
<div className="detail-grid"> <div className="detail-grid">
@ -1529,6 +1594,24 @@ export default function ViewersPage() {
.devices-fold .detail-copy { .devices-fold .detail-copy {
margin: 0.25rem 0 0.5rem; 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 { .device-rows {
list-style: none; list-style: none;
margin: 0.35rem 0 0; margin: 0.35rem 0 0;

View file

@ -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) ——— // ——— Device link (TV) ———
app.post( app.post(
"/api/v1/client/device/code", "/api/v1/client/device/code",

View file

@ -306,6 +306,20 @@ export class ViewerService {
* In-app goedkeuren: viewer is al ingelogd (Bearer) en bevestigt een TV/device-code. * In-app goedkeuren: viewer is al ingelogd (Bearer) en bevestigt een TV/device-code.
*/ */
async approveDeviceCode(viewerId: string, codeRaw: string) { 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 } }); const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId } });
if (!viewer || !viewer.enabled || !viewer.appAccess) { if (!viewer || !viewer.enabled || !viewer.appAccess) {
throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403); throw new AppError("FORBIDDEN", "Geen app-toegang voor dit account", 403);