feat(demo): login trigger, feature gating + admin UI (HU/EN/DE)
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.
This commit is contained in:
parent
e0c63c26d4
commit
6405350104
11 changed files with 307 additions and 62 deletions
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Phase>(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("");
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("settings.sync.actions")}>
|
||||
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
||||
<button
|
||||
onClick={() => syncSubs.mutate()}
|
||||
disabled={syncSubs.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||
{t("settings.sync.syncSubscriptions")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
|
||||
<button
|
||||
onClick={() => deepAll.mutate()}
|
||||
disabled={deepAll.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
||||
>
|
||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("settings.sync.backfillEverything")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
{!me.is_demo && (
|
||||
<Section title={t("settings.sync.actions")}>
|
||||
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
||||
<button
|
||||
onClick={() => syncSubs.mutate()}
|
||||
disabled={syncSubs.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||
{t("settings.sync.syncSubscriptions")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
|
||||
<button
|
||||
onClick={() => deepAll.mutate()}
|
||||
disabled={deepAll.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
||||
>
|
||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("settings.sync.backfillEverything")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{me.role === "admin" && s && (
|
||||
<Section title={t("settings.sync.admin")}>
|
||||
|
|
@ -498,38 +501,47 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||
{t("settings.account.youtubeAccessIntro")}
|
||||
</p>
|
||||
<div className="divide-y divide-border">
|
||||
<AccessRow
|
||||
title={t("settings.account.readTitle")}
|
||||
granted={me.can_read}
|
||||
grantedHint={t("settings.account.readGrantedHint")}
|
||||
enableHint={t("settings.account.readEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=read";
|
||||
}}
|
||||
/>
|
||||
<AccessRow
|
||||
title={t("settings.account.writeTitle")}
|
||||
granted={me.can_write}
|
||||
grantedHint={t("settings.account.writeGrantedHint")}
|
||||
enableHint={t("settings.account.writeEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=write";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="mt-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
{t("settings.account.walkMeThrough")}
|
||||
</button>
|
||||
</Section>
|
||||
{me.is_demo ? (
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<p className="text-xs text-muted leading-relaxed">
|
||||
{t("settings.account.demoNotice")}
|
||||
</p>
|
||||
</Section>
|
||||
) : (
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||
{t("settings.account.youtubeAccessIntro")}
|
||||
</p>
|
||||
<div className="divide-y divide-border">
|
||||
<AccessRow
|
||||
title={t("settings.account.readTitle")}
|
||||
granted={me.can_read}
|
||||
grantedHint={t("settings.account.readGrantedHint")}
|
||||
enableHint={t("settings.account.readEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=read";
|
||||
}}
|
||||
/>
|
||||
<AccessRow
|
||||
title={t("settings.account.writeTitle")}
|
||||
granted={me.can_write}
|
||||
grantedHint={t("settings.account.writeGrantedHint")}
|
||||
enableHint={t("settings.account.writeEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=write";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="mt-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
{t("settings.account.walkMeThrough")}
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
{me.role === "admin" && <AdminInvites />}
|
||||
{me.role === "admin" && <AdminDemo />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Section title={t("settings.demo.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{r.email}</div>
|
||||
{r.added_by && (
|
||||
<div className="text-[11px] text-muted truncate">
|
||||
{t("settings.demo.addedBy", { who: r.added_by })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||
<button
|
||||
onClick={() => remove.mutate(r.id)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
||||
aria-label={t("settings.demo.remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newEmail.trim()) add.mutate(newEmail.trim());
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<Tooltip hint={t("settings.demo.resetHint")}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t("settings.demo.resetTitle"),
|
||||
message: t("settings.demo.resetConfirm"),
|
||||
confirmLabel: t("settings.demo.reset"),
|
||||
danger: true,
|
||||
})
|
||||
)
|
||||
reset.mutate();
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
||||
{t("settings.demo.reset")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteRow({
|
||||
inv,
|
||||
onApprove,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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<DemoWhitelistEntry[]> =>
|
||||
req("/api/admin/demo/whitelist"),
|
||||
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
|
||||
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<Invite[]> =>
|
||||
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
||||
approveInvite: (id: number) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue