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 }),
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meQuery.data?.id]);
|
}, [meQuery.data?.id]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { setLanguage, type LangCode } from "../i18n";
|
import { setLanguage, type LangCode } from "../i18n";
|
||||||
|
|
@ -6,6 +6,8 @@ import LanguageSwitcher from "./LanguageSwitcher";
|
||||||
|
|
||||||
type Phase = "idle" | "sending" | "requested" | "approved" | "error";
|
type Phase = "idle" | "sending" | "requested" | "approved" | "error";
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
|
@ -18,6 +20,28 @@ export default function Login() {
|
||||||
const [phase, setPhase] = useState<Phase>(bounced === "requested" ? "requested" : "idle");
|
const [phase, setPhase] = useState<Phase>(bounced === "requested" ? "requested" : "idle");
|
||||||
const [error, setError] = useState("");
|
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) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, type Invite, type Me } from "../lib/api";
|
import { api, type Invite, type Me } from "../lib/api";
|
||||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from "../lib/notifications";
|
} from "../lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
type TabId = "appearance" | "notifications" | "sync" | "account";
|
type TabId = "appearance" | "notifications" | "sync" | "account";
|
||||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
|
|
@ -397,6 +398,7 @@ function Sync({ me }: { me: Me }) {
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{!me.is_demo && (
|
||||||
<Section title={t("settings.sync.actions")}>
|
<Section title={t("settings.sync.actions")}>
|
||||||
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -419,6 +421,7 @@ function Sync({ me }: { me: Me }) {
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Section>
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{me.role === "admin" && s && (
|
{me.role === "admin" && s && (
|
||||||
<Section title={t("settings.sync.admin")}>
|
<Section title={t("settings.sync.admin")}>
|
||||||
|
|
@ -498,6 +501,13 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</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")}>
|
<Section title={t("settings.account.youtubeAccess")}>
|
||||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||||
{t("settings.account.youtubeAccessIntro")}
|
{t("settings.account.youtubeAccessIntro")}
|
||||||
|
|
@ -529,7 +539,9 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
{t("settings.account.walkMeThrough")}
|
{t("settings.account.walkMeThrough")}
|
||||||
</button>
|
</button>
|
||||||
</Section>
|
</Section>
|
||||||
|
)}
|
||||||
{me.role === "admin" && <AdminInvites />}
|
{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({
|
function InviteRow({
|
||||||
inv,
|
inv,
|
||||||
onApprove,
|
onApprove,
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,7 @@
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"somethingWrong": "Etwas ist schiefgelaufen.",
|
"somethingWrong": "Etwas ist schiefgelaufen.",
|
||||||
"accessRequestsTitle": "Zugangsanfragen",
|
"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",
|
"granted": "Erteilt",
|
||||||
"enable": "Aktivieren",
|
"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.",
|
"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": {
|
"invites": {
|
||||||
"title": "Zugriffsanfragen",
|
"title": "Zugriffsanfragen",
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,7 @@
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"somethingWrong": "Something went wrong.",
|
"somethingWrong": "Something went wrong.",
|
||||||
"accessRequestsTitle": "Access requests",
|
"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",
|
"granted": "Granted",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.",
|
"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": {
|
"invites": {
|
||||||
"title": "Access requests",
|
"title": "Access requests",
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,7 @@
|
||||||
"language": "Nyelv",
|
"language": "Nyelv",
|
||||||
"somethingWrong": "Hiba történt.",
|
"somethingWrong": "Hiba történt.",
|
||||||
"accessRequestsTitle": "Hozzáférési kérések",
|
"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",
|
"granted": "Megadva",
|
||||||
"enable": "Engedélyezés",
|
"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.",
|
"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": {
|
"invites": {
|
||||||
"title": "Hozzáférési kérelmek",
|
"title": "Hozzáférési kérelmek",
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,21 @@ export interface Me {
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
|
is_demo: boolean;
|
||||||
can_read: boolean;
|
can_read: boolean;
|
||||||
can_write: boolean;
|
can_write: boolean;
|
||||||
pending_invites: number;
|
pending_invites: number;
|
||||||
preferences: Record<string, any>;
|
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 {
|
export interface Invite {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -395,6 +404,18 @@ export const api = {
|
||||||
// --- onboarding / admin ---
|
// --- onboarding / admin ---
|
||||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||||
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
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[]> =>
|
adminInvites: (status?: string): Promise<Invite[]> =>
|
||||||
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
||||||
approveInvite: (id: number) =>
|
approveInvite: (id: number) =>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@
|
||||||
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
|
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
|
||||||
export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed";
|
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;
|
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
||||||
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
|
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue