Add admin control to list/disable/delete addons and permanently remove nodes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 03:33:49 +02:00
parent cc7b7c8505
commit b5236188c6
4 changed files with 186 additions and 13 deletions

View file

@ -100,6 +100,26 @@ export default function NodesPage() {
loadNodes();
}
async function deleteNode(id: string, name: string) {
if (
!confirm(
`Node "${name}" definitief verwijderen?\n\nAlle bestanden van deze node verdwijnen uit de catalogus. Dit kan niet ongedaan worden gemaakt.`
)
) {
return;
}
const res = await fetch(`/api/v1/admin/nodes/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error?.message ?? "Verwijderen mislukt");
return;
}
loadNodes();
}
async function disableNode(id: string) {
if (!confirm("Node tijdelijk uitschakelen?")) return;
await fetch(`/api/v1/admin/nodes/${id}/disable`, {
@ -266,6 +286,9 @@ export default function NodesPage() {
</button>
</>
)}
<button className="danger" onClick={() => deleteNode(node.id, node.name)}>
Verwijderen
</button>
</td>
</tr>
))}

View file

@ -1,14 +1,34 @@
"use client";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface AddonTokenRow {
id: string;
label: string | null;
revoked: boolean;
createdAt: string;
}
const MASTER_URL = "https://master.vonas.nl";
export default function SettingsPage() {
useAuth();
const [tokens, setTokens] = useState<AddonTokenRow[]>([]);
const [addonToken, setAddonToken] = useState("");
const [installUrl, setInstallUrl] = useState("");
const [label, setLabel] = useState("Stremio");
const loadTokens = useCallback(() => {
fetch("/api/v1/admin/addon-tokens", { credentials: "include" })
.then((r) => r.json())
.then((d) => setTokens(d.tokens ?? []));
}, []);
useEffect(() => {
loadTokens();
}, [loadTokens]);
async function createAddonToken() {
const res = await fetch("/api/v1/admin/addon-tokens", {
method: "POST",
@ -17,9 +37,39 @@ export default function SettingsPage() {
body: JSON.stringify({ label }),
});
const data = await res.json();
if (!res.ok) {
alert(data.error?.message ?? "Aanmaken mislukt");
return;
}
setAddonToken(data.token);
// Addon draait op Master, niet op Admin
setInstallUrl(`https://master.vonas.nl/stremio/${data.token}/manifest.json`);
setInstallUrl(`${MASTER_URL}/stremio/${data.token}/manifest.json`);
loadTokens();
}
async function revokeToken(id: string) {
if (!confirm("Addon deactiveren? Bestaande Stremio-installaties stoppen met werken.")) return;
await fetch(`/api/v1/admin/addon-tokens/${id}/revoke`, {
method: "POST",
credentials: "include",
});
loadTokens();
}
async function activateToken(id: string) {
await fetch(`/api/v1/admin/addon-tokens/${id}/activate`, {
method: "POST",
credentials: "include",
});
loadTokens();
}
async function deleteToken(id: string) {
if (!confirm("Addon definitief verwijderen? Dit kan niet ongedaan worden gemaakt.")) return;
await fetch(`/api/v1/admin/addon-tokens/${id}`, {
method: "DELETE",
credentials: "include",
});
loadTokens();
}
return (
@ -28,29 +78,85 @@ export default function SettingsPage() {
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Instellingen</h1>
<div className="card">
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Stremio Addon
Nieuwe Stremio-addon
</h2>
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.875rem" }}>
Genereer een addon token om de media library in Stremio te installeren.
Genereer een addon-token om de bibliotheek in Stremio te installeren. Bewaar de URL —
die wordt maar één keer getoond.
</p>
<div className="form-group">
<label>Label</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} />
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="bijv. Thuis / TV" />
</div>
<button onClick={createAddonToken}>Addon token genereren</button>
<button onClick={createAddonToken}>Addon aanmaken</button>
{addonToken && (
<>
<div className="token-display">{addonToken}</div>
<p style={{ fontSize: "0.875rem", color: "#a1a1aa" }}>Install URL:</p>
<p style={{ fontSize: "0.875rem", color: "#a1a1aa", marginTop: "1rem" }}>
Installatie-URL (nu kopiëren):
</p>
<div className="token-display">{installUrl}</div>
<p style={{ fontSize: "0.875rem", color: "#71717a", marginTop: "0.5rem" }}>
Plak deze URL in Stremio → Addons → Community Addons → URL invoeren
Stremio → Addons → Community Addons → URL invoeren
</p>
</>
)}
</div>
<div className="card">
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Bestaande addons
</h2>
<table>
<thead>
<tr>
<th>Label</th>
<th>Status</th>
<th>Aangemaakt</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.id}>
<td>{t.label || "—"}</td>
<td>
<span className={`status ${t.revoked ? "revoked" : "online"}`}>
{t.revoked ? "Inactief" : "Actief"}
</span>
</td>
<td>{new Date(t.createdAt).toLocaleString()}</td>
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
{t.revoked ? (
<button onClick={() => activateToken(t.id)} style={{ background: "#166534" }}>
Activeren
</button>
) : (
<button onClick={() => revokeToken(t.id)} style={{ background: "#b45309" }}>
Deactiveren
</button>
)}
<button className="danger" onClick={() => deleteToken(t.id)}>
Verwijderen
</button>
</td>
</tr>
))}
{tokens.length === 0 && (
<tr>
<td colSpan={4} style={{ textAlign: "center", color: "#71717a" }}>
Nog geen addons aangemaakt
</td>
</tr>
)}
</tbody>
</table>
<p style={{ fontSize: "0.8rem", color: "#71717a", marginTop: "1rem" }}>
Deactiveren of verwijderen zorgt dat de bijbehorende Stremio-addon niet meer werkt.
De installatie-URL zelf wordt om veiligheidsredenen niet opnieuw getoond.
</p>
</div>
</div>
</>
);

