diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f31790..b5d5b59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(loadLayout); const [page, setPageState] = useState(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 && ( + setWizardOpen(false)} /> + )} ); diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx new file mode 100644 index 0000000..0a47b84 --- /dev/null +++ b/frontend/src/components/OnboardingWizard.tsx @@ -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 ( +
+ +

+ Google will show a "Google hasn't verified this app"{" "} + screen — that's expected, because Subfeed hasn't gone through Google's full review. + It's safe to continue: click Advanced →{" "} + Go to Subfeed. You can revoke access anytime from + your{" "} + + Google Account + + . +

+
+ ); +} + +function Dots({ index, total }: { index: number; total: number }) { + return ( +
+ {Array.from({ length: total }).map((_, i) => ( + + ))} +
+ ); +} + +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 ( +
+
+
+ + + {step === "read" && ( + <> +
+ +
+

Connect your YouTube subscriptions

+

+ You're signed in. To build your feed, Subfeed needs{" "} + read-only access to the channels you're + subscribed to on YouTube. It never posts, deletes, or changes anything with this. +

+ +
+ + +
+ + )} + + {step === "write" && ( + <> +
+ +
+

You're connected

+

+ Your feed is ready. Optionally, you can let Subfeed{" "} + unsubscribe 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. +

+ +
+ + +
+ + )} + + {step === "done" && ( + <> +
+ +
+

All set

+

+ Read and unsubscribe access are both granted. You can review or revoke either one + anytime in Settings → Account, or from your Google Account. +

+ + + )} + +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index cb0bdd3..1be2e52 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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("appearance"); const [closing, setClosing] = useState(false); @@ -115,7 +117,7 @@ export default function SettingsPanel({ )} {t.id === "notifications" && } {t.id === "sync" && } - {t.id === "account" && } + {t.id === "account" && }
))} @@ -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 ( +
+
+
{title}
+

+ {granted ? grantedHint : enableHint} +

+
+ {granted ? ( + + Granted + + ) : ( + + + + )} +
+ ); +} + +function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { return ( <>
@@ -480,34 +521,39 @@ function Account({ me }: { me: Me }) {
{me.role}
-
-
-
-
Playlist editing & YouTube export
-

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

-
- {me.can_write ? ( - - Enabled - - ) : ( - - - - )} -
+
+ +
+

+ Sign-in only shares your name and email. YouTube access is granted separately, below — + each is optional and revocable anytime in your Google Account. +

+
+ { + window.location.href = "/auth/upgrade?access=read"; + }} + /> + { + window.location.href = "/auth/upgrade?access=write"; + }} + />
+
{me.role === "admin" && } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fee444c..a0ef056 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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; diff --git a/frontend/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts new file mode 100644 index 0000000..b706274 --- /dev/null +++ b/frontend/src/lib/onboarding.ts @@ -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"); +}