Manage Token Tray key from Admin settings.

Generate or paste a secret in the UI; agent API prefers DB key over TOKEN_TRAY_SECRET env.
This commit is contained in:
Jos Vooges | STH 2026-09-22 23:34:51 +02:00
parent 22d46ffac7
commit c425b2b839
5 changed files with 327 additions and 17 deletions

View file

@ -144,6 +144,16 @@ export default function SettingsPage() {
const [pwMsg, setPwMsg] = useState<string | null>(null);
const [pwErr, setPwErr] = useState<string | null>(null);
const [trayApiUrl, setTrayApiUrl] = useState("");
const [trayHasSecret, setTrayHasSecret] = useState(false);
const [trayFromEnv, setTrayFromEnv] = useState(false);
const [trayEnabled, setTrayEnabled] = useState(true);
const [traySecretOnce, setTraySecretOnce] = useState<string | null>(null);
const [trayManualSecret, setTrayManualSecret] = useState("");
const [trayBusy, setTrayBusy] = useState(false);
const [trayMsg, setTrayMsg] = useState<string | null>(null);
const [trayErr, setTrayErr] = useState<string | null>(null);
const [gpProjectId, setGpProjectId] = useState("");
const [gpSaEmail, setGpSaEmail] = useState("");
const [gpPrivateKey, setGpPrivateKey] = useState("");
@ -282,6 +292,20 @@ export default function SettingsPage() {
.catch(() => undefined);
}, []);
const loadTokenTray = useCallback(() => {
fetch("/api/v1/admin/settings/token-tray", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const s = d.settings;
if (!s) return;
setTrayApiUrl(s.apiUrl ?? "");
setTrayHasSecret(!!s.hasSecret);
setTrayFromEnv(!!s.fromEnvFallback);
setTrayEnabled(s.enabled !== false);
})
.catch(() => undefined);
}, []);
useEffect(() => {
loadShelves();
loadDs();
@ -289,6 +313,7 @@ export default function SettingsPage() {
loadOmdb();
loadGooglePlay();
loadTvSettings();
loadTokenTray();
fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => r.json())
.then((d) => setAdminEmail(d.user?.email ?? ""))
@ -301,7 +326,7 @@ export default function SettingsPage() {
}
})
.catch(() => undefined);
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings]);
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings, loadTokenTray]);
async function saveInstallPageEnabled(enabled: boolean) {
setInstallPageBusy(true);
@ -373,6 +398,47 @@ export default function SettingsPage() {
}
}
async function saveTokenTray(body: {
generate?: boolean;
secret?: string;
enabled?: boolean;
clear?: boolean;
}) {
setTrayBusy(true);
setTrayMsg(null);
setTrayErr(null);
try {
const res = await fetch("/api/v1/admin/settings/token-tray", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setTrayErr(data.error?.message ?? "Opslaan mislukt");
return;
}
const s = data.settings;
setTrayApiUrl(s?.apiUrl ?? trayApiUrl);
setTrayHasSecret(!!s?.hasSecret);
setTrayFromEnv(!!s?.fromEnvFallback);
setTrayEnabled(s?.enabled !== false);
if (s?.secretOnce) {
setTraySecretOnce(s.secretOnce);
setTrayMsg("Nieuwe key aangemaakt — kopieer hem nu naar de Token Tray (wordt niet opnieuw getoond).");
} else if (body.clear) {
setTraySecretOnce(null);
setTrayMsg("Tray-key gewist");
} else {
setTrayMsg("Opgeslagen");
}
setTrayManualSecret("");
} finally {
setTrayBusy(false);
}
}
async function createShelf() {
if (!newShelfName.trim()) return;
const res = await fetch("/api/v1/admin/library/shelves", {
@ -715,6 +781,112 @@ export default function SettingsPage() {
</button>
</div>
<div className="section-head" style={{ marginTop: "1.5rem" }}>
<h2 className="section-title">Token Tray</h2>
<p className="muted">
Key voor de Windows Token Tray-app (<code>apps/token-tray</code>). Plak API-URL +
key in de tray-instellingen.
</p>
</div>
<div className="card">
{trayErr && <div className="error">{trayErr}</div>}
{trayMsg && (
<p className="muted" style={{ color: "var(--ok)", marginBottom: "0.75rem" }}>
{trayMsg}
</p>
)}
<div className="form-group">
<label>API URL (voor de tray)</label>
<input type="text" value={trayApiUrl} readOnly />
<p className="muted" style={{ fontSize: "0.85rem", marginTop: 4 }}>
Kopieer dit veld naar <strong>API URL</strong> in de tray.
</p>
</div>
<p className="muted" style={{ fontSize: "0.9rem" }}>
Status:{" "}
{trayHasSecret ? (
<span style={{ color: "var(--ok)" }}>
key actief{trayFromEnv ? " (via .env)" : ""}
</span>
) : (
<span>nog geen key</span>
)}
</p>
{traySecretOnce ? (
<div className="form-group" style={{ marginTop: "0.75rem" }}>
<label>Tray key (eenmalig zichtbaar)</label>
<input
type="text"
value={traySecretOnce}
readOnly
style={{ fontFamily: "monospace", fontSize: "0.85rem" }}
onFocus={(e) => e.currentTarget.select()}
/>
<button
type="button"
style={{ marginTop: 8 }}
onClick={() => {
void navigator.clipboard.writeText(traySecretOnce);
setTrayMsg("Key gekopieerd naar klembord");
}}
>
Kopieer key
</button>
</div>
) : null}
<div className="form-group" style={{ marginTop: "0.75rem" }}>
<label>Eigen key plakken (optioneel, min. 16 tekens)</label>
<input
type="password"
value={trayManualSecret}
onChange={(e) => setTrayManualSecret(e.target.value)}
placeholder="leeg laten = genereer"
autoComplete="off"
/>
</div>
<label
className="checkbox-row"
style={{ display: "flex", gap: "0.6rem", alignItems: "center", marginBottom: 12 }}
>
<input
type="checkbox"
checked={trayEnabled}
disabled={trayBusy}
onChange={(e) => void saveTokenTray({ enabled: e.target.checked })}
/>
<span>Token Tray API inschakelen</span>
</label>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<button
type="button"
disabled={trayBusy}
onClick={() => void saveTokenTray({ generate: true, enabled: true })}
>
{trayBusy ? "Bezig…" : "Genereer nieuwe key"}
</button>
<button
type="button"
disabled={trayBusy || trayManualSecret.trim().length < 16}
onClick={() =>
void saveTokenTray({ secret: trayManualSecret.trim(), enabled: true })
}
>
Key opslaan
</button>
<button
type="button"
disabled={trayBusy || !trayHasSecret}
onClick={() => {
if (confirm("Tray-key wissen? De tray kan daarna niet meer pushen.")) {
void saveTokenTray({ clear: true });
}
}}
>
Wissen
</button>
</div>
</div>
<div className="section-head" style={{ marginTop: "1.5rem" }}>
<h2 className="section-title">Publieke install-pagina</h2>
<p className="muted">

View file

@ -1,13 +1,17 @@
import type { FastifyInstance, FastifyRequest } from "fastify";
import type { Config } from "../config";
import { AppError } from "../security/errors";
import { resolveTokenTraySecret } from "../settings/token-tray";
function requireTrayKey(request: FastifyRequest, config: Config) {
const secret = config.TOKEN_TRAY_SECRET?.trim();
async function requireTrayKey(request: FastifyRequest, config: Config) {
const secret = await resolveTokenTraySecret(
config.SESSION_SECRET,
config.TOKEN_TRAY_SECRET
);
if (!secret) {
throw new AppError(
"FORBIDDEN",
"Token-tray is niet geconfigureerd (TOKEN_TRAY_SECRET ontbreekt)",
"Token-tray is niet geconfigureerd — maak een key onder Admin → Instellingen",
403
);
}
@ -19,7 +23,7 @@ function requireTrayKey(request: FastifyRequest, config: Config) {
export function registerAgentRoutes(app: FastifyInstance, config: Config) {
app.get("/api/v1/agent/status", async (request) => {
requireTrayKey(request, config);
await requireTrayKey(request, config);
return {
ok: true,
trayEnabled: true,
@ -28,7 +32,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
});
app.post("/api/v1/agent/viaplay/tokens", async (request) => {
requireTrayKey(request, config);
await requireTrayKey(request, config);
const body = request.body as {
email?: string;
label?: string;

View file

@ -15,6 +15,10 @@ import {
upsertGooglePlaySettings,
} from "./google-play";
import { getIptvSettings, upsertIptvSettings } from "./iptv";
import {
getTokenTraySettingsPublic,
upsertTokenTraySettings,
} from "./token-tray";
import { listIptvCategoriesForAdmin, refreshIptvEpgNow } from "../viewer/iptv-client";
import type { GooglePlayAccessService } from "../google-play/service";
@ -133,4 +137,19 @@ export function registerSettingsRoutes(
const categories = await listIptvCategoriesForAdmin(config);
return { ok: true, ...result, categories };
});
app.get("/api/v1/admin/settings/token-tray", { preHandler: requireAdmin }, async () => {
return { settings: await getTokenTraySettingsPublic(config) };
});
app.put("/api/v1/admin/settings/token-tray", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
secret?: string;
generate?: boolean;
enabled?: boolean;
clear?: boolean;
};
const settings = await upsertTokenTraySettings(config.SESSION_SECRET, config, body);
return { settings };
});
}

View file

@ -0,0 +1,108 @@
import { randomBytes } from "node:crypto";
import { prisma } from "../database/client";
import { decryptSecret, encryptSecret } from "../security/crypto";
import { AppError } from "../security/errors";
import type { Config } from "../config";
const TOKEN_TRAY_ID = "token-tray";
export type TokenTraySettingsPublic = {
enabled: boolean;
hasSecret: boolean;
/** Alleen gezet direct na generate/save — plak dit in de tray. */
secretOnce?: string;
apiUrl: string;
fromEnvFallback: boolean;
};
export async function getTokenTraySettingsPublic(
config: Config
): Promise<TokenTraySettingsPublic> {
const row = await prisma.integrationSetting.findUnique({ where: { id: TOKEN_TRAY_ID } });
const hasDb = !!row?.apiKeyEnc;
const hasEnv = !!config.TOKEN_TRAY_SECRET?.trim();
return {
enabled: row?.enabled ?? true,
hasSecret: hasDb || hasEnv,
apiUrl: config.PUBLIC_URL.replace(/\/$/, ""),
fromEnvFallback: !hasDb && hasEnv,
};
}
/** Secret die de agent-API accepteert: DB → anders env. */
export async function resolveTokenTraySecret(
sessionSecret: string,
envFallback?: string | null
): Promise<string | null> {
const row = await prisma.integrationSetting.findUnique({ where: { id: TOKEN_TRAY_ID } });
if (row && row.enabled === false) return null;
if (row?.apiKeyEnc) {
try {
const key = decryptSecret(row.apiKeyEnc, sessionSecret).trim();
if (key) return key;
} catch {
/* fall through */
}
}
const env = envFallback?.trim();
return env || null;
}
export function generateTokenTraySecret(): string {
return randomBytes(24).toString("base64url");
}
export async function upsertTokenTraySettings(
sessionSecret: string,
config: Config,
input: {
secret?: string;
generate?: boolean;
enabled?: boolean;
clear?: boolean;
}
): Promise<TokenTraySettingsPublic> {
const existing = await prisma.integrationSetting.findUnique({ where: { id: TOKEN_TRAY_ID } });
const enabled = input.enabled ?? existing?.enabled ?? true;
let apiKeyEnc = existing?.apiKeyEnc ?? null;
let secretOnce: string | undefined;
if (input.clear) {
apiKeyEnc = null;
} else if (input.generate) {
secretOnce = generateTokenTraySecret();
apiKeyEnc = encryptSecret(secretOnce, sessionSecret);
} else if (input.secret?.trim()) {
const trimmed = input.secret.trim();
if (trimmed.length < 16) {
throw new AppError("INVALID_REQUEST", "Tray-key minstens 16 tekens", 400);
}
secretOnce = trimmed;
apiKeyEnc = encryptSecret(trimmed, sessionSecret);
}
if (enabled && !apiKeyEnc && !config.TOKEN_TRAY_SECRET?.trim()) {
throw new AppError(
"INVALID_REQUEST",
"Genereer of plak een tray-key (of zet TOKEN_TRAY_SECRET in .env)",
400
);
}
await prisma.integrationSetting.upsert({
where: { id: TOKEN_TRAY_ID },
create: {
id: TOKEN_TRAY_ID,
apiKeyEnc,
enabled,
},
update: {
apiKeyEnc,
enabled,
},
});
const pub = await getTokenTraySettingsPublic(config);
return secretOnce ? { ...pub, secretOnce } : pub;
}

View file

@ -2,13 +2,9 @@
Windows system-tray helper die Viaplay-sessie-tokens vernieuwt en naar de master-api pusht.
## Server
Zet in master-api `.env`:
```env
TOKEN_TRAY_SECRET=minstens-16-willekeurige-tekens
```
1. Open **Admin → Instellingen → Admin** → sectie **Token Tray**
2. Klik **Genereer nieuwe key** en kopieer de key (+ API URL) naar de tray-app
3. Of zet optioneel `TOKEN_TRAY_SECRET` in `.env` (fallback als er geen DB-key is)
Endpoint:
@ -17,12 +13,23 @@ Endpoint:
## Lokaal starten
```bash
cd apps/token-tray
pnpm install
pnpm dev
PowerShell blokkeert soms `pnpm.ps1`. Gebruik dan **`pnpm.cmd`** (of CMD):
```powershell
cd apps\token-tray
pnpm.cmd install
pnpm.cmd run build
pnpm.cmd run start
```
Of eenmalig voor je user-account:
```powershell
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
```
Daarna werkt gewoon `pnpm install` / `pnpm dev` weer.
1. Vul **API URL** + **Tray key** (= `TOKEN_TRAY_SECRET`)
2. Vul het **zelfde e-mailadres** als het Scripts → Viaplay account
3. Tray → **Viaplay inloggen / vernieuwen** (CAPTCHA in het venster)