From 0b107515e42fb68f4f3197ee6ed3f76fdf76bd1c Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 31 Aug 2026 01:11:43 +0200 Subject: [PATCH] Add viewer accounts, device linking, and Android TV client foundation. --- apps/admin-ui/src/app/link/page.tsx | 142 ++++ apps/admin-ui/src/app/viewers/page.tsx | 225 ++++++ apps/admin-ui/src/components/Nav.tsx | 1 + apps/android-tv/.gitignore | 12 + apps/android-tv/README.md | 30 + apps/android-tv/app/build.gradle.kts | 66 ++ apps/android-tv/app/proguard-rules.pro | 1 + .../app/src/main/AndroidManifest.xml | 35 + .../nl/vonas/mediacluster/tv/MainActivity.kt | 20 + .../java/nl/vonas/mediacluster/tv/McApp.kt | 5 + .../vonas/mediacluster/tv/data/ApiClient.kt | 278 +++++++ .../mediacluster/tv/data/SessionStore.kt | 31 + .../java/nl/vonas/mediacluster/tv/ui/McNav.kt | 692 +++++++++++++++++ .../vonas/mediacluster/tv/ui/theme/Theme.kt | 24 + .../app/src/main/res/drawable/app_banner.xml | 5 + .../src/main/res/drawable/ic_launcher_fg.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../app/src/main/res/values/colors.xml | 4 + .../app/src/main/res/values/strings.xml | 4 + .../app/src/main/res/values/themes.xml | 7 + apps/android-tv/build.gradle.kts | 5 + apps/android-tv/gradle.properties | 9 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + apps/android-tv/gradlew | 248 ++++++ apps/android-tv/gradlew.bat | 93 +++ apps/android-tv/settings.gradle.kts | 16 + .../migration.sql | 103 +++ apps/master-api/prisma/schema.prisma | 99 +++ apps/master-api/src/app.ts | 90 +++ apps/master-api/src/config.ts | 2 + apps/master-api/src/viewer/routes.ts | 187 +++++ apps/master-api/src/viewer/service.ts | 717 ++++++++++++++++++ 33 files changed, 3173 insertions(+) create mode 100644 apps/admin-ui/src/app/link/page.tsx create mode 100644 apps/admin-ui/src/app/viewers/page.tsx create mode 100644 apps/android-tv/.gitignore create mode 100644 apps/android-tv/README.md create mode 100644 apps/android-tv/app/build.gradle.kts create mode 100644 apps/android-tv/app/proguard-rules.pro create mode 100644 apps/android-tv/app/src/main/AndroidManifest.xml create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/MainActivity.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/McApp.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/SessionStore.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/McNav.kt create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/ui/theme/Theme.kt create mode 100644 apps/android-tv/app/src/main/res/drawable/app_banner.xml create mode 100644 apps/android-tv/app/src/main/res/drawable/ic_launcher_fg.xml create mode 100644 apps/android-tv/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 apps/android-tv/app/src/main/res/values/colors.xml create mode 100644 apps/android-tv/app/src/main/res/values/strings.xml create mode 100644 apps/android-tv/app/src/main/res/values/themes.xml create mode 100644 apps/android-tv/build.gradle.kts create mode 100644 apps/android-tv/gradle.properties create mode 100644 apps/android-tv/gradle/wrapper/gradle-wrapper.jar create mode 100644 apps/android-tv/gradle/wrapper/gradle-wrapper.properties create mode 100644 apps/android-tv/gradlew create mode 100644 apps/android-tv/gradlew.bat create mode 100644 apps/android-tv/settings.gradle.kts create mode 100644 apps/master-api/prisma/migrations/20260831010000_viewer_client/migration.sql create mode 100644 apps/master-api/src/viewer/routes.ts create mode 100644 apps/master-api/src/viewer/service.ts diff --git a/apps/admin-ui/src/app/link/page.tsx b/apps/admin-ui/src/app/link/page.tsx new file mode 100644 index 0000000..65a59e2 --- /dev/null +++ b/apps/admin-ui/src/app/link/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; + +export default function LinkDevicePage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [ok, setOk] = useState(null); + + useEffect(() => { + const q = new URLSearchParams(window.location.search); + const c = q.get("code"); + if (c) setCode(c.toUpperCase()); + }, []); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setErr(null); + setOk(null); + try { + const res = await fetch("/api/v1/link/claim", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, code }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Koppelen mislukt"); + return; + } + setOk(`Apparaat “${data.deviceName ?? "TV"}” is gekoppeld. Je kunt dit venster sluiten.`); + setPassword(""); + } finally { + setBusy(false); + } + } + + return ( +
+
+

Media Cluster

+

Apparaat koppelen

+

+ Log in met je kijker-account en voer de code van je TV of telefoon in. +

+
+ + + + {err &&

{err}

} + {ok &&

{ok}

} + +
+
+ +
+ ); +} diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx new file mode 100644 index 0000000..dd45a75 --- /dev/null +++ b/apps/admin-ui/src/app/viewers/page.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { Nav, useAuth } from "@/components/Nav"; + +type Device = { + id: string; + name: string; + platform: string; + lastSeenAt: string | null; + createdAt: string; +}; + +type Viewer = { + id: string; + email: string; + name: string | null; + enabled: boolean; + appAccess: boolean; + createdAt: string; + deviceCount: number; + devices: Device[]; +}; + +export default function ViewersPage() { + useAuth(); + const [viewers, setViewers] = useState([]); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + + const load = useCallback(() => { + fetch("/api/v1/admin/viewers", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => setViewers(d.viewers ?? [])) + .catch(() => setErr("Kon kijkers niet laden")); + }, []); + + useEffect(() => { + load(); + }, [load]); + + async function createViewer(e: FormEvent) { + e.preventDefault(); + setBusy(true); + setMsg(null); + setErr(null); + try { + const res = await fetch("/api/v1/admin/viewers", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, name: name || undefined, appAccess: true }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setErr(data.error?.message ?? "Aanmaken mislukt"); + return; + } + setMsg(`Kijker ${data.viewer.email} aangemaakt`); + setEmail(""); + setPassword(""); + setName(""); + load(); + } finally { + setBusy(false); + } + } + + async function toggle(v: Viewer, field: "enabled" | "appAccess") { + await fetch(`/api/v1/admin/viewers/${v.id}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [field]: !v[field] }), + }); + load(); + } + + async function revokeDevice(deviceId: string) { + if (!confirm("Apparaat intrekken?")) return; + await fetch(`/api/v1/admin/viewers/devices/${deviceId}/revoke`, { + method: "POST", + credentials: "include", + }); + load(); + } + + return ( + <> +