"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 = { 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([]); const [playConfig, setPlayConfig] = useState(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(null); const [err, setErr] = useState(null); const [playEmails, setPlayEmails] = useState>({}); const [selectedId, setSelectedId] = useState(null); const [mobileShowDetail, setMobileShowDetail] = useState(false); const [query, setQuery] = useState(""); const [showCreate, setShowCreate] = useState(false); const [actionId, setActionId] = useState(null); const [iptvForms, setIptvForms] = useState>({}); const [iptvLocalMsg, setIptvLocalMsg] = useState>({}); const [iptvLocalErr, setIptvLocalErr] = useState>({}); const [addonInstallUrls, setAddonInstallUrls] = useState>({}); const [claimCodes, setClaimCodes] = useState>({}); const [newPasswords, setNewPasswords] = useState>({}); 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) { 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 ( <>