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}
statusFilter={channelFilter}
setStatusFilter={setChannelFilter}
onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed");

View file

@ -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 ?? [])

View file

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

View file

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

View file

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

View file

@ -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<any> {
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();
}