stremio/apps/admin-ui/src/app/viewers/page.tsx
Jos Vooges | STH 0eb0c5d367 Add admin Google Play Closed Testing access via Cloud Identity Groups.
Admins can enable or revoke store access per viewer; membership syncs to a configurable Google Group without changing app auth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 03:04:34 +02:00

456 lines
14 KiB
TypeScript

"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 GooglePlaySyncStatus =
| "NOT_ENABLED"
| "PENDING_ADD"
| "ACTIVE"
| "PENDING_REMOVE"
| "REMOVED"
| "ERROR";
type Viewer = {
id: string;
email: string;
name: string | null;
enabled: boolean;
appAccess: boolean;
googlePlayEmail: string | null;
googlePlayAccess: boolean;
googlePlayMembershipId: string | null;
googlePlaySyncStatus: GooglePlaySyncStatus;
googlePlayLastSyncedAt: string | null;
googlePlaySyncError: string | null;
googlePlayResolvedEmail: string;
createdAt: string;
deviceCount: number;
devices: Device[];
};
type PlayConfig = {
configured: boolean;
groupEmail: string | null;
groupId: string | null;
optInUrl: string | null;
};
const SYNC_LABELS: Record<GooglePlaySyncStatus, string> = {
NOT_ENABLED: "Niet ingeschakeld",
PENDING_ADD: "Bezig met toevoegen…",
ACTIVE: "Actief",
PENDING_REMOVE: "Bezig met verwijderen…",
REMOVED: "Verwijderd",
ERROR: "Fout",
};
export default function ViewersPage() {
useAuth();
const [viewers, setViewers] = useState<Viewer[]>([]);
const [playConfig, setPlayConfig] = useState<PlayConfig | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [playEmails, setPlayEmails] = useState<Record<string, string>>({});
const load = useCallback(() => {
fetch("/api/v1/admin/viewers", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const list: Viewer[] = d.viewers ?? [];
setViewers(list);
setPlayEmails((prev) => {
const next = { ...prev };
for (const v of list) {
if (next[v.id] === undefined) {
next[v.id] = v.googlePlayEmail ?? "";
}
}
return next;
});
})
.catch(() => setErr("Kon kijkers niet laden"));
}, []);
useEffect(() => {
load();
fetch("/api/v1/admin/google-play-access/config", { credentials: "include" })
.then((r) => r.json())
.then((d) => setPlayConfig(d.config ?? null))
.catch(() => undefined);
}, [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 setPlayAccess(v: Viewer, enabled: boolean) {
setMsg(null);
setErr(null);
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enabled,
email: playEmails[v.id]?.trim() || null,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Google Play-toegang bijwerken mislukt");
} else {
setMsg(data.message ?? (enabled ? "Google Play access enabled." : "Google Play access removed."));
if (data.syncError) setErr(data.syncError);
}
load();
}
async function retryPlaySync(v: Viewer) {
setMsg(null);
setErr(null);
const res = await fetch(`/api/v1/admin/viewers/${v.id}/google-play-access/retry`, {
method: "POST",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Synchronisatie mislukt");
} else {
setMsg(data.message ?? "Synchronization completed.");
if (data.syncError) setErr(data.syncError);
}
load();
}
async function bulkAction(path: "sync-all" | "reconcile") {
setMsg(null);
setErr(null);
const res = await fetch(`/api/v1/admin/google-play-access/${path}`, {
method: "POST",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setErr(data.error?.message ?? "Actie mislukt");
return;
}
const s = data.summary;
setMsg(
`Scanned: ${s.scanned} · Correct: ${s.correct} · Added: ${s.added} · Removed: ${s.removed} · Errors: ${s.errors}`
);
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 (
<>
<Nav />
<main className="page">
<header className="page-header">
<div>
<p className="page-kicker">Mensen</p>
<h1>Kijkers</h1>
<p className="muted">
Accounts voor de Android TV / phone-app. Gebruikers koppelen zelf via{" "}
<a href="/link">/link</a> met een code.
</p>
</div>
</header>
{playConfig && (
<section className="card-block play-banner">
<h2>Google Play access</h2>
<p className="muted">
{playConfig.configured
? `Groep: ${playConfig.groupEmail ?? playConfig.groupId}`
: "Google Play-toegang is nog niet geconfigureerd op de server (zie GOOGLE_PLAY_ACCESS_SETUP.md)."}
</p>
{playConfig.configured && (
<div className="viewer-actions">
<button type="button" onClick={() => bulkAction("sync-all")}>
Sync all
</button>
<button type="button" onClick={() => bulkAction("reconcile")}>
Reconcile
</button>
</div>
)}
</section>
)}
<section className="card-block">
<h2>Nieuwe kijker</h2>
<form className="form-grid" onSubmit={createViewer}>
<label>
Naam
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Jos" />
</label>
<label>
E-mail
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label>
Wachtwoord
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit" className="btn-primary" disabled={busy}>
Aanmaken
</button>
</form>
{msg && <p className="form-ok">{msg}</p>}
{err && <p className="form-error">{err}</p>}
</section>
<section className="card-block">
<h2>Overzicht</h2>
{viewers.length === 0 ? (
<p className="muted">Nog geen kijkers.</p>
) : (
<div className="viewer-list">
{viewers.map((v) => (
<article key={v.id} className="viewer-row">
<div>
<strong>{v.name || v.email}</strong>
<div className="muted">Account email: {v.email}</div>
<div className="muted">
{v.enabled ? "Actief" : "Uit"} · app {v.appAccess ? "aan" : "uit"} ·{" "}
{v.deviceCount} apparaat(en)
</div>
</div>
<div className="viewer-actions">
<button type="button" onClick={() => toggle(v, "enabled")}>
{v.enabled ? "Uitschakelen" : "Activeren"}
</button>
<button type="button" onClick={() => toggle(v, "appAccess")}>
{v.appAccess ? "App uit" : "App aan"}
</button>
</div>
<div className="play-section">
<h3>Google Play access</h3>
<label className="play-field">
Google Play email
<input
type="email"
placeholder={v.email}
value={playEmails[v.id] ?? ""}
onChange={(e) =>
setPlayEmails((prev) => ({ ...prev, [v.id]: e.target.value }))
}
/>
<span className="muted">
Fallback: {v.googlePlayResolvedEmail || v.email}
</span>
</label>
<div className="play-meta">
<span>
Store access:{" "}
<strong>{v.googlePlayAccess ? "ON" : "OFF"}</strong>
</span>
<span>
Status:{" "}
<strong className={v.googlePlaySyncStatus === "ERROR" ? "status-err" : ""}>
{SYNC_LABELS[v.googlePlaySyncStatus] ?? v.googlePlaySyncStatus}
</strong>
</span>
{v.googlePlayLastSyncedAt && (
<span className="muted">
Last synchronized:{" "}
{new Date(v.googlePlayLastSyncedAt).toLocaleString("nl-NL")}
</span>
)}
{v.googlePlaySyncError && (
<span className="form-error">{v.googlePlaySyncError}</span>
)}
</div>
<div className="viewer-actions">
{!v.googlePlayAccess ? (
<button type="button" onClick={() => setPlayAccess(v, true)}>
Store access aan
</button>
) : (
<button type="button" onClick={() => setPlayAccess(v, false)}>
Store access uit
</button>
)}
{v.googlePlaySyncStatus === "ERROR" && (
<button type="button" onClick={() => retryPlaySync(v)}>
Retry synchronization
</button>
)}
{v.googlePlayAccess &&
v.googlePlaySyncStatus === "ACTIVE" &&
playConfig?.optInUrl && (
<a
href={playConfig.optInUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-link"
>
Open Google Play
</a>
)}
</div>
</div>
{v.devices.length > 0 && (
<ul className="device-list">
{v.devices.map((d) => (
<li key={d.id}>
<span>
{d.name} <span className="muted">({d.platform})</span>
</span>
<button type="button" onClick={() => revokeDevice(d.id)}>
Intrekken
</button>
</li>
))}
</ul>
)}
</article>
))}
</div>
)}
</section>
</main>
<style jsx>{`
.viewer-list {
display: grid;
gap: 1rem;
}
.viewer-row {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1rem;
display: grid;
gap: 0.75rem;
}
.viewer-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.play-section {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 0.75rem;
display: grid;
gap: 0.5rem;
}
.play-section h3 {
margin: 0;
font-size: 0.95rem;
}
.play-field {
display: grid;
gap: 0.25rem;
max-width: 360px;
}
.play-field input {
width: 100%;
}
.play-meta {
display: grid;
gap: 0.25rem;
font-size: 0.9rem;
}
.play-banner .viewer-actions {
margin-top: 0.5rem;
}
.status-err {
color: #ff8b8b;
}
.btn-link {
color: #8ec5ff;
text-decoration: underline;
font-size: 0.9rem;
}
.device-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.device-list li {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
font-size: 0.9rem;
}
.form-ok {
color: #8dffb0;
}
.form-error {
color: #ff8b8b;
}
`}</style>
</>
);
}