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:
parent
7869cd6661
commit
95a5c17a6c
3 changed files with 116 additions and 2 deletions
|
|
@ -156,6 +156,7 @@ export default function ViewersPage() {
|
|||
const [iptvLocalMsg, setIptvLocalMsg] = useState<Record<string, string | null>>({});
|
||||
const [iptvLocalErr, setIptvLocalErr] = useState<Record<string, string | null>>({});
|
||||
const [addonInstallUrls, setAddonInstallUrls] = useState<Record<string, string | null>>({});
|
||||
const [claimCodes, setClaimCodes] = useState<Record<string, string>>({});
|
||||
|
||||
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() {
|
|||
<p className="page-kicker">Mensen</p>
|
||||
<h1>Gebruikers</h1>
|
||||
<p className="page-lead muted">
|
||||
Unified accounts voor app, IPTV en Stremio-addons. Koppelen via{" "}
|
||||
<Link href="/link">/link</Link> met een code.
|
||||
Unified accounts voor app, IPTV en Stremio-addons. Apparaten koppelen via code hieronder
|
||||
of via <Link href="/link">/link</Link>.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn-primary" onClick={() => setShowCreate((v) => !v)}>
|
||||
|
|
@ -783,6 +813,41 @@ export default function ViewersPage() {
|
|||
))}
|
||||
</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>
|
||||
|
||||
<div className="detail-grid">
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue