From cdc15ad1f9118d35f95569b2ad7c1cd573a4079c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 02:02:05 +0200 Subject: [PATCH] feat(channels): clearer error when a YouTube action lacks the scope api: HttpError now carries the server's detail. Channels sync/backfill/unsubscribe now detect a 403 (no YouTube grant) and show a 'connect your YouTube account' message with a Connect action that opens the onboarding wizard, instead of a vague 'failed' toast. Translated HU/EN/DE. --- frontend/src/App.tsx | 1 + frontend/src/components/Channels.tsx | 24 ++++++++++++++++++---- frontend/src/i18n/locales/de/channels.json | 4 +++- frontend/src/i18n/locales/en/channels.json | 4 +++- frontend/src/i18n/locales/hu/channels.json | 4 +++- frontend/src/lib/api.ts | 16 ++++++++++++--- 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d49a0c..4e890b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -203,6 +203,7 @@ export default function App() { canWrite={meQuery.data!.can_write} statusFilter={channelFilter} setStatusFilter={setChannelFilter} + onOpenWizard={() => setWizardOpen(true)} onViewChannel={(id, name) => { setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setPage("feed"); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 8cd4d94..32f4785 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -13,7 +13,7 @@ import { UserMinus, X, } from "lucide-react"; -import { api, type ManagedChannel, type Tag } from "../lib/api"; +import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { formatEta } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; @@ -33,14 +33,30 @@ export default function Channels({ onViewChannel, statusFilter, setStatusFilter, + onOpenWizard, }: { canWrite: boolean; onViewChannel: (id: string, name: string) => void; statusFilter: ChannelStatusFilter; setStatusFilter: (f: ChannelStatusFilter) => void; + onOpenWizard: () => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); + + // A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't + // granted the needed scope — surface that with a "Connect" action instead of a vague fail. + const notifyActionError = (err: unknown, fallbackKey: string) => { + if (err instanceof HttpError && err.status === 403) { + notify({ + level: "error", + message: t("channels.notify.needYouTube"), + action: { label: t("channels.notify.connect"), onClick: onOpenWizard }, + }); + } else { + notify({ level: "error", message: t(fallbackKey) }); + } + }; const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); @@ -96,7 +112,7 @@ export default function Channels({ invalidate(); notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) }); }, - onError: () => notify({ level: "error", message: t("channels.notify.syncFailed") }), + onError: (e) => notifyActionError(e, "channels.notify.syncFailed"), }); const createTag = useMutation({ mutationFn: (name: string) => api.createTag({ name, category: "other" }), @@ -116,7 +132,7 @@ export default function Channels({ qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.unsubscribed") }); }, - onError: () => notify({ level: "error", message: t("channels.notify.unsubscribeFailed") }), + onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), @@ -128,7 +144,7 @@ export default function Channels({ message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, - onError: () => notify({ level: "error", message: t("channels.notify.fullHistoryFailed") }), + onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), }); const channels = (channelsQuery.data ?? []) diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json index 31d3e58..e317808 100644 --- a/frontend/src/i18n/locales/de/channels.json +++ b/frontend/src/i18n/locales/de/channels.json @@ -63,7 +63,9 @@ "unsubscribed": "Bei YouTube abbestellt", "unsubscribeFailed": "Abbestellen fehlgeschlagen", "fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", - "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden" + "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", + "needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.", + "connect": "Verbinden" }, "confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus." } diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index c9dadfc..3fbbb72 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -63,7 +63,9 @@ "unsubscribed": "Unsubscribed on YouTube", "unsubscribeFailed": "Unsubscribe failed", "fullHistoryRequested": "Full history requested for {{count}} channels", - "fullHistoryFailed": "Couldn't request full history" + "fullHistoryFailed": "Couldn't request full history", + "needYouTube": "Connect your YouTube account to do this.", + "connect": "Connect" }, "confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead." } diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 010ce7c..6767ed4 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -63,7 +63,9 @@ "unsubscribed": "Leiratkozva a YouTube-on", "unsubscribeFailed": "A leiratkozás sikertelen", "fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", - "fullHistoryFailed": "Nem sikerült teljes előzményt kérni" + "fullHistoryFailed": "Nem sikerült teljes előzményt kérni", + "needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.", + "connect": "Csatlakoztatás" }, "confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el." } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e5a088c..2bf9319 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -95,9 +95,11 @@ export interface VersionInfo { class HttpError extends Error { status: number; - constructor(status: number) { - super(`HTTP ${status}`); + detail?: string; // server-provided reason (FastAPI's `detail`), when present + constructor(status: number, detail?: string) { + super(detail || `HTTP ${status}`); this.status = status; + this.detail = detail; } } @@ -128,11 +130,19 @@ async function req(url: string, opts: RequestInit = {}): Promise { throw e; } if (!r.ok) { + // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. + let detail: string | undefined; + try { + const body = await r.json(); + if (body && typeof body.detail === "string") detail = body.detail; + } catch { + /* no JSON body */ + } // Server faults are worth surfacing; 401/403/404 etc. are handled by callers. if (r.status >= 500) { notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`); } - throw new HttpError(r.status); + throw new HttpError(r.status, detail); } return r.status === 204 ? null : r.json(); }