diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx index 4ce7eb2..1b66845 100644 --- a/apps/admin-ui/src/app/viewers/page.tsx +++ b/apps/admin-ui/src/app/viewers/page.tsx @@ -496,12 +496,53 @@ export default function ViewersPage() { } async function revokeDevice(deviceId: string) { - if (!confirm("Apparaat intrekken? De app moet opnieuw worden gekoppeld.")) return; - await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, { - method: "POST", - credentials: "include", - }); - load(); + 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 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 (expandedId === v.id) setExpandedId(null); + setMsg(`${label} verwijderd`); + load(); + } finally { + setActionId(null); + } } return ( @@ -675,6 +716,44 @@ export default function ViewersPage() { {open && (
+
0 && v.deviceCount <= 3}> + + Apparaten + + {v.deviceCount === 0 + ? " · geen gekoppeld" + : ` · ${v.deviceCount} actief`} + + + {v.devices.length === 0 ? ( +

Nog geen gekoppelde apparaten.

+ ) : ( + + )} +
+

Account

@@ -730,6 +809,16 @@ export default function ViewersPage() {
+
+ +
@@ -993,32 +1082,6 @@ export default function ViewersPage() { )}
- -
-

Apparaten

- {v.devices.length === 0 ? ( -

Nog geen gekoppelde apparaten.

- ) : ( - - )} -
)} @@ -1365,13 +1428,41 @@ export default function ViewersPage() { word-break: break-all; color: var(--text-bright); } - .devices-block { - border-top: 1px solid var(--border-dim); - padding-top: 0.85rem; + .devices-fold { + margin-bottom: 0.85rem; + border: 1px solid var(--border-dim); + border-radius: 10px; + background: var(--bg-elevated); + padding: 0.35rem 0.75rem 0.65rem; + } + .devices-fold summary { + cursor: pointer; + list-style: none; + font-weight: 600; + font-size: 0.92rem; + padding: 0.45rem 0; + color: var(--text-bright); + user-select: none; + } + .devices-fold summary::-webkit-details-marker { + display: none; + } + .devices-fold summary::before { + content: "▸"; + display: inline-block; + width: 1rem; + color: var(--muted); + transition: transform 0.15s ease; + } + .devices-fold[open] summary::before { + transform: rotate(90deg); + } + .devices-fold .detail-copy { + margin: 0.25rem 0 0.5rem; } .device-rows { list-style: none; - margin: 0; + margin: 0.35rem 0 0; padding: 0; display: grid; gap: 0.45rem; @@ -1384,7 +1475,7 @@ export default function ViewersPage() { padding: 0.65rem 0.75rem; border-radius: 8px; border: 1px solid var(--border-dim); - background: var(--bg-elevated); + background: var(--bg); } .device-rows strong { display: block; diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 29ba8db..0a95fe8 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -61,6 +61,21 @@ export function registerViewerRoutes( return { viewer }; }); + app.delete("/api/v1/admin/viewers/:id", { preHandler: requireAdmin }, async (request) => { + const { id } = request.params as { id: string }; + const admin = (request as AuthenticatedRequest).user; + // Play-groep opruimen vóór cascade-delete + try { + await googlePlay.onViewerDisabled(id, { + adminUserId: admin.id, + adminEmail: admin.email, + }); + } catch { + // verwijderen mag niet blokkeren op Play-sync + } + return viewers.deleteViewer(id); + }); + app.get("/api/v1/admin/viewers/:id/iptv", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; const { prisma } = await import("../database/client"); diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 3c917af..fff5f80 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -116,7 +116,7 @@ export class ViewerService { const rows = await prisma.viewerUser.findMany({ orderBy: { createdAt: "desc" }, include: { - _count: { select: { devices: true } }, + _count: { select: { devices: { where: { revoked: false } } } }, iptvLine: { select: { id: true, @@ -190,6 +190,14 @@ export class ViewerService { })); } + async deleteViewer(id: string) { + const existing = await prisma.viewerUser.findUnique({ where: { id }, select: { id: true } }); + if (!existing) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404); + // Cascade: profiles, devices, prefs, favorites, iptv-lijn; addon-tokens blijven (viewerUserId → null) + await prisma.viewerUser.delete({ where: { id } }); + return { ok: true }; + } + async updateViewer( id: string, data: {