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