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:
parent
22d46ffac7
commit
c425b2b839
5 changed files with 327 additions and 17 deletions
|
|
@ -144,6 +144,16 @@ export default function SettingsPage() {
|
||||||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||||||
const [pwErr, setPwErr] = 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 [gpProjectId, setGpProjectId] = useState("");
|
||||||
const [gpSaEmail, setGpSaEmail] = useState("");
|
const [gpSaEmail, setGpSaEmail] = useState("");
|
||||||
const [gpPrivateKey, setGpPrivateKey] = useState("");
|
const [gpPrivateKey, setGpPrivateKey] = useState("");
|
||||||
|
|
@ -282,6 +292,20 @@ export default function SettingsPage() {
|
||||||
.catch(() => undefined);
|
.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(() => {
|
useEffect(() => {
|
||||||
loadShelves();
|
loadShelves();
|
||||||
loadDs();
|
loadDs();
|
||||||
|
|
@ -289,6 +313,7 @@ export default function SettingsPage() {
|
||||||
loadOmdb();
|
loadOmdb();
|
||||||
loadGooglePlay();
|
loadGooglePlay();
|
||||||
loadTvSettings();
|
loadTvSettings();
|
||||||
|
loadTokenTray();
|
||||||
fetch("/api/v1/auth/me", { credentials: "include" })
|
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setAdminEmail(d.user?.email ?? ""))
|
.then((d) => setAdminEmail(d.user?.email ?? ""))
|
||||||
|
|
@ -301,7 +326,7 @@ export default function SettingsPage() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings]);
|
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings, loadTokenTray]);
|
||||||
|
|
||||||
async function saveInstallPageEnabled(enabled: boolean) {
|
async function saveInstallPageEnabled(enabled: boolean) {
|
||||||
setInstallPageBusy(true);
|
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() {
|
async function createShelf() {
|
||||||
if (!newShelfName.trim()) return;
|
if (!newShelfName.trim()) return;
|
||||||
const res = await fetch("/api/v1/admin/library/shelves", {
|
const res = await fetch("/api/v1/admin/library/shelves", {
|
||||||
|
|
@ -715,6 +781,112 @@ export default function SettingsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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" }}>
|
<div className="section-head" style={{ marginTop: "1.5rem" }}>
|
||||||
<h2 className="section-title">Publieke install-pagina</h2>
|
<h2 className="section-title">Publieke install-pagina</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
import type { Config } from "../config";
|
import type { Config } from "../config";
|
||||||
import { AppError } from "../security/errors";
|
import { AppError } from "../security/errors";
|
||||||
|
import { resolveTokenTraySecret } from "../settings/token-tray";
|
||||||
|
|
||||||
function requireTrayKey(request: FastifyRequest, config: Config) {
|
async function requireTrayKey(request: FastifyRequest, config: Config) {
|
||||||
const secret = config.TOKEN_TRAY_SECRET?.trim();
|
const secret = await resolveTokenTraySecret(
|
||||||
|
config.SESSION_SECRET,
|
||||||
|
config.TOKEN_TRAY_SECRET
|
||||||
|
);
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
"FORBIDDEN",
|
"FORBIDDEN",
|
||||||
"Token-tray is niet geconfigureerd (TOKEN_TRAY_SECRET ontbreekt)",
|
"Token-tray is niet geconfigureerd — maak een key onder Admin → Instellingen",
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -19,7 +23,7 @@ function requireTrayKey(request: FastifyRequest, config: Config) {
|
||||||
|
|
||||||
export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
||||||
app.get("/api/v1/agent/status", async (request) => {
|
app.get("/api/v1/agent/status", async (request) => {
|
||||||
requireTrayKey(request, config);
|
await requireTrayKey(request, config);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
trayEnabled: true,
|
trayEnabled: true,
|
||||||
|
|
@ -28,7 +32,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/v1/agent/viaplay/tokens", async (request) => {
|
app.post("/api/v1/agent/viaplay/tokens", async (request) => {
|
||||||
requireTrayKey(request, config);
|
await requireTrayKey(request, config);
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
email?: string;
|
email?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import {
|
||||||
upsertGooglePlaySettings,
|
upsertGooglePlaySettings,
|
||||||
} from "./google-play";
|
} from "./google-play";
|
||||||
import { getIptvSettings, upsertIptvSettings } from "./iptv";
|
import { getIptvSettings, upsertIptvSettings } from "./iptv";
|
||||||
|
import {
|
||||||
|
getTokenTraySettingsPublic,
|
||||||
|
upsertTokenTraySettings,
|
||||||
|
} from "./token-tray";
|
||||||
import { listIptvCategoriesForAdmin, refreshIptvEpgNow } from "../viewer/iptv-client";
|
import { listIptvCategoriesForAdmin, refreshIptvEpgNow } from "../viewer/iptv-client";
|
||||||
import type { GooglePlayAccessService } from "../google-play/service";
|
import type { GooglePlayAccessService } from "../google-play/service";
|
||||||
|
|
||||||
|
|
@ -133,4 +137,19 @@ export function registerSettingsRoutes(
|
||||||
const categories = await listIptvCategoriesForAdmin(config);
|
const categories = await listIptvCategoriesForAdmin(config);
|
||||||
return { ok: true, ...result, categories };
|
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 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
apps/master-api/src/settings/token-tray.ts
Normal file
108
apps/master-api/src/settings/token-tray.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,9 @@
|
||||||
|
|
||||||
Windows system-tray helper die Viaplay-sessie-tokens vernieuwt en naar de master-api pusht.
|
Windows system-tray helper die Viaplay-sessie-tokens vernieuwt en naar de master-api pusht.
|
||||||
|
|
||||||
## Server
|
1. Open **Admin → Instellingen → Admin** → sectie **Token Tray**
|
||||||
|
2. Klik **Genereer nieuwe key** en kopieer de key (+ API URL) naar de tray-app
|
||||||
Zet in master-api `.env`:
|
3. Of zet optioneel `TOKEN_TRAY_SECRET` in `.env` (fallback als er geen DB-key is)
|
||||||
|
|
||||||
```env
|
|
||||||
TOKEN_TRAY_SECRET=minstens-16-willekeurige-tekens
|
|
||||||
```
|
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
|
|
||||||
|
|
@ -17,12 +13,23 @@ Endpoint:
|
||||||
|
|
||||||
## Lokaal starten
|
## Lokaal starten
|
||||||
|
|
||||||
```bash
|
PowerShell blokkeert soms `pnpm.ps1`. Gebruik dan **`pnpm.cmd`** (of CMD):
|
||||||
cd apps/token-tray
|
|
||||||
pnpm install
|
```powershell
|
||||||
pnpm dev
|
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`)
|
1. Vul **API URL** + **Tray key** (= `TOKEN_TRAY_SECRET`)
|
||||||
2. Vul het **zelfde e-mailadres** als het Scripts → Viaplay account
|
2. Vul het **zelfde e-mailadres** als het Scripts → Viaplay account
|
||||||
3. Tray → **Viaplay inloggen / vernieuwen** (CAPTCHA in het venster)
|
3. Tray → **Viaplay inloggen / vernieuwen** (CAPTCHA in het venster)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue