diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index 56f38c7..8a8ec37 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -1,6 +1,7 @@ { "title": "Couldn't complete that", "generic": "The server couldn't carry out that action. Please try again.", + "validation": "Invalid value for “{{field}}”. Please check it and try again.", "server": "Server error", "ok": "OK", "offline": { diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 43f5311..4bd1342 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -1,6 +1,7 @@ { "title": "Nem sikerült", "generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.", + "validation": "Érvénytelen érték: „{{field}}”. Ellenőrizd, majd próbáld újra.", "server": "Szerverhiba", "ok": "OK", "offline": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0240262..509285f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -212,8 +212,8 @@ export interface VersionInfo { class HttpError extends Error { status: number; detail?: string; // server-provided reason (FastAPI's `detail`), when present - detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object - constructor(status: number, detail?: string, detailData?: any) { + detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object + constructor(status: number, detail?: string, detailData?: unknown) { super(detail || `HTTP ${status}`); this.status = status; this.detail = detail; @@ -225,20 +225,31 @@ class HttpError extends Error { // usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a // structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English // with a dot-decimal separator. Falls back to a generic message for unknown shapes. -function localizeDetail(d: any): string { +function localizeDetail(d: unknown): string { const t = i18n.t.bind(i18n); - if (d?.code === "quota") { - if (d.reason === "max_bytes") { + // FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends + // well-formed requests, so this only fires on genuinely malformed input — surface the offending + // field name (localized) rather than FastAPI's raw English `msg`, which reads wrong in a HU UI. + if (Array.isArray(d)) { + const loc = (d[0] as { loc?: unknown } | undefined)?.loc; + const field = Array.isArray(loc) + ? loc.filter((s): s is string => typeof s === "string" && s !== "body").pop() + : undefined; + return field ? t("errors.validation", { field }) : t("errors.generic"); + } + const o = d as { code?: string; reason?: string; limit?: number } | null | undefined; + if (o?.code === "quota") { + if (o.reason === "max_bytes") { const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format( - (d.limit || 0) / 1_073_741_824, + (o.limit || 0) / 1_073_741_824, ); return t("downloads.errors.quota.max_bytes", { size: gb }); } - if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit }); + if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit }); return t("downloads.errors.quota.generic"); } - if (d?.code === "edit") - return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic")); + if (o?.code === "edit") + return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic")); return t("errors.generic"); } @@ -341,7 +352,11 @@ export function clearActiveAccount(): void { } } -async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise { +async function req( + url: string, + opts: RequestInit = {}, + cfg: ReqConfig = {}, +): Promise { const method = opts.method ?? "GET"; const canRetry = cfg.idempotent ?? method === "GET"; let attempt = 0; @@ -386,7 +401,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr if (!r.ok) { // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. let detail: string | undefined; - let detailData: any; + let detailData: unknown; try { const body = await r.json(); if (body && typeof body.detail === "string") detail = body.detail; @@ -417,7 +432,9 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr } throw new HttpError(r.status, detail, detailData); } - return r.status === 204 ? null : r.json(); + // The single audited cast in this layer: the server contract is asserted here, once, so + // every typed api.* method (T comes from its declared return type) is checked from here up. + return (r.status === 204 ? null : await r.json()) as T; } } @@ -1098,7 +1115,8 @@ export const api = { // (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never // logs a failed request to the console). React Query treats the null as data, not an error. me: async (): Promise => { - const r = await req("/api/me"); + // Always 200: `{ authenticated: false }` when logged out, else the full Me + `authenticated`. + const r = await req<{ authenticated: boolean } & Partial>("/api/me"); return r && r.authenticated ? (r as Me) : null; }, accounts: (): Promise => req("/api/me/accounts"), @@ -1176,7 +1194,8 @@ export const api = { ) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), resetChannelBackfill: (id: string) => req(`/api/channels/${id}/reset-backfill`, { method: "POST" }), - deepAll: (on = true) => req(`/api/sync/deep-all?on=${on}`, { method: "POST" }), + deepAll: (on = true): Promise<{ updated: number; deep_requested: boolean }> => + req(`/api/sync/deep-all?on=${on}`, { method: "POST" }), attachChannelTag: (id: string, tagId: number) => req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }), detachChannelTag: (id: string, tagId: number) => @@ -1184,7 +1203,15 @@ export const api = { unsubscribeChannel: (id: string) => req(`/api/channels/${id}/subscription`, { method: "DELETE" }), discoveredChannels: (): Promise => req("/api/channels/discovery"), subscribeChannel: (id: string) => req(`/api/channels/${id}/subscribe`, { method: "POST" }), - syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }), + syncSubscriptions: (): Promise<{ + subscriptions: number; + channels_new: number; + channels_detailed: number; + removed_stale: number; + prune_skipped: boolean; + quota_used_estimate: number; + quota_remaining_today: number; + }> => req("/api/sync/subscriptions", { method: "POST" }), // --- channel page (About detail + ephemeral exploration of un-subscribed channels) --- channelDetail: (id: string): Promise => req(`/api/channels/${id}`), // Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,