Add admin control to list/disable/delete addons and permanently remove nodes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cc7b7c8505
commit
b5236188c6
4 changed files with 186 additions and 13 deletions
|
|
@ -100,6 +100,26 @@ export default function NodesPage() {
|
||||||
loadNodes();
|
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) {
|
async function disableNode(id: string) {
|
||||||
if (!confirm("Node tijdelijk uitschakelen?")) return;
|
if (!confirm("Node tijdelijk uitschakelen?")) return;
|
||||||
await fetch(`/api/v1/admin/nodes/${id}/disable`, {
|
await fetch(`/api/v1/admin/nodes/${id}/disable`, {
|
||||||
|
|
@ -266,6 +286,9 @@ export default function NodesPage() {
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button className="danger" onClick={() => deleteNode(node.id, node.name)}>
|
||||||
|
Verwijderen
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,34 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Nav, useAuth } from "@/components/Nav";
|
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() {
|
export default function SettingsPage() {
|
||||||
useAuth();
|
useAuth();
|
||||||
|
const [tokens, setTokens] = useState<AddonTokenRow[]>([]);
|
||||||
const [addonToken, setAddonToken] = useState("");
|
const [addonToken, setAddonToken] = useState("");
|
||||||
const [installUrl, setInstallUrl] = useState("");
|
const [installUrl, setInstallUrl] = useState("");
|
||||||
const [label, setLabel] = useState("Stremio");
|
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() {
|
async function createAddonToken() {
|
||||||
const res = await fetch("/api/v1/admin/addon-tokens", {
|
const res = await fetch("/api/v1/admin/addon-tokens", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -17,9 +37,39 @@ export default function SettingsPage() {
|
||||||
body: JSON.stringify({ label }),
|
body: JSON.stringify({ label }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(data.error?.message ?? "Aanmaken mislukt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setAddonToken(data.token);
|
setAddonToken(data.token);
|
||||||
// Addon draait op Master, niet op Admin
|
setInstallUrl(`${MASTER_URL}/stremio/${data.token}/manifest.json`);
|
||||||
setInstallUrl(`https://master.vonas.nl/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 (
|
return (
|
||||||
|
|
@ -28,29 +78,85 @@ export default function SettingsPage() {
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Instellingen</h1>
|
<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" }}>
|
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
|
||||||
Stremio Addon
|
Nieuwe Stremio-addon
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.875rem" }}>
|
<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>
|
</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Label</label>
|
<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>
|
</div>
|
||||||
<button onClick={createAddonToken}>Addon token genereren</button>
|
<button onClick={createAddonToken}>Addon aanmaken</button>
|
||||||
{addonToken && (
|
{addonToken && (
|
||||||
<>
|
<>
|
||||||
<div className="token-display">{addonToken}</div>
|
<p style={{ fontSize: "0.875rem", color: "#a1a1aa", marginTop: "1rem" }}>
|
||||||
<p style={{ fontSize: "0.875rem", color: "#a1a1aa" }}>Install URL:</p>
|
Installatie-URL (nu kopiëren):
|
||||||
|
</p>
|
||||||
<div className="token-display">{installUrl}</div>
|
<div className="token-display">{installUrl}</div>
|
||||||
<p style={{ fontSize: "0.875rem", color: "#71717a", marginTop: "0.5rem" }}>
|
<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>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
const body = request.body as { label?: string };
|
const body = request.body as { label?: string };
|
||||||
const token = generateSecureToken(24);
|
const token = generateSecureToken(24);
|
||||||
|
|
||||||
await prisma.addonToken.create({
|
const created = await prisma.addonToken.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
tokenHash: hashToken(token),
|
tokenHash: hashToken(token),
|
||||||
|
|
@ -265,6 +265,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
|
id: created.id,
|
||||||
|
label: created.label,
|
||||||
installUrl: `/stremio/${token}/manifest.json`,
|
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) => {
|
app.post("/api/v1/admin/addon-tokens/:id/revoke", { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
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 };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,22 @@ export async function registerNodeRoutes(app: FastifyInstance) {
|
||||||
|
|
||||||
return { node: { id: node.id, status: node.status } };
|
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: {
|
function serializeNode(n: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue