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:
parent
9c61bd898d
commit
196eff6232
6 changed files with 43 additions and 10 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 ?? [])
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue