diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9c8e8cf..802a711 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string { return fallback; } +/** Structured 409 blocked-by payload attached to ApiError.blockedBy. */ +export interface BlockedByDetail { + message: string; + entity: string; + blocked_by: string[]; +} + +export class ApiError extends Error { + status: number; + blockedBy?: BlockedByDetail; + constructor(message: string, status: number, blockedBy?: BlockedByDetail) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.blockedBy = blockedBy; + } +} + +/** Parse a server-issued datetime string as UTC (appends Z if no timezone info present). */ +export function parseDate(dateStr: string): Date { + if (!dateStr) return new Date(NaN); + if (!/Z$|[+-]\d{2}:?\d{2}$/.test(dateStr)) return new Date(dateStr + 'Z'); + return new Date(dateStr); +} + +/** If the thrown error was a structured 409 from delete_protection, return its payload. */ +export function getBlockedBy(err: unknown): BlockedByDetail | null { + if (err instanceof ApiError && err.blockedBy) return err.blockedBy; + return null; +} + function getToken(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem('access_token'); @@ -106,7 +137,17 @@ export async function api( if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error(err.detail || `HTTP ${res.status}`); + // Structured blocked-by detail (from delete_protection.raise_if_used) + if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) { + const bb: BlockedByDetail = { + message: err.detail.message || `HTTP ${res.status}`, + entity: err.detail.entity || '', + blocked_by: err.detail.blocked_by, + }; + throw new ApiError(bb.message, res.status, bb); + } + const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`); + throw new ApiError(msg, res.status); } return res.json(); @@ -114,3 +155,57 @@ export async function api( clearTimeout(timeout); } } + +/** + * Auth-aware ``fetch`` wrapper for calls that can't go through ``api()`` — + * typically multipart/form-data uploads or binary downloads where we need the + * raw ``Response`` object rather than parsed JSON. + * + * - Injects the Bearer token automatically. + * - Does NOT set ``Content-Type`` (the caller's body — e.g. ``FormData`` — + * decides the encoding; browsers add the boundary). + * - Attempts a one-shot token refresh on 401, matching ``api()``. + * - Translates non-OK responses to ``ApiError`` so callers can use the same + * ``getBlockedBy`` / ``err.message`` handling pattern. + */ +export async function fetchAuth( + path: string, + options: RequestInit = {}, +): Promise { + const token = getToken(); + const headers: Record = { ...(options.headers as Record) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const url = path.startsWith('http') ? path : `${API_BASE}${path}`; + let res = await fetch(url, { ...options, headers }); + + if (res.status === 401 && token) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + headers['Authorization'] = `Bearer ${getToken()}`; + res = await fetch(url, { ...options, headers }); + } + } + + if (res.status === 401) { + clearTokens(); + if (typeof window !== 'undefined') window.location.href = '/login'; + throw new ApiError('Unauthorized', 401); + } + + if (!res.ok) { + const err = await res.clone().json().catch(() => ({ detail: res.statusText })); + if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) { + const bb: BlockedByDetail = { + message: err.detail.message || `HTTP ${res.status}`, + entity: err.detail.entity || '', + blocked_by: err.detail.blocked_by, + }; + throw new ApiError(bb.message, res.status, bb); + } + const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`); + throw new ApiError(msg, res.status); + } + + return res; +} diff --git a/frontend/src/lib/components/BlockedByModal.svelte b/frontend/src/lib/components/BlockedByModal.svelte new file mode 100644 index 0000000..3bb4f49 --- /dev/null +++ b/frontend/src/lib/components/BlockedByModal.svelte @@ -0,0 +1,74 @@ + + + + {#if detail} +
+
+ +
+
+

{detail.message}

+ {#if detail.entity} +

{detail.entity}

+ {/if} +
+
+
+

{t('common.blockedByIntro')}

+ {#if blockedCount > 0} + + {blockedCount} + + {/if} +
+ {#if blockedCount > 0} +
    + {#each detail.blocked_by as consumer} +
  • + + + + {consumer} +
  • + {/each} +
+ {/if} + {/if} +
+ +
+
+ + diff --git a/frontend/src/lib/components/EventChart.svelte b/frontend/src/lib/components/EventChart.svelte index 841b4c6..9ce9e9f 100644 --- a/frontend/src/lib/components/EventChart.svelte +++ b/frontend/src/lib/components/EventChart.svelte @@ -1,5 +1,6 @@ @@ -252,13 +274,23 @@ -

- - {t('dashboard.recentEvents')} +
+

+ + {t('dashboard.recentEvents')} + {#if status.total_events > 0} + ({status.total_events}) + {/if} +

{#if status.total_events > 0} - ({status.total_events}) + {/if} -

+
@@ -370,6 +402,9 @@ {/if} {/if} + confirmClearEvents = false} /> + diff --git a/frontend/src/routes/actions/ExecutionHistory.svelte b/frontend/src/routes/actions/ExecutionHistory.svelte index 06e3e91..fa144e2 100644 --- a/frontend/src/routes/actions/ExecutionHistory.svelte +++ b/frontend/src/routes/actions/ExecutionHistory.svelte @@ -1,5 +1,5 @@ @@ -280,6 +286,8 @@ confirmDelete = null} /> + blockedBy = null} /> + diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index e9ed685..43f8f7c 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -1,7 +1,8 @@