View file

@ -255,7 +255,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
const body = request.body as { label?: string };
const token = generateSecureToken(24);
await prisma.addonToken.create({
const created = await prisma.addonToken.create({
data: {
userId: user.id,
tokenHash: hashToken(token),
@ -265,6 +265,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return {
token,
id: created.id,
label: created.label,
installUrl: `/stremio/${token}/manifest.json`,
};
});
@ -287,7 +289,33 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
app.post("/api/v1/admin/addon-tokens/:id/revoke", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
await prisma.addonToken.update({ where: { id }, data: { revoked: true } });
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const updated = await prisma.addonToken.updateMany({
where: { id, userId: user.id },
data: { revoked: true },
});
if (updated.count === 0) throw new AppError("NOT_FOUND", "Addon token not found", 404);
return { ok: true };
});
app.post("/api/v1/admin/addon-tokens/:id/activate", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const updated = await prisma.addonToken.updateMany({
where: { id, userId: user.id },
data: { revoked: false },
});
if (updated.count === 0) throw new AppError("NOT_FOUND", "Addon token not found", 404);
return { ok: true };
});
app.delete("/api/v1/admin/addon-tokens/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const deleted = await prisma.addonToken.deleteMany({
where: { id, userId: user.id },
});
if (deleted.count === 0) throw new AppError("NOT_FOUND", "Addon token not found", 404);
return { ok: true };
});

View file

@ -236,6 +236,22 @@ export async function registerNodeRoutes(app: FastifyInstance) {
return { node: { id: node.id, status: node.status } };
});
app.delete("/api/v1/admin/nodes/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const existing = await prisma.node.findUnique({ where: { id } });
if (!existing) throw new AppError("NOT_FOUND", "Node not found", 404);
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.disconnect(id);
await prisma.playbackSession.deleteMany({ where: { nodeId: id } });
await prisma.mediaFile.deleteMany({ where: { nodeId: id } });
await prisma.nodeCredential.deleteMany({ where: { nodeId: id } });
await prisma.node.delete({ where: { id } });
return { ok: true };
});
}
function serializeNode(n: {