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:
npeter83 2026-06-14 01:11:29 +02:00
parent 580d7f0e0d
commit 5215765b51
5 changed files with 302 additions and 29 deletions

View file

@ -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 &amp; 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 />}
</>