feat(onboarding): first-login wizard for incremental YouTube consent
After the clean name/email sign-in, a wizard walks the user through granting YouTube read (then optionally write) one step at a time, each with a plain rationale and an up-front heads-up about Google's "unverified app" screen. The visible step is derived from the granted scopes (can_read/can_write) so the flow resumes correctly across the full-page consent redirect; it's dismissible and reopenable from Settings -> Account, which now lists read and write as separate, individually-grantable access rows.
This commit is contained in:
parent
4765db89de
commit
e0980487af
5 changed files with 302 additions and 29 deletions
|
|
@ -24,6 +24,8 @@ import Feed from "./components/Feed";
|
|||
import Channels from "./components/Channels";
|
||||
import Stats from "./components/Stats";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import OnboardingWizard from "./components/OnboardingWizard";
|
||||
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
|
||||
import Toaster from "./components/Toaster";
|
||||
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
|
|
@ -61,6 +63,7 @@ export default function App() {
|
|||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||
const [page, setPageState] = useState<Page>(readPage);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
|
||||
function setFilters(next: FeedFilters) {
|
||||
setFiltersState(next);
|
||||
|
|
@ -86,6 +89,13 @@ export default function App() {
|
|||
|
||||
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
||||
|
||||
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
|
||||
// each consent redirect). Derived from granted scopes + storage flags so it's stable
|
||||
// across the full-page OAuth round-trip; dismissible and reopenable from Settings.
|
||||
useEffect(() => {
|
||||
if (meQuery.data && shouldAutoOpenOnboarding(meQuery.data)) setWizardOpen(true);
|
||||
}, [meQuery.data?.id, meQuery.data?.can_read, meQuery.data?.can_write]);
|
||||
|
||||
// On login, adopt server-stored preferences.
|
||||
useEffect(() => {
|
||||
const prefs = meQuery.data?.preferences;
|
||||
|
|
@ -181,8 +191,15 @@ export default function App() {
|
|||
view={view}
|
||||
setView={changeView}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onOpenWizard={() => {
|
||||
setSettingsOpen(false);
|
||||
setWizardOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{wizardOpen && meQuery.data && (
|
||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||
)}
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
176
frontend/src/components/OnboardingWizard.tsx
Normal file
176
frontend/src/components/OnboardingWizard.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { useEffect } from "react";
|
||||
import { AlertTriangle, Check, ShieldCheck, X, Youtube } from "lucide-react";
|
||||
import type { Me } from "../lib/api";
|
||||
import { beginGrant, endOnboarding } from "../lib/onboarding";
|
||||
|
||||
// A first-login wizard that walks the user through granting YouTube access one scope at a
|
||||
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
|
||||
// app" screen. Sign-in itself only asks for name/email (no warning); YouTube access is
|
||||
// always an explicit, informed opt-in here.
|
||||
//
|
||||
// The visible step is derived from what's already granted, so the wizard resumes correctly
|
||||
// after each redirect: no read -> "read", read but no write -> "write", both -> "done".
|
||||
|
||||
// A small reusable heads-up so the Google consent warning never feels like a surprise.
|
||||
function ConsentHeadsUp() {
|
||||
return (
|
||||
<div className="flex gap-2.5 rounded-xl border border-amber-400/30 bg-amber-400/5 p-3 text-left">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
|
||||
<p className="text-xs leading-relaxed text-fg/80">
|
||||
Google will show a <span className="font-medium">"Google hasn't verified this app"</span>{" "}
|
||||
screen — that's expected, because Subfeed hasn't gone through Google's full review.
|
||||
It's safe to continue: click <span className="font-medium">Advanced</span> →{" "}
|
||||
<span className="font-medium">Go to Subfeed</span>. You can revoke access anytime from
|
||||
your{" "}
|
||||
<a
|
||||
href="https://myaccount.google.com/permissions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Google Account
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dots({ index, total }: { index: number; total: number }) {
|
||||
return (
|
||||
<div className="flex justify-center gap-1.5">
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === index ? "w-5 bg-accent" : "w-1.5 bg-border"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
|
||||
// Closing without finishing the essential read step suppresses future auto-popups
|
||||
// (it stays reachable from Settings). Once read is granted, closing is just "done".
|
||||
const close = () => {
|
||||
endOnboarding(!me.can_read);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me.can_read]);
|
||||
|
||||
const step = !me.can_read ? "read" : !me.can_write ? "write" : "done";
|
||||
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]" onClick={close} />
|
||||
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{step === "read" && (
|
||||
<>
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Youtube className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">Connect your YouTube subscriptions</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||
You're signed in. To build your feed, Subfeed needs{" "}
|
||||
<span className="text-fg/90">read-only</span> access to the channels you're
|
||||
subscribed to on YouTube. It never posts, deletes, or changes anything with this.
|
||||
</p>
|
||||
<ConsentHeadsUp />
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => beginGrant("read")}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
<Youtube className="w-4 h-4" /> Connect (read-only)
|
||||
</button>
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "write" && (
|
||||
<>
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Check className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">You're connected</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||
Your feed is ready. Optionally, you can let Subfeed{" "}
|
||||
<span className="text-fg/90">unsubscribe</span> from channels on YouTube for you —
|
||||
this needs an extra write permission. Skip it to stay read-only; you can always
|
||||
enable it later in Settings.
|
||||
</p>
|
||||
<ConsentHeadsUp />
|
||||
<div className="mt-5 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => beginGrant("write")}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" /> Enable unsubscribe
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
endOnboarding(false);
|
||||
onClose();
|
||||
}}
|
||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||
>
|
||||
No thanks — keep it read-only
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<>
|
||||
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
|
||||
<Check className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">All set</h2>
|
||||
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
|
||||
Read and unsubscribe access are both granted. You can review or revoke either one
|
||||
anytime in Settings → Account, or from your Google Account.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
endOnboarding(false);
|
||||
onClose();
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition w-full"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Dots index={stepIndex} total={3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ export default function SettingsPanel({
|
|||
view,
|
||||
setView,
|
||||
onClose,
|
||||
onOpenWizard,
|
||||
}: {
|
||||
me: Me;
|
||||
theme: ThemePrefs;
|
||||
|
|
@ -36,6 +37,7 @@ export default function SettingsPanel({
|
|||
view: "grid" | "list";
|
||||
setView: (v: "grid" | "list") => void;
|
||||
onClose: () => void;
|
||||
onOpenWizard: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabId>("appearance");
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
|
@ -115,7 +117,7 @@ export default function SettingsPanel({
|
|||
)}
|
||||
{t.id === "notifications" && <Notifications />}
|
||||
{t.id === "sync" && <Sync me={me} />}
|
||||
{t.id === "account" && <Account me={me} />}
|
||||
{t.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -464,7 +466,46 @@ function Sync({ me }: { me: Me }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Account({ me }: { me: Me }) {
|
||||
function AccessRow({
|
||||
title,
|
||||
granted,
|
||||
grantedHint,
|
||||
enableHint,
|
||||
onEnable,
|
||||
}: {
|
||||
title: string;
|
||||
granted: boolean;
|
||||
grantedHint: string;
|
||||
enableHint: string;
|
||||
onEnable: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||
{granted ? grantedHint : enableHint}
|
||||
</p>
|
||||
</div>
|
||||
{granted ? (
|
||||
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||
Granted
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint="Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Subfeed.">
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Section title="Account">
|
||||
|
|
@ -480,34 +521,39 @@ function Account({ me }: { me: Me }) {
|
|||
<div className="text-xs text-muted capitalize">{me.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 pt-3 border-t border-border">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">Playlist editing & YouTube export</div>
|
||||
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||
{me.can_write
|
||||
? "Granted. You can unsubscribe from channels on YouTube (and export playlists once that ships). Subfeed only writes when you ask it to."
|
||||
: "Subfeed is read-only by default — it can browse your subscriptions but not change your YouTube account. Enable this to unsubscribe from channels (and, later, export playlists). You'll re-consent with Google."}
|
||||
</p>
|
||||
</div>
|
||||
{me.can_write ? (
|
||||
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint="Redirects to Google to grant YouTube write access (unsubscribe / export). You can keep using Subfeed read-only if you skip it.">
|
||||
<button
|
||||
onClick={() => {
|
||||
window.location.href = "/auth/upgrade";
|
||||
}}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="YouTube access">
|
||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||
Sign-in only shares your name and email. YouTube access is granted separately, below —
|
||||
each is optional and revocable anytime in your Google Account.
|
||||
</p>
|
||||
<div className="divide-y divide-border">
|
||||
<AccessRow
|
||||
title="Read subscriptions (your feed)"
|
||||
granted={me.can_read}
|
||||
grantedHint="Granted. Subfeed reads the channels you follow to build your feed. It never changes your YouTube account with this."
|
||||
enableHint="Read-only access to your subscriptions — required to build your feed. Subfeed can't modify anything with it."
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=read";
|
||||
}}
|
||||
/>
|
||||
<AccessRow
|
||||
title="Unsubscribe (write)"
|
||||
granted={me.can_write}
|
||||
grantedHint="Granted. You can unsubscribe from channels on YouTube from inside Subfeed. It only writes when you ask it to."
|
||||
enableHint="Lets Subfeed unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only."
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=write";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="mt-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
Walk me through setup
|
||||
</button>
|
||||
</Section>
|
||||
{me.role === "admin" && <AdminInvites />}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface Me {
|
|||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
can_read: boolean;
|
||||
can_write: boolean;
|
||||
pending_invites: number;
|
||||
preferences: Record<string, any>;
|
||||
|
|
|
|||
33
frontend/src/lib/onboarding.ts
Normal file
33
frontend/src/lib/onboarding.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Onboarding wizard visibility, kept stable across the OAuth redirect round-trip.
|
||||
//
|
||||
// Granting a YouTube scope navigates away to Google's consent screen and back, which
|
||||
// fully reloads the SPA — so we can't hold the wizard's progress in React state. Instead
|
||||
// we derive the current step from the granted scopes (me.can_read / me.can_write) and use
|
||||
// two storage flags to decide whether to auto-open:
|
||||
//
|
||||
// ONBOARD_ACTIVE (sessionStorage) — set while the user is mid-flow (e.g. just clicked
|
||||
// "Connect"); makes the wizard reopen after the
|
||||
// redirect so they land on the next step.
|
||||
// ONBOARD_DISMISSED (localStorage) — set when the user skips the essential read step;
|
||||
// suppresses the auto-popup on future logins (they
|
||||
// can still reopen it from Settings → Account).
|
||||
|
||||
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
|
||||
export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed";
|
||||
|
||||
export function shouldAutoOpenOnboarding(me: { can_read: boolean }): boolean {
|
||||
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
||||
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
|
||||
}
|
||||
|
||||
/** Mark a grant as in progress, then send the user to Google's consent screen. */
|
||||
export function beginGrant(access: "read" | "write"): void {
|
||||
sessionStorage.setItem(ONBOARD_ACTIVE, "1");
|
||||
window.location.href = `/auth/upgrade?access=${access}`;
|
||||
}
|
||||
|
||||
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
|
||||
export function endOnboarding(dismiss: boolean): void {
|
||||
sessionStorage.removeItem(ONBOARD_ACTIVE);
|
||||
if (dismiss) localStorage.setItem(ONBOARD_DISMISSED, "1");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue