Add user delete and collapsible device unlink in admin Gebruikers.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a431bebf3b
commit
a6fa9c22fb
3 changed files with 152 additions and 38 deletions
|
|
@ -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 && (
|
||||
<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">
|
||||
<section className="detail-block">
|
||||
<h3>Account</h3>
|
||||
|
|
@ -730,6 +809,16 @@ export default function ViewersPage() {
|
|||
</button>
|
||||
</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 className="detail-block">
|
||||
|
|
@ -993,32 +1082,6 @@ export default function ViewersPage() {
|
|||
)}
|
||||
</section>
|
||||
</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>
|
||||
)}
|
||||
</li>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue