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.
This commit is contained in:
npeter83 2026-06-15 02:02:05 +02:00
parent 9c61bd898d
commit 196eff6232
6 changed files with 43 additions and 10 deletions

View file

@ -203,6 +203,7 @@ export default function App() {
canWrite={meQuery.data!.can_write} canWrite={meQuery.data!.can_write}
statusFilter={channelFilter} statusFilter={channelFilter}
setStatusFilter={setChannelFilter} setStatusFilter={setChannelFilter}
onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => { onViewChannel={(id, name) => {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed"); setPage("feed");

View file

@ -13,7 +13,7 @@ import {
UserMinus, UserMinus,
X, X,
} from "lucide-react"; } 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 { formatEta } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -33,14 +33,30 @@ export default function Channels({
onViewChannel, onViewChannel,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
onOpenWizard,
}: { }: {
canWrite: boolean; canWrite: boolean;
onViewChannel: (id: string, name: string) => void; onViewChannel: (id: string, name: string) => void;
statusFilter: ChannelStatusFilter; statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void; setStatusFilter: (f: ChannelStatusFilter) => void;
onOpenWizard: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); 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 channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
@ -96,7 +112,7 @@ export default function Channels({
invalidate(); invalidate();
notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) }); 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({ const createTag = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }), mutationFn: (name: string) => api.createTag({ name, category: "other" }),
@ -116,7 +132,7 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.unsubscribed") }); 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({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
@ -128,7 +144,7 @@ export default function Channels({
message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), 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 ?? []) const channels = (channelsQuery.data ?? [])

View file

@ -63,7 +63,9 @@
"unsubscribed": "Bei YouTube abbestellt", "unsubscribed": "Bei YouTube abbestellt",
"unsubscribeFailed": "Abbestellen fehlgeschlagen", "unsubscribeFailed": "Abbestellen fehlgeschlagen",
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", "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." "confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus."
} }

View file

@ -63,7 +63,9 @@
"unsubscribed": "Unsubscribed on YouTube", "unsubscribed": "Unsubscribed on YouTube",
"unsubscribeFailed": "Unsubscribe failed", "unsubscribeFailed": "Unsubscribe failed",
"fullHistoryRequested": "Full history requested for {{count}} channels", "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." "confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead."
} }

View file

@ -63,7 +63,9 @@
"unsubscribed": "Leiratkozva a YouTube-on", "unsubscribed": "Leiratkozva a YouTube-on",
"unsubscribeFailed": "A leiratkozás sikertelen", "unsubscribeFailed": "A leiratkozás sikertelen",
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", "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." "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."
} }

View file

@ -95,9 +95,11 @@ export interface VersionInfo {
class HttpError extends Error { class HttpError extends Error {
status: number; status: number;
constructor(status: number) { detail?: string; // server-provided reason (FastAPI's `detail`), when present
super(`HTTP ${status}`); constructor(status: number, detail?: string) {
super(detail || `HTTP ${status}`);
this.status = status; this.status = status;
this.detail = detail;
} }
} }
@ -128,11 +130,19 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
throw e; throw e;
} }
if (!r.ok) { 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. // Server faults are worth surfacing; 401/403/404 etc. are handled by callers.
if (r.status >= 500) { if (r.status >= 500) {
notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`); 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(); return r.status === 204 ? null : r.json();
} }