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) {
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue