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 ea317c0009
commit cdc15ad1f9
6 changed files with 43 additions and 10 deletions

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