From 64053501048a5f0037f5da441ea412b45076df04 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 09:17:34 +0200 Subject: [PATCH] feat(demo): login trigger, feature gating + admin UI (HU/EN/DE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login page quietly probes /auth/demo (debounced) as a valid email is typed/pasted and reloads into the app on a match — no visible button. Demo sessions default to the whole library, get a one-time shared-account warning, never see the YouTube-connect onboarding/access UI or sync actions, and admins get a demo whitelist + reset panel in Settings. --- frontend/src/App.tsx | 11 + frontend/src/components/Login.tsx | 26 ++- frontend/src/components/SettingsPanel.tsx | 232 ++++++++++++++++----- frontend/src/i18n/locales/de/common.json | 4 +- frontend/src/i18n/locales/de/settings.json | 21 +- frontend/src/i18n/locales/en/common.json | 4 +- frontend/src/i18n/locales/en/settings.json | 21 +- frontend/src/i18n/locales/hu/common.json | 4 +- frontend/src/i18n/locales/hu/settings.json | 21 +- frontend/src/lib/api.ts | 21 ++ frontend/src/lib/onboarding.ts | 4 +- 11 files changed, 307 insertions(+), 62 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d74470c..91fdfee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -189,6 +189,17 @@ export default function App() { message: t("common.accessRequestsMessage", { count: pending }), }); } + // The demo account is shared: there are no subscriptions, so default it to the whole + // library (the "my" feed would be empty), and warn that its state is communal. + if (meQuery.data?.is_demo) { + setFilters({ ...loadStoredFilters(), scope: "all" }); + notify({ + level: "warning", + title: t("common.demoTitle"), + message: t("common.demoSharedNotice"), + requiresInteraction: true, + }); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.id]); diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 71bee93..b139df5 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../lib/api"; import { setLanguage, type LangCode } from "../i18n"; @@ -6,6 +6,8 @@ import LanguageSwitcher from "./LanguageSwitcher"; type Phase = "idle" | "sending" | "requested" | "approved" | "error"; +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + export default function Login() { const { t, i18n } = useTranslation(); @@ -18,6 +20,28 @@ export default function Login() { const [phase, setPhase] = useState(bounced === "requested" ? "requested" : "idle"); const [error, setError] = useState(""); + // Hidden demo entry: a whitelisted email signs straight into the shared demo account, with + // no button to give it away. We watch the field and — once it holds a syntactically valid + // email and typing has settled — quietly probe /auth/demo. A match sets the session, so we + // reload into the app; a non-match does nothing visible (the normal "request access" flow + // stays available). The server is rate-limited and answers uniformly, so this can't be + // hammered as an oracle. Skipped while the request-access form is mid-submit/done. + useEffect(() => { + const candidate = email.trim().toLowerCase(); + const settled = phase === "requested" || phase === "approved"; + if (phase === "sending" || settled || !EMAIL_RE.test(candidate)) return; + const timer = setTimeout(() => { + api + .demoLogin(candidate) + .then((r) => { + if (r.authenticated) window.location.reload(); + }) + .catch(() => {}); + }, 700); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [email, phase]); + async function submit(e: React.FormEvent) { e.preventDefault(); setError(""); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 1867af2..653dc2b 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react"; +import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Trash2, User, UserPlus, X } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Invite, type Me } from "../lib/api"; import { formatEta, quotaActionLabel } from "../lib/format"; @@ -14,6 +14,7 @@ import { } from "../lib/notifications"; import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints"; import Tooltip from "./Tooltip"; +import { useConfirm } from "./ConfirmProvider"; type TabId = "appearance" | "notifications" | "sync" | "account"; const TABS: { id: TabId; icon: typeof Monitor }[] = [ @@ -397,28 +398,30 @@ function Sync({ me }: { me: Me }) { )} -
- - - - - - -
+ {!me.is_demo && ( +
+ + + + + + +
+ )} {me.role === "admin" && s && (
@@ -498,38 +501,47 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
-
-

- {t("settings.account.youtubeAccessIntro")} -

-
- { - window.location.href = "/auth/upgrade?access=read"; - }} - /> - { - window.location.href = "/auth/upgrade?access=write"; - }} - /> -
- -
+ {me.is_demo ? ( +
+

+ {t("settings.account.demoNotice")} +

+
+ ) : ( +
+

+ {t("settings.account.youtubeAccessIntro")} +

+
+ { + window.location.href = "/auth/upgrade?access=read"; + }} + /> + { + window.location.href = "/auth/upgrade?access=write"; + }} + /> +
+ +
+ )} {me.role === "admin" && } + {me.role === "admin" && } ); } @@ -631,6 +643,118 @@ function AdminInvites() { ); } +function AdminDemo() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() }); + const [newEmail, setNewEmail] = useState(""); + + const add = useMutation({ + mutationFn: (email: string) => api.addDemoWhitelist(email), + onSuccess: () => { + setNewEmail(""); + qc.invalidateQueries({ queryKey: ["demo-whitelist"] }); + notify({ level: "success", message: t("settings.demo.added") }); + }, + onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }), + }); + const remove = useMutation({ + mutationFn: (id: number) => api.removeDemoWhitelist(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }), + onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }), + }); + const reset = useMutation({ + mutationFn: () => api.resetDemo(), + onSuccess: (r: { playlists_seeded: number }) => + notify({ + level: "success", + message: t("settings.demo.resetDone", { count: r.playlists_seeded }), + }), + onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }), + }); + + const rows = list.data ?? []; + + return ( +
+

{t("settings.demo.intro")}

+ + {rows.length > 0 && ( +
+ {rows.map((r) => ( +
+
+
{r.email}
+ {r.added_by && ( +
+ {t("settings.demo.addedBy", { who: r.added_by })} +
+ )} +
+ + + +
+ ))} +
+ )} + +
{ + e.preventDefault(); + if (newEmail.trim()) add.mutate(newEmail.trim()); + }} + className="flex items-center gap-2" + > + setNewEmail(e.target.value)} + placeholder={t("settings.demo.addPlaceholder")} + className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + +
+ +
+ + + +
+
+ ); +} + function InviteRow({ inv, onApprove, diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index 8ae17fd..70a8846 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -12,5 +12,7 @@ "language": "Sprache", "somethingWrong": "Etwas ist schiefgelaufen.", "accessRequestsTitle": "Zugangsanfragen", - "accessRequestsMessage": "{{count}} ausstehend — prüfe sie unter Einstellungen → Konto." + "accessRequestsMessage": "{{count}} ausstehend — prüfe sie unter Einstellungen → Konto.", + "demoTitle": "Demo-Konto", + "demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden." } diff --git a/frontend/src/i18n/locales/de/settings.json b/frontend/src/i18n/locales/de/settings.json index ab9ce5d..26230d7 100644 --- a/frontend/src/i18n/locales/de/settings.json +++ b/frontend/src/i18n/locales/de/settings.json @@ -79,7 +79,26 @@ "granted": "Erteilt", "enable": "Aktivieren", "enableHint": "Leitet zu Google weiter, um die Berechtigung zu erteilen. Google zeigt einen Hinweis „nicht verifizierte App“ — das ist zu erwarten; klicke auf Erweitert → Weiter zu Siftlode.", - "walkMeThrough": "Führe mich durch die Einrichtung" + "walkMeThrough": "Führe mich durch die Einrichtung", + "demoNotice": "Dies ist das gemeinsame Demo-Konto. Es hat keine YouTube-Verbindung — du kannst die gesamte gemeinsame Bibliothek durchsuchen, filtern und sortieren, Playlists erstellen und Dinge ausprobieren, aber nichts berührt ein echtes YouTube-Konto." + }, + "demo": { + "title": "Demo-Zugang", + "intro": "E-Mails hier können das gemeinsame Demo-Konto direkt von der Anmeldeseite aus betreten — ohne Google-Anmeldung. Sie tippen die Adresse einfach ins Feld und werden nach einem Moment eingelassen.", + "addPlaceholder": "E-Mail für die Demo auf die Whitelist…", + "add": "Hinzufügen", + "added": "Zur Demo-Whitelist hinzugefügt", + "addFailed": "Diese E-Mail konnte nicht hinzugefügt werden", + "removeFailed": "Diese E-Mail konnte nicht entfernt werden", + "remove": "Entfernen", + "removeHint": "Diese E-Mail von der Demo-Whitelist entfernen.", + "addedBy": "hinzugefügt von {{who}}", + "reset": "Demo-Zustand zurücksetzen", + "resetHint": "Löscht die Playlists, ausgeblendeten/angesehenen/gespeicherten Videos und Einstellungen des Demo-Kontos und legt dann ein paar Beispiel-Playlists neu an.", + "resetTitle": "Demo-Zustand zurücksetzen?", + "resetConfirm": "Dies löscht alles, was das Demo-Konto angesammelt hat — Playlists, ausgeblendete/angesehene/gespeicherte Videos und Einstellungen — und legt ein paar Beispiel-Playlists neu an. Es kann nicht rückgängig gemacht werden.", + "resetDone": "Demo zurückgesetzt — {{count}} Beispiel-Playlist(s) angelegt", + "resetFailed": "Das Demo-Konto konnte nicht zurückgesetzt werden" }, "invites": { "title": "Zugriffsanfragen", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 228f33a..e282281 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -12,5 +12,7 @@ "language": "Language", "somethingWrong": "Something went wrong.", "accessRequestsTitle": "Access requests", - "accessRequestsMessage": "{{count}} pending — review in Settings → Account." + "accessRequestsMessage": "{{count}} pending — review in Settings → Account.", + "demoTitle": "Demo account", + "demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time." } diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 0686d64..0b39c94 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -79,7 +79,26 @@ "granted": "Granted", "enable": "Enable", "enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.", - "walkMeThrough": "Walk me through setup" + "walkMeThrough": "Walk me through setup", + "demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account." + }, + "demo": { + "title": "Demo access", + "intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.", + "addPlaceholder": "Whitelist an email for demo…", + "add": "Add", + "added": "Added to the demo whitelist", + "addFailed": "Couldn't add that email", + "removeFailed": "Couldn't remove that email", + "remove": "Remove", + "removeHint": "Remove this email from the demo whitelist.", + "addedBy": "added by {{who}}", + "reset": "Reset demo state", + "resetHint": "Wipe the demo account's playlists, hidden/watched/saved videos and settings, then re-seed a few sample playlists.", + "resetTitle": "Reset demo state?", + "resetConfirm": "This clears everything the demo account has accumulated — playlists, hidden/watched/saved videos and settings — and re-seeds a few sample playlists. It can't be undone.", + "resetDone": "Demo reset — {{count}} sample playlist(s) seeded", + "resetFailed": "Couldn't reset the demo account" }, "invites": { "title": "Access requests", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index d010adc..1389955 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -12,5 +12,7 @@ "language": "Nyelv", "somethingWrong": "Hiba történt.", "accessRequestsTitle": "Hozzáférési kérések", - "accessRequestsMessage": "{{count}} függőben — nézd át a Beállítások → Fiók menüben." + "accessRequestsMessage": "{{count}} függőben — nézd át a Beállítások → Fiók menüben.", + "demoTitle": "Demo fiók", + "demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják." } diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 54afd23..f58012b 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -79,7 +79,26 @@ "granted": "Megadva", "enable": "Engedélyezés", "enableHint": "Átirányít a Google-höz az engedély megadásához. A Google „nem ellenőrzött alkalmazás” figyelmeztetést mutat — ez normális; kattints a Speciális → Tovább a Siftlode-ra lehetőségre.", - "walkMeThrough": "Vezess végig a beállításon" + "walkMeThrough": "Vezess végig a beállításon", + "demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz." + }, + "demo": { + "title": "Demo hozzáférés", + "intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.", + "addPlaceholder": "E-mail fehérlistára a demóhoz…", + "add": "Hozzáadás", + "added": "Hozzáadva a demo fehérlistához", + "addFailed": "Nem sikerült hozzáadni ezt az e-mailt", + "removeFailed": "Nem sikerült eltávolítani ezt az e-mailt", + "remove": "Eltávolítás", + "removeHint": "E-mail eltávolítása a demo fehérlistáról.", + "addedBy": "hozzáadta: {{who}}", + "reset": "Demo állapot visszaállítása", + "resetHint": "Törli a demo fiók lejátszási listáit, elrejtett/megnézett/mentett videóit és beállításait, majd újra létrehoz pár minta lejátszási listát.", + "resetTitle": "Visszaállítod a demo állapotot?", + "resetConfirm": "Ez törli mindazt, amit a demo fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.", + "resetDone": "Demo visszaállítva — {{count}} minta lejátszási lista létrehozva", + "resetFailed": "Nem sikerült visszaállítani a demo fiókot" }, "invites": { "title": "Hozzáférési kérelmek", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 98d8ed9..647102a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,12 +6,21 @@ export interface Me { display_name: string | null; avatar_url: string | null; role: string; + is_demo: boolean; can_read: boolean; can_write: boolean; pending_invites: number; preferences: Record; } +export interface DemoWhitelistEntry { + id: number; + email: string; + note: string | null; + added_by: string | null; + created_at: string | null; +} + export interface Invite { id: number; email: string; @@ -395,6 +404,18 @@ export const api = { // --- onboarding / admin --- requestAccess: (email: string): Promise<{ status: string }> => req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }), + // Hidden demo entry: a whitelisted email logs into the shared demo account, no Google + // sign-in. Returns whether a session was established. + demoLogin: (email: string): Promise<{ authenticated: boolean }> => + req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }), + adminDemoWhitelist: (): Promise => + req("/api/admin/demo/whitelist"), + addDemoWhitelist: (email: string): Promise => + req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }), + removeDemoWhitelist: (id: number) => + req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }), + resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> => + req("/api/admin/demo/reset", { method: "POST" }), adminInvites: (status?: string): Promise => req(`/api/admin/invites${status ? `?status=${status}` : ""}`), approveInvite: (id: number) => diff --git a/frontend/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts index b706274..9575b3f 100644 --- a/frontend/src/lib/onboarding.ts +++ b/frontend/src/lib/onboarding.ts @@ -15,7 +15,9 @@ export const ONBOARD_ACTIVE = "subfeed.onboarding.active"; export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed"; -export function shouldAutoOpenOnboarding(me: { can_read: boolean }): boolean { +export function shouldAutoOpenOnboarding(me: { can_read: boolean; is_demo?: boolean }): boolean { + // The shared demo account can never connect YouTube, so never nudge it to. + if (me.is_demo) return false; if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true; return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED); }