From b5236188c63550f86126564a17f425b370190b47 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 03:33:49 +0200 Subject: [PATCH] Add admin control to list/disable/delete addons and permanently remove nodes. Co-authored-by: Cursor --- apps/admin-ui/src/app/nodes/page.tsx | 23 +++++ apps/admin-ui/src/app/settings/page.tsx | 128 ++++++++++++++++++++++-- apps/master-api/src/admin/routes.ts | 32 +++++- apps/master-api/src/nodes/routes.ts | 16 +++ 4 files changed, 186 insertions(+), 13 deletions(-) diff --git a/apps/admin-ui/src/app/nodes/page.tsx b/apps/admin-ui/src/app/nodes/page.tsx index 4d4c880..d8ecf29 100644 --- a/apps/admin-ui/src/app/nodes/page.tsx +++ b/apps/admin-ui/src/app/nodes/page.tsx @@ -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() { )} + ))} diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index f2a4043..2fbc68d 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -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([]); 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() {

Instellingen

-
+

- Stremio Addon + Nieuwe Stremio-addon

- 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.

- setLabel(e.target.value)} /> + setLabel(e.target.value)} placeholder="bijv. Thuis / TV" />
- + {addonToken && ( <> -
{addonToken}
-

Install URL:

+

+ Installatie-URL (nu kopiëren): +

{installUrl}

- Plak deze URL in Stremio → Addons → Community Addons → URL invoeren + Stremio → Addons → Community Addons → URL invoeren

)}
+ +
+

+ Bestaande addons +

+ + + + + + + + + + + {tokens.map((t) => ( + + + + + + + ))} + {tokens.length === 0 && ( + + + + )} + +
LabelStatusAangemaaktActies
{t.label || "—"} + + {t.revoked ? "Inactief" : "Actief"} + + {new Date(t.createdAt).toLocaleString()} + {t.revoked ? ( + + ) : ( + + )} + +
+ Nog geen addons aangemaakt +
+

+ Deactiveren of verwijderen zorgt dat de bijbehorende Stremio-addon niet meer werkt. + De installatie-URL zelf wordt om veiligheidsredenen niet opnieuw getoond. +

+
); diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index bf065ed..dd3ae3a 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -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 }; }); diff --git a/apps/master-api/src/nodes/routes.ts b/apps/master-api/src/nodes/routes.ts index da2ce13..de6e459 100644 --- a/apps/master-api/src/nodes/routes.ts +++ b/apps/master-api/src/nodes/routes.ts @@ -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: {