From 4f76e391246befd1e21776000e187a2762c551ff Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 1 Sep 2026 03:10:59 +0200 Subject: [PATCH] Move Google Play credentials into admin Settings UI. Store encrypted service-account and group config in the database so Dokploy env vars are optional. Co-authored-by: Cursor --- GOOGLE_PLAY_ACCESS_SETUP.md | 67 +++-- apps/admin-ui/src/app/settings/page.tsx | 204 +++++++++++++- apps/admin-ui/src/app/viewers/page.tsx | 3 +- .../migration.sql | 2 + apps/master-api/prisma/schema.prisma | 2 + apps/master-api/src/app.ts | 5 +- apps/master-api/src/google-play/client.ts | 8 +- apps/master-api/src/google-play/routes.ts | 2 +- .../src/google-play/service.test.ts | 15 +- apps/master-api/src/google-play/service.ts | 79 +++--- apps/master-api/src/settings/google-play.ts | 250 ++++++++++++++++++ apps/master-api/src/settings/routes.ts | 33 ++- 12 files changed, 601 insertions(+), 69 deletions(-) create mode 100644 apps/master-api/prisma/migrations/20260901010000_integration_config_json/migration.sql create mode 100644 apps/master-api/src/settings/google-play.ts diff --git a/GOOGLE_PLAY_ACCESS_SETUP.md b/GOOGLE_PLAY_ACCESS_SETUP.md index 9706e4d..caba588 100644 --- a/GOOGLE_PLAY_ACCESS_SETUP.md +++ b/GOOGLE_PLAY_ACCESS_SETUP.md @@ -16,26 +16,47 @@ Adminpaneel → backend → Google Cloud Identity Groups API → Google Group - De Closed Testing-track wordt **eenmalig** gekoppeld aan `PLAY_ACCESS_GROUP_EMAIL`. - Het adminpaneel beheert alleen lidmaatschap van die groep. -## Benodigde environment variables +## Benodigde configuratie -Stel deze in op de **master-api** server (niet in de admin UI, niet in Git): +**Voorkeur:** vul alles in via het adminpaneel → **Instellingen → Google Play**. +Credentials worden versleuteld in de database opgeslagen (zoals OpenSubtitles). -| Variabele | Verplicht | Beschrijving | -|-----------|-----------|--------------| -| `GOOGLE_CLOUD_PROJECT_ID` | Ja | Google Cloud project ID | -| `GOOGLE_SERVICE_ACCOUNT_EMAIL` | Ja | Service account e-mail | -| `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` | Ja | PEM private key ( `\n` als `\n` in env) | -| `PLAY_ACCESS_GROUP_ID` | Ja | Group resource ID (zonder `groups/` prefix mag ook) | -| `PLAY_ACCESS_GROUP_EMAIL` | Ja | E-mailadres van de Google Group | -| `GOOGLE_WORKSPACE_ADMIN_EMAIL` | Meestal ja | Admin voor domain-wide delegation | -| `GOOGLE_WORKSPACE_CUSTOMER_ID` | Optioneel | Workspace customer ID | -| `GOOGLE_PLAY_OPT_IN_URL` | Aanbevolen | Publieke Closed Testing opt-in URL | -| `GOOGLE_PLAY_SYNC_INTERVAL_MS` | Optioneel | Reconciliatie-interval (default: 86400000 = 24u) | +Optioneel blijven environment variables werken als fallback (bijv. voor lokale development). + +### Velden in Instellingen + +| Veld | Verplicht | Beschrijving | +|------|-----------|--------------| +| Project ID | Ja | Google Cloud project ID | +| Service account e-mail | Ja | Service account e-mail | +| Private key | Ja | PEM private key | +| Workspace admin e-mail | Meestal ja | Admin voor domain-wide delegation | +| Workspace customer ID | Optioneel | Workspace customer ID | +| Play access group ID | Ja | Group resource ID (zonder `groups/` prefix) | +| Play access group e-mail | Ja | E-mailadres van de Google Group | +| Opt-in URL | Aanbevolen | Publieke Closed Testing opt-in URL | + +### Optionele environment variables (fallback) + +Stel deze alleen in als je géén Settings-UI gebruikt: + +| Variabele | Beschrijving | +|-----------|--------------| +| `GOOGLE_CLOUD_PROJECT_ID` | Google Cloud project ID | +| `GOOGLE_SERVICE_ACCOUNT_EMAIL` | Service account e-mail | +| `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` | PEM private key (`\n` als `\\n` in env) | +| `PLAY_ACCESS_GROUP_ID` | Group resource ID | +| `PLAY_ACCESS_GROUP_EMAIL` | E-mailadres van de Google Group | +| `GOOGLE_WORKSPACE_ADMIN_EMAIL` | Admin voor domain-wide delegation | +| `GOOGLE_WORKSPACE_CUSTOMER_ID` | Workspace customer ID | +| `GOOGLE_PLAY_OPT_IN_URL` | Publieke Closed Testing opt-in URL | +| `GOOGLE_PLAY_SYNC_INTERVAL_MS` | Reconciliatie-interval (default: 86400000 = 24u) | ### Waar instellen -- **Lokaal:** `apps/master-api/.env` -- **Productie/Dokploy:** omgeving van de master-api container (`deploy/master/.env` template) +- **Productie (aanbevolen):** Admin UI → Instellingen → Google Play +- **Lokaal fallback:** `apps/master-api/.env` +- **Dokploy env:** alleen nodig als je Settings-UI niet wilt gebruiken ## Google Cloud — handmatige stappen @@ -174,14 +195,12 @@ Body PATCH: ## Wat jij nog handmatig moet invullen -Stop hier — vul **geen** credentials in deze repo: +Stop hier — vul **geen** credentials in deze repo. -1. `GOOGLE_CLOUD_PROJECT_ID` -2. `GOOGLE_SERVICE_ACCOUNT_EMAIL` -3. `GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY` -4. `GOOGLE_WORKSPACE_ADMIN_EMAIL` (indien Workspace) -5. `PLAY_ACCESS_GROUP_ID` -6. `PLAY_ACCESS_GROUP_EMAIL` -7. `GOOGLE_PLAY_OPT_IN_URL` +1. Maak Google Cloud service account + group + Play Closed Testing-koppeling (stappen hierboven) +2. Open adminpaneel → **Instellingen → Google Play** +3. Vul project, service account, private key, group ID/e-mail en opt-in URL in +4. Opslaan +5. Ga naar **Kijkers** en zet Store access aan voor een testgebruiker -Daarna master-api herstarten en in adminpaneel testen met één tester-account. +Daarna master-api herstarten is alleen nodig als je de nieuwe code net hebt gedeployed. diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index c2c3583..c2b9a75 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -4,10 +4,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { Nav, useAuth } from "@/components/Nav"; -type SettingsTab = "shelves" | "download-station" | "opensubtitles"; +type SettingsTab = "shelves" | "download-station" | "opensubtitles" | "google-play"; function parseTab(v: string | null): SettingsTab { - if (v === "download-station" || v === "opensubtitles" || v === "shelves") return v; + if (v === "download-station" || v === "opensubtitles" || v === "shelves" || v === "google-play") { + return v; + } return "shelves"; } @@ -47,6 +49,7 @@ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [ { id: "shelves", label: "Planken", short: "Planken" }, { id: "download-station", label: "Download Station", short: "DS" }, { id: "opensubtitles", label: "OpenSubtitles", short: "Subs" }, + { id: "google-play", label: "Google Play", short: "Play" }, ]; const emptyConfig = (nodeId = ""): DsConfig => ({ @@ -99,6 +102,22 @@ export default function SettingsPage() { const [osErr, setOsErr] = useState(null); const [osWarn, setOsWarn] = useState(null); + const [gpProjectId, setGpProjectId] = useState(""); + const [gpSaEmail, setGpSaEmail] = useState(""); + const [gpPrivateKey, setGpPrivateKey] = useState(""); + const [gpAdminEmail, setGpAdminEmail] = useState(""); + const [gpCustomerId, setGpCustomerId] = useState(""); + const [gpGroupId, setGpGroupId] = useState(""); + const [gpGroupEmail, setGpGroupEmail] = useState(""); + const [gpOptInUrl, setGpOptInUrl] = useState(""); + const [gpEnabled, setGpEnabled] = useState(true); + const [gpHasKey, setGpHasKey] = useState(false); + const [gpConfigured, setGpConfigured] = useState(false); + const [gpSource, setGpSource] = useState("none"); + const [gpBusy, setGpBusy] = useState(false); + const [gpMsg, setGpMsg] = useState(null); + const [gpErr, setGpErr] = useState(null); + const loadShelves = useCallback(() => { fetch("/api/v1/admin/library/shelves", { credentials: "include" }) .then((r) => r.json()) @@ -141,11 +160,33 @@ export default function SettingsPage() { .catch(() => undefined); }, []); + const loadGooglePlay = useCallback(() => { + fetch("/api/v1/admin/settings/google-play", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => { + const s = d.settings; + if (!s) return; + setGpConfigured(!!s.configured); + setGpEnabled(s.enabled !== false); + setGpProjectId(s.projectId ?? ""); + setGpSaEmail(s.serviceAccountEmail ?? ""); + setGpAdminEmail(s.workspaceAdminEmail ?? ""); + setGpCustomerId(s.workspaceCustomerId ?? ""); + setGpGroupId(s.groupId ?? ""); + setGpGroupEmail(s.groupEmail ?? ""); + setGpOptInUrl(s.optInUrl ?? ""); + setGpHasKey(!!s.hasPrivateKey); + setGpSource(s.source ?? "none"); + }) + .catch(() => undefined); + }, []); + useEffect(() => { loadShelves(); loadDs(); loadOpenSubtitles(); - }, [loadShelves, loadDs, loadOpenSubtitles]); + loadGooglePlay(); + }, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay]); useEffect(() => { if (!nodeId) return; @@ -255,6 +296,44 @@ export default function SettingsPage() { } } + async function saveGooglePlay() { + setGpBusy(true); + setGpMsg(null); + setGpErr(null); + try { + const res = await fetch("/api/v1/admin/settings/google-play", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + projectId: gpProjectId, + serviceAccountEmail: gpSaEmail, + privateKey: gpPrivateKey || undefined, + workspaceAdminEmail: gpAdminEmail, + workspaceCustomerId: gpCustomerId, + groupId: gpGroupId, + groupEmail: gpGroupEmail, + optInUrl: gpOptInUrl, + enabled: gpEnabled, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setGpErr(data.error?.message ?? "Opslaan mislukt"); + return; + } + setGpMsg( + data.settings?.configured + ? "Opgeslagen — Google Play-toegang is geconfigureerd" + : "Opgeslagen — nog niet compleet (private key + groep + project vereist)" + ); + setGpPrivateKey(""); + loadGooglePlay(); + } finally { + setGpBusy(false); + } + } + return ( <>