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:
npeter83 2026-06-16 09:17:34 +02:00
parent e0c63c26d4
commit 6405350104
11 changed files with 307 additions and 62 deletions

View file

@ -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]);

View file

@ -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("");

View file

@ -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,28 +398,30 @@ function Sync({ me }: { me: Me }) {
)} )}
</Section> </Section>
<Section title={t("settings.sync.actions")}> {!me.is_demo && (
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}> <Section title={t("settings.sync.actions")}>
<button <Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
onClick={() => syncSubs.mutate()} <button
disabled={syncSubs.isPending} onClick={() => syncSubs.mutate()}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" 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")} <RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
</button> {t("settings.sync.syncSubscriptions")}
</Tooltip> </button>
<Tooltip hint={t("settings.sync.backfillEverythingHint")}> </Tooltip>
<button <Tooltip hint={t("settings.sync.backfillEverythingHint")}>
onClick={() => deepAll.mutate()} <button
disabled={deepAll.isPending} onClick={() => deepAll.mutate()}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2" 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")} <History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
</button> {t("settings.sync.backfillEverything")}
</Tooltip> </button>
</Section> </Tooltip>
</Section>
)}
{me.role === "admin" && s && ( {me.role === "admin" && s && (
<Section title={t("settings.sync.admin")}> <Section title={t("settings.sync.admin")}>
@ -498,38 +501,47 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</div> </div>
</Section> </Section>
<Section title={t("settings.account.youtubeAccess")}> {me.is_demo ? (
<p className="text-xs text-muted leading-relaxed mb-1"> <Section title={t("settings.account.youtubeAccess")}>
{t("settings.account.youtubeAccessIntro")} <p className="text-xs text-muted leading-relaxed">
</p> {t("settings.account.demoNotice")}
<div className="divide-y divide-border"> </p>
<AccessRow </Section>
title={t("settings.account.readTitle")} ) : (
granted={me.can_read} <Section title={t("settings.account.youtubeAccess")}>
grantedHint={t("settings.account.readGrantedHint")} <p className="text-xs text-muted leading-relaxed mb-1">
enableHint={t("settings.account.readEnableHint")} {t("settings.account.youtubeAccessIntro")}
onEnable={() => { </p>
window.location.href = "/auth/upgrade?access=read"; <div className="divide-y divide-border">
}} <AccessRow
/> title={t("settings.account.readTitle")}
<AccessRow granted={me.can_read}
title={t("settings.account.writeTitle")} grantedHint={t("settings.account.readGrantedHint")}
granted={me.can_write} enableHint={t("settings.account.readEnableHint")}
grantedHint={t("settings.account.writeGrantedHint")} onEnable={() => {
enableHint={t("settings.account.writeEnableHint")} window.location.href = "/auth/upgrade?access=read";
onEnable={() => { }}
window.location.href = "/auth/upgrade?access=write"; />
}} <AccessRow
/> title={t("settings.account.writeTitle")}
</div> granted={me.can_write}
<button grantedHint={t("settings.account.writeGrantedHint")}
onClick={onOpenWizard} enableHint={t("settings.account.writeEnableHint")}
className="mt-2 text-sm text-accent hover:underline" onEnable={() => {
> window.location.href = "/auth/upgrade?access=write";
{t("settings.account.walkMeThrough")} }}
</button> />
</Section> </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" && <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,

View file

@ -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."
} }

View file

@ -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",

View file

@ -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."
} }

View file

@ -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",

View file

@ -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."
} }

View file

@ -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",

View file

@ -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) =>

View file

@ -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);
} }