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: {