stremio/apps/admin-ui/src/app/viewers/page.tsx

1913 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
type Device = {
id: string;
name: string;
platform: string;
lastSeenAt: string | null;
createdAt: string;
};
type GooglePlaySyncStatus =
| "NOT_ENABLED"
| "PENDING_ADD"
| "ACTIVE"
| "PENDING_REMOVE"
| "REMOVED"
| "ERROR";
type ViewerIptv = {
id: string;
label: string | null;
baseUrl: string;
username: string;
hasPassword: boolean;
enableLive: boolean;
enableVod: boolean;
enableSeries: boolean;
lastOkAt: string | null;
lastError: string | null;
ok: boolean;
};
type AddonTokenRow = {
id: string;
label: string | null;
revoked: boolean;
createdAt: string;
hasOwnIptv: boolean;
};
type Viewer = {
id: string;
email: string;
name: string | null;
enabled: boolean;
appAccess: boolean;
iptvAccess: boolean;
iptvAddonTokenId: string | null;
googlePlayEmail: string | null;
googlePlayAccess: boolean;
googlePlayMembershipId: string | null;
googlePlaySyncStatus: GooglePlaySyncStatus;
googlePlayLastSyncedAt: string | null;
googlePlaySyncError: string | null;
googlePlayResolvedEmail: string;
createdAt: string;
deviceCount: number;
devices: Device[];
iptv?: ViewerIptv | null;
addonTokens?: AddonTokenRow[];
loginFailedCount?: number;
loginLocked?: boolean;
loginLockedUntil?: string | null;
};
type IptvFormState = {
label: string;
baseUrl: string;
username: string;
password: string;
enableLive: boolean;
enableVod: boolean;
enableSeries: boolean;
};
type PlayConfig = {
configured: boolean;
groupEmail: string | null;
groupId: string | null;
optInUrl: string | null;
};
const MASTER_BASE = "https://master.vonas.nl";
const SYNC_LABELS: Record<GooglePlaySyncStatus, string> = {
NOT_ENABLED: "Uit",
PENDING_ADD: "Bezig…",
ACTIVE: "Actief",
PENDING_REMOVE: "Bezig…",
REMOVED: "Uit",
ERROR: "Fout",
};
function formatWhen(iso: string | null) {
if (!iso) return null;
return new Date(iso).toLocaleString("nl-NL", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function emptyIptvForm(): IptvFormState {
return {
label: "",
baseUrl: "",
username: "",
password: "",
enableLive: true,
enableVod: true,
enableSeries: true,
};
}
function formFromIptv(iptv: ViewerIptv | null | undefined): IptvFormState {
if (!iptv) return emptyIptvForm();
return {
label: iptv.label ?? "",
baseUrl: iptv.baseUrl ?? "",
username: iptv.username ?? "",
password: "",
enableLive: iptv.enableLive ?? true,
enableVod: iptv.enableVod ?? true,
enableSeries: iptv.enableSeries ?? true,
};
}
function iptvBadge(iptv: ViewerIptv | null | undefined): { label: string; tone: "ok" | "bad" | "off" } {
if (!iptv) return { label: "IPTV geen", tone: "off" };
if (iptv.ok) return { label: "IPTV OK", tone: "ok" };
return { label: "IPTV fout", tone: "bad" };
}
function playTone(v: Viewer): "ok" | "warn" | "bad" | "off" {
if (v.googlePlaySyncStatus === "ERROR") return "bad";
if (v.googlePlayAccess && v.googlePlaySyncStatus === "ACTIVE") return "ok";
if (v.googlePlayAccess) return "warn";
return "off";
}
export default function ViewersPage() {
useAuth();
const [viewers, setViewers] = useState<Viewer[]>([]);
const [playConfig, setPlayConfig] = useState<PlayConfig | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [playEmails, setPlayEmails] = useState<Record<string, string>>({});
const [selectedId, setSelectedId] = useState<string | null>(null);
const [mobileShowDetail, setMobileShowDetail] = useState(false);
const [query, setQuery] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [iptvForms, setIptvForms] = useState<Record<string, IptvFormState>>({});
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 [newPasswords, setNewPasswords] = useState<Record<string, string>>({});
const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const list: Viewer[] = d.viewers ?? [];
setViewers(list);
setPlayEmails((prev) => {
const next = { ...prev };
for (const v of list) {
if (next[v.id] === undefined) next[v.id] = v.googlePlayEmail ?? "";
}
return next;
});
})
.catch(() => setErr("Kon gebruikers niet laden"));
}, []);
useEffect(() => {
load();
fetch("/api/v1/admin/google-play-access/config", { credentials: "include" })
.then((r) => r.json())
.then((d) => setPlayConfig(d.config ?? null))
.catch(() => undefined);
}, [load]);
const stats = useMemo(() => {
const active = viewers.filter((v) => v.enabled).length;
const appOn = viewers.filter((v) => v.appAccess).length;
const playOn = viewers.filter((v) => v.googlePlayAccess).length;
const playErr = viewers.filter((v) => v.googlePlaySyncStatus === "ERROR").length;
return { total: viewers.length, active, appOn, playOn, playErr };
}, [viewers]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return viewers;
return viewers.filter((v) => {
const hay = `${v.name ?? ""} ${v.email}`.toLowerCase();
return hay.includes(q);
});
}, [viewers, query]);
const selected = useMemo(
() => viewers.find((v) => v.id === selectedId) ?? null,
[viewers, selectedId]
);
useEffect(() => {
if (viewers.length === 0) {
setSelectedId(null);
return;
}
if (selectedId && viewers.some((v) => v.id === selectedId)) return;
const first = viewers[0];
setSelectedId(first.id);
setIptvForms((prev) =>
prev[first.id] ? prev : { ...prev, [first.id]: formFromIptv(first.iptv) }
);
}, [viewers, selectedId]);
function selectViewer(v: Viewer) {
setSelectedId(v.id);
setMobileShowDetail(true);
setIptvForms((prev) => ({ ...prev, [v.id]: formFromIptv(v.iptv) }));
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
}
function patchIptvForm(id: string, patch: Partial<IptvFormState>) {
setIptvForms((prev) => ({
...prev,
[id]: { ...(prev[id] ?? emptyIptvForm()), ...patch },
}));
}
async function createViewer(e: FormEvent) {
e.preventDefault();
setBusy(true);
setMsg(null);
setErr(null);
try {
const res = await fetch("/api/v1/admin/viewers", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name: name || undefined, appAccess: true }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Aanmaken mislukt");
return;
}
setMsg(`${data.viewer.email} aangemaakt`);
setEmail("");
setPassword("");
setName("");
setShowCreate(false);
setSelectedId(data.viewer.id);
setMobileShowDetail(true);
setIptvForms((prev) => ({ ...prev, [data.viewer.id]: emptyIptvForm() }));
load();
} finally {
setBusy(false);
}
}
async function toggle(v: Viewer, field: "enabled" | "appAccess" | "iptvAccess") {
setActionId(v.id);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: !v[field] }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErr(data.error?.message ?? "Bijwerken mislukt");
}
load();
} finally {
setActionId(null);
}
}
async function saveIptv(v: Viewer) {
const form = iptvForms[v.id] ?? emptyIptvForm();
setActionId(v.id);
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/iptv`, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: form.label.trim() || undefined,
baseUrl: form.baseUrl.trim(),
username: form.username.trim(),
password: form.password.trim() || undefined,
enableLive: form.enableLive,
enableVod: form.enableVod,
enableSeries: form.enableSeries,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data.error?.message ?? "IPTV opslaan mislukt";
setIptvLocalErr((prev) => ({ ...prev, [v.id]: message }));
setErr(message);
return;
}
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: "IPTV-lijn opgeslagen" }));
setMsg("IPTV-lijn opgeslagen");
patchIptvForm(v.id, { password: "" });
load();
} finally {
setActionId(null);
}
}
async function testIptv(v: Viewer) {
setActionId(v.id);
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/iptv/test`, {
method: "POST",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data.error?.message ?? "IPTV-test mislukt";
setIptvLocalErr((prev) => ({ ...prev, [v.id]: message }));
setErr(message);
load();
return;
}
const summary = `OK · live ${data.liveCategories ?? 0} · vod ${data.vodCategories ?? 0} · series ${data.seriesCategories ?? 0}`;
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: summary }));
setMsg("IPTV-test gelukt");
load();
} finally {
setActionId(null);
}
}
async function deleteIptv(v: Viewer) {
if (!confirm("IPTV-lijn ontkoppelen voor deze gebruiker?")) return;
setActionId(v.id);
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: null }));
setIptvLocalErr((prev) => ({ ...prev, [v.id]: null }));
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/iptv`, {
method: "DELETE",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data.error?.message ?? "Ontkoppelen mislukt";
setIptvLocalErr((prev) => ({ ...prev, [v.id]: message }));
setErr(message);
return;
}
setIptvForms((prev) => ({ ...prev, [v.id]: emptyIptvForm() }));
setIptvLocalMsg((prev) => ({ ...prev, [v.id]: "IPTV-lijn ontkoppeld" }));
setMsg("IPTV-lijn ontkoppeld");
load();
} finally {
setActionId(null);
}
}
async function createAddonToken(v: Viewer) {
setActionId(v.id);
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/addon-tokens`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: v.name || v.email }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Addon aanmaken mislukt");
return;
}
const installPath = typeof data.installUrl === "string" ? data.installUrl : "";
const fullUrl = installPath.startsWith("http")
? installPath
: `${MASTER_BASE}${installPath.startsWith("/") ? "" : "/"}${installPath}`;
setAddonInstallUrls((prev) => ({ ...prev, [v.id]: fullUrl }));
setMsg("Stremio-addon aangemaakt — kopieer de installatie-URL (eenmalig)");
load();
} finally {
setActionId(null);
}
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
setMsg("Gekopieerd naar klembord");
} catch {
setErr("Kopiëren mislukt");
}
}
async function setPlayAccess(v: Viewer, enabled: boolean) {
setActionId(v.id);
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enabled,
email: playEmails[v.id]?.trim() || null,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Google Play bijwerken mislukt");
} else {
setMsg(
enabled
? data.syncError
? "Instelling opgeslagen — synchronisatie mislukt, probeer opnieuw"
: "Google Play-toegang ingeschakeld"
: data.syncError
? "Instelling opgeslagen — synchronisatie mislukt, probeer opnieuw"
: "Google Play-toegang uitgeschakeld"
);
if (data.syncError) setErr(data.syncError);
}
load();
} finally {
setActionId(null);
}
}
async function savePlayEmail(v: Viewer) {
setActionId(v.id);
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enabled: v.googlePlayAccess,
email: playEmails[v.id]?.trim() || null,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "E-mail opslaan mislukt");
} else {
setMsg("Google Play-e-mail opgeslagen");
if (data.syncError) setErr(data.syncError);
}
load();
} finally {
setActionId(null);
}
}
async function retryPlaySync(v: Viewer) {
setActionId(v.id);
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access/retry`, {
method: "POST",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Synchronisatie mislukt");
} else {
setMsg(data.syncError ? "Synchronisatie mislukt" : "Synchronisatie gelukt");
if (data.syncError) setErr(data.syncError);
}
load();
} finally {
setActionId(null);
}
}
async function bulkAction(path: "sync-all" | "reconcile") {
setBulkBusy(true);
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/google-play-access/${path}`, {
method: "POST",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Actie mislukt");
return;
}
const s = data.summary;
setMsg(
`${path === "sync-all" ? "Sync" : "Reconcile"}: ${s.scanned} gecontroleerd · ${s.correct} ok · ${s.added} toegevoegd · ${s.removed} verwijderd · ${s.errors} fouten`
);
load();
} finally {
setBulkBusy(false);
}
}
async function revokeDevice(deviceId: string) {
if (!confirm("Apparaat loskoppelen? De app moet opnieuw worden gekoppeld.")) return;
setActionId(deviceId);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErr(data.error?.message ?? "Apparaat loskoppelen mislukt");
return;
}
setMsg("Apparaat losgekoppeld");
load();
} finally {
setActionId(null);
}
}
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 deviceName = data.deviceName ? ` (${data.deviceName})` : "";
setMsg(`Apparaat gekoppeld${deviceName} — de app/TV rondt zelf af`);
setClaimCodes((prev) => ({ ...prev, [viewerId]: "" }));
load();
} finally {
setActionId(null);
}
}
async function unlockLogin(v: Viewer) {
setActionId(`unlock-${v.id}`);
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}/unlock-login`, {
method: "POST",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Ontgrendelen mislukt");
return;
}
setMsg(`Login ontgrendeld voor ${v.email}`);
load();
} finally {
setActionId(null);
}
}
async function changeViewerPassword(v: Viewer) {
const next = (newPasswords[v.id] ?? "").trim();
if (next.length < 8) {
setErr("Nieuw wachtwoord minstens 8 tekens");
return;
}
setActionId(`pw-${v.id}`);
setMsg(null);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: next }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Wachtwoord wijzigen mislukt");
return;
}
setNewPasswords((prev) => ({ ...prev, [v.id]: "" }));
setMsg(`Wachtwoord bijgewerkt voor ${v.email}`);
load();
} finally {
setActionId(null);
}
}
async function deleteViewer(v: Viewer) {
const label = v.name || v.email;
if (
!confirm(
`Gebruiker “${label}” definitief verwijderen?\n\nApparaten, profielen, IPTV-lijn en voortgang verdwijnen. Stremio-addons blijven bestaan maar zonder koppeling.`
)
) {
return;
}
setActionId(v.id);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/${v.id}`, {
method: "DELETE",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Verwijderen mislukt");
return;
}
if (selectedId === v.id) {
setSelectedId(null);
setMobileShowDetail(false);
}
setMsg(`${label} verwijderd`);
load();
} finally {
setActionId(null);
}
}
const iptv = selected?.iptv ?? null;
const iptvForm = selected
? iptvForms[selected.id] ?? formFromIptv(iptv)
: emptyIptvForm();
const tokens = selected?.addonTokens ?? [];
const installUrl = selected ? addonInstallUrls[selected.id] ?? null : null;
const badge = iptvBadge(iptv);
const pTone = selected ? playTone(selected) : "off";
return (
<>
<Nav />
<div className="container viewers-page">
<header className="page-header">
<div>
<p className="page-kicker">Mensen</p>
<h1>Gebruikers</h1>
<p className="page-lead muted">
Kies links een gebruiker · beheer rechts in blokken · apparaten via code of{" "}
<Link href="/link">/link</Link>.
</p>
</div>
<button type="button" className="btn-primary" onClick={() => setShowCreate((v) => !v)}>
{showCreate ? "Sluiten" : "Nieuwe gebruiker"}
</button>
</header>
<div className="stat-strip">
<span>
<strong>{stats.total}</strong> totaal
</span>
<span>
<strong>{stats.active}</strong> actief
</span>
<span>
<strong>{stats.appOn}</strong> app
</span>
<span>
<strong>{stats.playOn}</strong> play
{stats.playErr > 0 ? <em className="stat-warn"> · {stats.playErr} fout</em> : null}
</span>
</div>
{(msg || err) && (
<div className={`flash ${err ? "flash-err" : "flash-ok"}`} role="status">
{err ? err : msg}
<button
type="button"
className="flash-close"
onClick={() => {
setMsg(null);
setErr(null);
}}
>
×
</button>
</div>
)}
{showCreate && (
<section className="card create-card">
<h2 className="card-subtitle">Nieuwe gebruiker</h2>
<form className="form-grid" onSubmit={createViewer}>
<label>
Naam
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Jos" />
</label>
<label>
Account-e-mail
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="off"
/>
</label>
<label>
Wachtwoord
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<div className="create-actions">
<button type="submit" className="btn-primary" disabled={busy}>
{busy ? "Bezig…" : "Aanmaken"}
</button>
</div>
</form>
</section>
)}
<div className={`workspace${mobileShowDetail && selected ? " show-detail" : ""}`}>
<aside className="sidebar">
<div className="sidebar-top">
<label className="search-field">
<span className="sr-only">Zoeken</span>
<input
type="search"
placeholder="Zoek naam of e-mail…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<div className="sidebar-tools">
{playConfig?.configured ? (
<>
<button type="button" disabled={bulkBusy} onClick={() => void bulkAction("sync-all")}>
Sync Play
</button>
<button type="button" disabled={bulkBusy} onClick={() => void bulkAction("reconcile")}>
Reconcile
</button>
</>
) : (
<Link href="/settings?tab=google-play" className="setup-link">
Play instellen →
</Link>
)}
</div>
</div>
{filtered.length === 0 ? (
<p className="muted empty">
{viewers.length === 0
? "Nog geen gebruikers."
: "Geen treffers voor deze zoekopdracht."}
</p>
) : (
<ul className="user-list">
{filtered.map((v) => {
const active = selectedId === v.id;
const ib = iptvBadge(v.iptv);
return (
<li key={v.id}>
<button
type="button"
className={`user-row${active ? " active" : ""}${!v.enabled ? " dim" : ""}`}
onClick={() => selectViewer(v)}
aria-current={active ? "true" : undefined}
>
<div className="user-row-main">
<strong>{v.name || v.email}</strong>
{v.name ? <span>{v.email}</span> : null}
</div>
<div className="user-row-meta">
<span className={`dot ${v.enabled ? "ok" : "off"}`} title="Account" />
<span className={`dot ${v.appAccess ? "ok" : "off"}`} title="App" />
<span className={`dot ${ib.tone}`} title={ib.label} />
<span className={`dot ${playTone(v)}`} title="Play" />
<span className="device-count">{v.deviceCount}</span>
</div>
</button>
</li>
);
})}
</ul>
)}
</aside>
<section className="detail-pane" aria-live="polite">
{!selected ? (
<div className="detail-empty">
<p className="muted">Selecteer links een gebruiker om te beheren.</p>
</div>
) : (
<>
<div className="detail-head">
<button
type="button"
className="back-btn"
onClick={() => setMobileShowDetail(false)}
>
← Lijst
</button>
<div className="detail-who">
<h2>{selected.name || selected.email}</h2>
{selected.name ? <p className="muted">{selected.email}</p> : null}
</div>
<div className="detail-status">
<span className={`pill ${selected.enabled ? "ok" : "off"}`}>
{selected.enabled ? "Account aan" : "Account uit"}
</span>
<span className={`pill ${selected.appAccess ? "ok" : "off"}`}>
{selected.appAccess ? "App aan" : "App uit"}
</span>
<span className={`pill ${badge.tone}`}>{badge.label}</span>
<span className={`pill ${pTone}`}>
Play {SYNC_LABELS[selected.googlePlaySyncStatus]}
</span>
{selected.loginLocked ? <span className="pill bad">Login vergrendeld</span> : null}
</div>
</div>
<div className="blocks">
<article className="block">
<header className="block-head">
<h3>Apparaten</h3>
<span className="muted">
{selected.deviceCount === 0
? "geen gekoppeld"
: `${selected.deviceCount} actief`}
</span>
</header>
{selected.devices.length === 0 ? (
<p className="muted detail-copy">Nog geen gekoppelde apparaten.</p>
) : (
<ul className="device-rows">
{selected.devices.map((d) => (
<li key={d.id}>
<div>
<strong>{d.name}</strong>
<span className="muted">
{d.platform}
{d.lastSeenAt
? ` · laatst gezien ${formatWhen(d.lastSeenAt)}`
: ""}
</span>
</div>
<button
type="button"
className="danger"
disabled={actionId === d.id}
onClick={() => void revokeDevice(d.id)}
>
Loskoppelen
</button>
</li>
))}
</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[selected.id] ?? ""}
onChange={(e) =>
setClaimCodes((prev) => ({
...prev,
[selected.id]: e.target.value.toUpperCase(),
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void claimDeviceCode(selected.id);
}
}}
/>
<button
type="button"
className="btn-primary"
disabled={
actionId === `claim-${selected.id}` ||
!(claimCodes[selected.id] ?? "").trim()
}
onClick={() => void claimDeviceCode(selected.id)}
>
{actionId === `claim-${selected.id}` ? "Koppelen…" : "Code koppelen"}
</button>
</div>
</div>
</article>
<article className="block">
<header className="block-head">
<h3>Account</h3>
</header>
<p className="muted detail-copy">
Bepaalt of deze gebruiker kan inloggen en de app-inhoud mag gebruiken.
</p>
<div
className={
selected.loginLocked || (selected.loginFailedCount ?? 0) > 0
? "login-lock-box"
: "login-lock-box idle"
}
>
<p>
{selected.loginLocked
? `Login vergrendeld tot ${
selected.loginLockedUntil
? new Date(selected.loginLockedUntil).toLocaleString("nl-NL")
: "…"
} (3 foute pogingen).`
: (selected.loginFailedCount ?? 0) > 0
? `${selected.loginFailedCount} foute wachtwoordpoging${
(selected.loginFailedCount ?? 0) === 1 ? "" : "en"
} (lock na 3).`
: "Geen login-blokkade actief. Ontgrendelen wist foute-pogingen-teller."}
</p>
<button
type="button"
disabled={actionId === `unlock-${selected.id}`}
onClick={() => void unlockLogin(selected)}
>
{actionId === `unlock-${selected.id}`
? "Ontgrendelen…"
: "Login ontgrendelen"}
</button>
</div>
<div className="form-group password-change">
<label className="field">
Nieuw wachtwoord
<input
type="password"
autoComplete="new-password"
placeholder="Minstens 8 tekens"
value={newPasswords[selected.id] ?? ""}
onChange={(e) =>
setNewPasswords((prev) => ({
...prev,
[selected.id]: e.target.value,
}))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void changeViewerPassword(selected);
}
}}
/>
</label>
<button
type="button"
className="btn-primary"
disabled={
actionId === `pw-${selected.id}` ||
!(newPasswords[selected.id] ?? "").trim()
}
onClick={() => void changeViewerPassword(selected)}
>
{actionId === `pw-${selected.id}`
? "Opslaan…"
: "Wachtwoord wijzigen"}
</button>
</div>
<div className="toggle-rows">
<div className="toggle-row">
<div>
<strong>Account actief</strong>
<span className="muted">Uitschakelen blokkeert login</span>
</div>
<button
type="button"
className={`switch ${selected.enabled ? "on" : ""}`}
disabled={actionId === selected.id}
onClick={() => void toggle(selected, "enabled")}
aria-pressed={selected.enabled}
>
<span />
</button>
</div>
<div className="toggle-row">
<div>
<strong>App-toegang</strong>
<span className="muted">Toegang tot content in de app</span>
</div>
<button
type="button"
className={`switch ${selected.appAccess ? "on" : ""}`}
disabled={actionId === selected.id}
onClick={() => void toggle(selected, "appAccess")}
aria-pressed={selected.appAccess}
>
<span />
</button>
</div>
<div className="toggle-row">
<div>
<strong>Live TV (IPTV)</strong>
<span className="muted">Aan alleen met IPTV-lijn hiernaast</span>
</div>
<button
type="button"
className={`switch ${selected.iptvAccess ? "on" : ""}`}
disabled={actionId === selected.id || !selected.appAccess}
onClick={() => void toggle(selected, "iptvAccess")}
aria-pressed={selected.iptvAccess}
>
<span />
</button>
</div>
</div>
<div className="detail-actions">
<button
type="button"
className="danger"
disabled={actionId === selected.id}
onClick={() => void deleteViewer(selected)}
>
Gebruiker verwijderen
</button>
</div>
</article>
<article className="block">
<header className="block-head">
<h3>IPTV · Xtream / XUI</h3>
</header>
<p className="muted detail-copy">
Eigen IPTV-lijn voor apps en gekoppelde Stremio-addons.
</p>
<div className="form-group">
<label className="field">
Label
<input
value={iptvForm.label}
onChange={(e) => patchIptvForm(selected.id, { label: e.target.value })}
placeholder="Thuis / XUI"
/>
</label>
<label className="field">
Base URL
<input
value={iptvForm.baseUrl}
onChange={(e) => patchIptvForm(selected.id, { baseUrl: e.target.value })}
placeholder="http://host:port"
inputMode="url"
/>
</label>
<label className="field">
Gebruikersnaam
<input
value={iptvForm.username}
onChange={(e) =>
patchIptvForm(selected.id, { username: e.target.value })
}
autoComplete="off"
/>
</label>
<label className="field">
Wachtwoord
<input
type="password"
value={iptvForm.password}
onChange={(e) =>
patchIptvForm(selected.id, { password: e.target.value })
}
placeholder={
iptv?.hasPassword ? "•••••••• (leeg = behouden)" : "Wachtwoord"
}
autoComplete="new-password"
/>
</label>
<div className="check-rows">
<label className="check-row">
<input
type="checkbox"
checked={iptvForm.enableLive}
onChange={(e) =>
patchIptvForm(selected.id, { enableLive: e.target.checked })
}
/>
Live
</label>
<label className="check-row">
<input
type="checkbox"
checked={iptvForm.enableVod}
onChange={(e) =>
patchIptvForm(selected.id, { enableVod: e.target.checked })
}
/>
VOD
</label>
<label className="check-row">
<input
type="checkbox"
checked={iptvForm.enableSeries}
onChange={(e) =>
patchIptvForm(selected.id, { enableSeries: e.target.checked })
}
/>
Series
</label>
</div>
</div>
{iptv?.lastError && (
<p className="text-bad sync-err">Laatste fout: {iptv.lastError}</p>
)}
{iptvLocalErr[selected.id] && (
<p className="text-bad sync-err">{iptvLocalErr[selected.id]}</p>
)}
{iptvLocalMsg[selected.id] && (
<p className="muted detail-copy">{iptvLocalMsg[selected.id]}</p>
)}
{iptv?.lastOkAt && !iptv.lastError && (
<p className="muted field-hint">Laatst OK: {formatWhen(iptv.lastOkAt)}</p>
)}
<div className="detail-actions">
<button
type="button"
className="btn-primary"
disabled={actionId === selected.id}
onClick={() => void saveIptv(selected)}
>
Opslaan
</button>
<button
type="button"
disabled={actionId === selected.id || !iptv}
onClick={() => void testIptv(selected)}
>
Testen
</button>
<button
type="button"
className="danger"
disabled={actionId === selected.id || !iptv}
onClick={() => void deleteIptv(selected)}
>
Ontkoppelen
</button>
</div>
</article>
<article className="block">
<header className="block-head">
<h3>Stremio-addon</h3>
</header>
<p className="muted detail-copy">
Stremio gebruikt de IPTV-lijn van deze gebruiker.
</p>
{tokens.length === 0 ? (
<p className="muted">Nog geen addon-tokens.</p>
) : (
<ul className="addon-rows">
{tokens.map((t) => (
<li key={t.id}>
<div>
<strong>{t.label || "Addon"}</strong>
<span className="muted">
{t.revoked ? "Ingetrokken" : "Actief"}
{t.hasOwnIptv ? " · eigen IPTV" : ""}
{t.createdAt ? ` · aangemaakt ${formatWhen(t.createdAt)}` : ""}
</span>
</div>
</li>
))}
</ul>
)}
<div className="detail-actions">
<button
type="button"
className="btn-primary"
disabled={actionId === selected.id}
onClick={() => void createAddonToken(selected)}
>
Addon aanmaken
</button>
</div>
{installUrl && (
<div className="install-box">
<p className="muted field-hint">
Installatie-URL (eenmalig zichtbaar — kopieer nu):
</p>
<code className="install-url">{installUrl}</code>
<button type="button" onClick={() => void copyText(installUrl)}>
Kopiëren
</button>
</div>
)}
</article>
<article className="block">
<header className="block-head">
<h3>Google Play</h3>
</header>
{!playConfig?.configured ? (
<p className="muted detail-copy">
Nog niet geconfigureerd. Ga naar{" "}
<Link href="/settings?tab=google-play">Instellingen → Google Play</Link>.
</p>
) : (
<>
<p className="muted detail-copy">
Alleen installatierecht via Closed Testing — los van app-login.
</p>
<label className="field">
Google Play-e-mail
<input
type="email"
placeholder={selected.email}
value={playEmails[selected.id] ?? ""}
onChange={(e) =>
setPlayEmails((prev) => ({
...prev,
[selected.id]: e.target.value,
}))
}
/>
</label>
<p className="field-hint muted">
Leeg = account-e-mail (
{selected.googlePlayResolvedEmail || selected.email})
</p>
<div className="play-status-line">
<span>
Status:{" "}
<strong className={pTone === "bad" ? "text-bad" : ""}>
{SYNC_LABELS[selected.googlePlaySyncStatus]}
</strong>
</span>
{selected.googlePlayLastSyncedAt && (
<span className="muted">
Laatst: {formatWhen(selected.googlePlayLastSyncedAt)}
</span>
)}
</div>
{selected.googlePlaySyncError && (
<p className="text-bad sync-err">{selected.googlePlaySyncError}</p>
)}
<div className="detail-actions">
<button
type="button"
disabled={actionId === selected.id}
onClick={() => void savePlayEmail(selected)}
>
E-mail opslaan
</button>
{selected.googlePlayAccess ? (
<button
type="button"
className="danger"
disabled={actionId === selected.id}
onClick={() => void setPlayAccess(selected, false)}
>
Play uit
</button>
) : (
<button
type="button"
className="btn-primary"
disabled={actionId === selected.id}
onClick={() => void setPlayAccess(selected, true)}
>
Play aan
</button>
)}
{selected.googlePlaySyncStatus === "ERROR" && (
<button
type="button"
disabled={actionId === selected.id}
onClick={() => void retryPlaySync(selected)}
>
Opnieuw syncen
</button>
)}
{selected.googlePlayAccess &&
selected.googlePlaySyncStatus === "ACTIVE" &&
playConfig.optInUrl && (
<a
href={playConfig.optInUrl}
target="_blank"
rel="noopener noreferrer"
className="optin-link"
>
Opt-in link
</a>
)}
</div>
</>
)}
</article>
</div>
</>
)}
</section>
</div>
</div>
<style jsx>{`
.viewers-page {
max-width: 1440px;
}
.stat-strip {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1.25rem;
margin: -0.5rem 0 1.1rem;
padding: 0.65rem 0.9rem;
border-radius: var(--radius);
border: 1px solid var(--border-dim);
background: var(--bg-panel);
font-size: 0.85rem;
color: var(--text-muted);
}
.stat-strip strong {
color: var(--text-bright);
font-family: var(--font-mono);
font-weight: 600;
margin-right: 0.25rem;
}
.stat-warn {
color: var(--warn);
font-style: normal;
}
.flash {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.9rem;
border: 1px solid transparent;
}
.flash-ok {
background: var(--ok-bg);
border-color: rgba(52, 211, 153, 0.35);
color: var(--ok);
}
.flash-err {
background: var(--danger-bg);
border-color: rgba(244, 63, 94, 0.35);
color: #fda4af;
}
.flash-close {
border: none;
background: transparent;
color: inherit;
font-size: 1.2rem;
line-height: 1;
padding: 0 0.2rem;
cursor: pointer;
opacity: 0.8;
}
.create-card {
margin-bottom: 1rem;
}
.card-subtitle {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-dim);
margin: 0 0 0.85rem;
}
.create-actions {
display: flex;
align-items: end;
}
.workspace {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
min-height: 60vh;
}
.sidebar {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
max-height: calc(100vh - 2rem);
padding: 0.75rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background:
linear-gradient(160deg, rgba(34, 211, 238, 0.04), transparent 45%),
var(--bg-panel);
box-shadow: var(--shadow-panel);
}
.sidebar-top {
display: grid;
gap: 0.55rem;
flex-shrink: 0;
}
.search-field input {
width: 100%;
}
.sidebar-tools {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.sidebar-tools button {
font-size: 0.75rem;
padding: 0.35rem 0.55rem;
}
.setup-link {
font-size: 0.8rem;
}
.empty {
padding: 1rem 0.25rem;
text-align: center;
}
.user-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
display: grid;
gap: 0.3rem;
min-height: 0;
}
.user-row {
width: 100%;
display: grid;
gap: 0.35rem;
padding: 0.7rem 0.75rem;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.user-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.user-row.active {
background: var(--accent-dim);
border-color: var(--border-strong);
}
.user-row.dim strong {
color: var(--text-muted);
}
.user-row-main {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.user-row-main strong {
color: var(--text-bright);
font-size: 0.92rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row-main span {
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-row-meta {
display: flex;
align-items: center;
gap: 0.35rem;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
}
.dot.ok {
background: var(--ok);
}
.dot.warn {
background: var(--warn);
}
.dot.bad {
background: var(--danger);
}
.dot.off {
background: rgba(148, 163, 184, 0.35);
}
.device-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
}
.detail-pane {
min-width: 0;
padding: 1rem 1.1rem 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
background:
linear-gradient(145deg, rgba(34, 211, 238, 0.03), transparent 35%),
var(--bg-panel);
box-shadow: var(--shadow-panel);
}
.detail-empty {
display: grid;
place-items: center;
min-height: 280px;
}
.detail-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem 1rem;
margin-bottom: 1.1rem;
padding-bottom: 0.9rem;
border-bottom: 1px solid var(--border-dim);
}
.back-btn {
display: none;
}
.detail-who {
flex: 1 1 12rem;
min-width: 0;
}
.detail-who h2 {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-bright);
font-family: var(--font-ui);
text-transform: none;
}
.detail-who p {
margin: 0.15rem 0 0;
}
.detail-status {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.pill {
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.28rem 0.5rem;
border-radius: 999px;
border: 1px solid transparent;
white-space: nowrap;
}
.pill.ok {
color: var(--ok);
background: var(--ok-bg);
border-color: rgba(52, 211, 153, 0.25);
}
.pill.warn {
color: var(--warn);
background: rgba(251, 191, 36, 0.12);
border-color: rgba(251, 191, 36, 0.28);
}
.pill.bad {
color: #fda4af;
background: var(--danger-bg);
border-color: rgba(244, 63, 94, 0.3);
}
.pill.off {
color: var(--text-muted);
background: rgba(148, 163, 184, 0.08);
border-color: var(--border-dim);
}
.blocks {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.block {
display: grid;
gap: 0.65rem;
align-content: start;
padding: 0.95rem 1rem;
border-radius: 10px;
border: 1px solid var(--border-dim);
background: var(--bg-panel-2);
}
.block:first-child {
grid-column: 1 / -1;
}
.block-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.block-head h3 {
margin: 0;
font-size: 0.95rem;
color: var(--text-bright);
}
.detail-copy {
font-size: 0.82rem;
line-height: 1.4;
}
.login-lock-box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(248, 113, 113, 0.35);
background: rgba(248, 113, 113, 0.08);
margin-bottom: 0.85rem;
}
.login-lock-box.idle {
border-color: var(--border-dim);
background: var(--bg-elevated);
}
.login-lock-box p {
margin: 0;
flex: 1;
min-width: 180px;
font-size: 0.88rem;
}
.password-change {
margin-bottom: 0.85rem;
padding: 0.75rem 0.9rem;
border-radius: 10px;
border: 1px solid var(--border-dim);
background: var(--bg-elevated);
}
.password-change .btn-primary {
justify-self: start;
}
.toggle-rows {
display: grid;
gap: 0.55rem;
}
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
border: 1px solid var(--border-dim);
background: var(--bg-elevated);
}
.toggle-row strong {
display: block;
font-size: 0.9rem;
}
.toggle-row .muted {
display: block;
font-size: 0.75rem;
margin-top: 0.1rem;
}
.switch {
width: 44px;
height: 26px;
border-radius: 999px;
border: 1px solid var(--border-dim);
background: rgba(148, 163, 184, 0.18);
padding: 2px;
cursor: pointer;
flex-shrink: 0;
}
.switch span {
display: block;
width: 20px;
height: 20px;
border-radius: 50%;
background: #cbd5e1;
transition: transform 0.15s ease;
}
.switch.on {
background: rgba(52, 211, 153, 0.35);
border-color: rgba(52, 211, 153, 0.55);
}
.switch.on span {
transform: translateX(18px);
background: var(--ok);
}
.form-group {
display: grid;
gap: 0.55rem;
}
.field {
display: grid;
gap: 0.3rem;
font-size: 0.82rem;
color: var(--text-muted);
}
.field input {
width: 100%;
}
.field-hint {
font-size: 0.75rem;
margin-top: -0.2rem;
}
.check-rows {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
}
.check-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-muted);
cursor: pointer;
}
.play-status-line {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.85rem;
}
.text-bad {
color: #fda4af;
}
.sync-err {
font-size: 0.8rem;
word-break: break-word;
}
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
}
.optin-link {
font-size: 0.85rem;
}
.addon-rows,
.device-rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
}
.addon-rows li,
.device-rows li {
padding: 0.65rem 0.75rem;
border-radius: 8px;
border: 1px solid var(--border-dim);
background: var(--bg-elevated);
}
.device-rows li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.addon-rows strong,
.device-rows strong {
display: block;
font-size: 0.9rem;
}
.addon-rows .muted,
.device-rows .muted {
display: block;
font-size: 0.75rem;
margin-top: 0.1rem;
}
.install-box {
display: grid;
gap: 0.4rem;
padding: 0.7rem 0.8rem;
border-radius: 8px;
border: 1px dashed var(--border);
background: var(--bg-elevated);
}
.install-url {
display: block;
font-family: var(--font-mono);
font-size: 0.75rem;
word-break: break-all;
color: var(--text-bright);
}
.claim-code-row {
margin-top: 0.25rem;
padding-top: 0.65rem;
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;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
@media (max-width: 980px) {
.blocks {
grid-template-columns: 1fr;
}
.block:first-child {
grid-column: auto;
}
}
@media (max-width: 820px) {
.workspace {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
max-height: none;
}
.detail-pane {
display: none;
}
.workspace.show-detail .sidebar {
display: none;
}
.workspace.show-detail .detail-pane {
display: block;
}
.back-btn {
display: inline-flex;
align-items: center;
font-size: 0.85rem;
padding: 0.35rem 0.6rem;
}
}
@media (max-width: 560px) {
.page-header {
flex-direction: column;
align-items: stretch;
}
.page-header > button {
width: 100%;
}
}
`}</style>
</>
);
}