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>
456 lines
14 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|