Add user delete and collapsible device unlink in admin Gebruikers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-08 00:53:05 +02:00
parent a431bebf3b
commit a6fa9c22fb
3 changed files with 152 additions and 38 deletions

View file

@ -496,12 +496,53 @@ export default function ViewersPage() {
} }
async function revokeDevice(deviceId: string) { async function revokeDevice(deviceId: string) {
if (!confirm("Apparaat intrekken? De app moet opnieuw worden gekoppeld.")) return; if (!confirm("Apparaat loskoppelen? De app moet opnieuw worden gekoppeld.")) return;
await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, { setActionId(deviceId);
setErr(null);
try {
const res = await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
}); });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErr(data.error?.message ?? "Apparaat loskoppelen mislukt");
return;
}
setMsg("Apparaat losgekoppeld");
load(); 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 ( return (
@ -675,6 +716,44 @@ export default function ViewersPage() {
{open && ( {open && (
<div className="viewer-detail"> <div className="viewer-detail">
<details className="devices-fold" open={v.deviceCount > 0 && v.deviceCount <= 3}>
<summary>
Apparaten
<span className="muted">
{v.deviceCount === 0
? " · geen gekoppeld"
: ` · ${v.deviceCount} actief`}
</span>
</summary>
{v.devices.length === 0 ? (
<p className="muted detail-copy">Nog geen gekoppelde apparaten.</p>
) : (
<ul className="device-rows">
{v.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>
)}
</details>
<div className="detail-grid"> <div className="detail-grid">
<section className="detail-block"> <section className="detail-block">
<h3>Account</h3> <h3>Account</h3>
@ -730,6 +809,16 @@ export default function ViewersPage() {
</button> </button>
</div> </div>
</div> </div>
<div className="detail-actions" style={{ marginTop: "0.85rem" }}>
<button
type="button"
className="danger"
disabled={actionId === v.id}
onClick={() => void deleteViewer(v)}
>
Gebruiker verwijderen
</button>
</div>
</section> </section>
<section className="detail-block"> <section className="detail-block">
@ -993,32 +1082,6 @@ export default function ViewersPage() {
)} )}
</section> </section>
</div> </div>
<section className="detail-block devices-block">
<h3>Apparaten</h3>
{v.devices.length === 0 ? (
<p className="muted">Nog geen gekoppelde apparaten.</p>
) : (
<ul className="device-rows">
{v.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" onClick={() => void revokeDevice(d.id)}>
Intrekken
</button>
</li>
))}
</ul>
)}
</section>
</div> </div>
)} )}
</li> </li>
@ -1365,13 +1428,41 @@ export default function ViewersPage() {
word-break: break-all; word-break: break-all;
color: var(--text-bright); color: var(--text-bright);
} }
.devices-block { .devices-fold {
border-top: 1px solid var(--border-dim); margin-bottom: 0.85rem;
padding-top: 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 { .device-rows {
list-style: none; list-style: none;
margin: 0; margin: 0.35rem 0 0;
padding: 0; padding: 0;
display: grid; display: grid;
gap: 0.45rem; gap: 0.45rem;
@ -1384,7 +1475,7 @@ export default function ViewersPage() {
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-dim); border: 1px solid var(--border-dim);
background: var(--bg-elevated); background: var(--bg);
} }
.device-rows strong { .device-rows strong {
display: block; display: block;

View file

@ -61,6 +61,21 @@ export function registerViewerRoutes(
return { viewer }; 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) => { app.get("/api/v1/admin/viewers/:id/iptv", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const { prisma } = await import("../database/client"); const { prisma } = await import("../database/client");

View file

@ -116,7 +116,7 @@ export class ViewerService {
const rows = await prisma.viewerUser.findMany({ const rows = await prisma.viewerUser.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { include: {
_count: { select: { devices: true } }, _count: { select: { devices: { where: { revoked: false } } } },
iptvLine: { iptvLine: {
select: { select: {
id: true, 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( async updateViewer(
id: string, id: string,
data: { data: {