Exporting any calendar whose name is not pure ASCII failed. str.isalnum() is true for "ö", so the "safe" filename kept the umlaut and went into Content-Disposition raw — an HTTP header must be ASCII, and a reverse proxy drops the response. The client then had no JSON to read and showed its "unknown error" fallback, which said nothing at all. attachments_router had already learned this and documents it in a comment; the export endpoint never got the same treatment. Now RFC 6266, exactly as that router does it. downloadRequest discarded a non-JSON error body entirely. That body is the only clue when the failure happens outside the app, so it is used now, with the status appended when there is nothing to read. The CalDAV publish button rendered as "..." — four buttons never fitted the 240px actions column, and the cell clips instead of overflowing because the columns are drag-resizable. Nobody could tell there was a button there, which is why publishing a calendar to Home Assistant looked impossible: the URL, the copy button and both hints only appear once it is switched on. The label is now just "CalDAV" with the full action as its tooltip, which also helps anyone who already dragged that column narrower, since saved widths beat the default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
5.1 KiB
JavaScript
140 lines
5.1 KiB
JavaScript
import { t } from './i18n.js';
|
|
const BASE = '/api';
|
|
|
|
// Paths where a 401 is a normal answer rather than an expired session, so the
|
|
// auto-logout reload must not fire (it would loop on a failed SSO handoff).
|
|
const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange',
|
|
'/auth/oidc/link-pending'];
|
|
|
|
async function request(method, path, body = null, formEncoded = false) {
|
|
const token = localStorage.getItem('token');
|
|
const headers = {};
|
|
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
let bodyStr = null;
|
|
if (body !== null) {
|
|
if (formEncoded) {
|
|
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
bodyStr = new URLSearchParams(body).toString();
|
|
} else {
|
|
headers['Content-Type'] = 'application/json';
|
|
bodyStr = JSON.stringify(body);
|
|
}
|
|
}
|
|
|
|
const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr });
|
|
|
|
if (res.status === 401 && !NO_RELOAD_ON_401.some(p => path.startsWith(p))) {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.reload();
|
|
return null;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
|
const e = new Error(err.detail || `HTTP ${res.status}`);
|
|
// Keep the status so callers can map it to a translated message instead of
|
|
// showing the backend's German detail to someone running the app in Finnish.
|
|
e.status = res.status;
|
|
throw e;
|
|
}
|
|
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
async function uploadRequest(path, formData) {
|
|
const token = localStorage.getItem('token');
|
|
const headers = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(`${BASE}${path}`, { method: 'POST', headers, body: formData });
|
|
|
|
if (res.status === 401) {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.reload();
|
|
return null;
|
|
}
|
|
if (!res.ok) {
|
|
// Upload errors may be non-JSON (e.g. an nginx 413/502 HTML page); fall back
|
|
// to the HTTP status so the message is diagnostic, not "unknown error".
|
|
const err = await res.json().catch(() => null);
|
|
const detail = (err && err.detail)
|
|
? err.detail
|
|
: (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim());
|
|
const e = new Error(detail);
|
|
e.status = res.status;
|
|
throw e;
|
|
}
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
async function downloadRequest(path, fallbackName) {
|
|
const token = localStorage.getItem('token');
|
|
const headers = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(`${BASE}${path}`, { method: 'GET', headers });
|
|
if (res.status === 401) {
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('user');
|
|
window.location.reload();
|
|
return null;
|
|
}
|
|
if (!res.ok) {
|
|
// A non-JSON body means the failure happened outside the app — a reverse
|
|
// proxy rejecting the response, say. Showing "unknown error" there hides
|
|
// the one clue there is, so fall back to the body text and the status.
|
|
const raw = await res.text().catch(() => '');
|
|
let detail = '';
|
|
try { detail = JSON.parse(raw).detail || ''; } catch { detail = raw.trim().slice(0, 200); }
|
|
const e = new Error(detail || t('unknown_error') + ' (HTTP ' + res.status + ')');
|
|
// Keep the status so callers can map it to a translated message instead of
|
|
// showing the backend's German detail to someone running the app in Finnish.
|
|
e.status = res.status;
|
|
throw e;
|
|
}
|
|
// Derive filename from Content-Disposition if present.
|
|
let filename = fallbackName || 'calendar.ics';
|
|
const cd = res.headers.get('Content-Disposition') || '';
|
|
const m = cd.match(/filename="?([^"]+)"?/);
|
|
if (m) filename = m[1];
|
|
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export const api = {
|
|
get: (path) => request('GET', path),
|
|
post: (path, body) => request('POST', path, body),
|
|
put: (path, body) => request('PUT', path, body),
|
|
delete: (path) => request('DELETE', path),
|
|
upload: (path, form) => uploadRequest(path, form),
|
|
download: (path, name) => downloadRequest(path, name),
|
|
|
|
login: (username, password, totp_code = null, remember_me = false) =>
|
|
request('POST', '/auth/login', { username, password, totp_code, remember_me }),
|
|
|
|
setupRequired: () => request('GET', '/auth/setup-required'),
|
|
setup: (data) => request('POST', '/auth/setup', data),
|
|
|
|
// Single sign-on. oidcComplete() trades the one-time HttpOnly handoff cookie
|
|
// set by the OIDC callback for the access token.
|
|
oidcProviders: () => request('GET', '/auth/oidc/providers'),
|
|
oidcComplete: () => request('POST', '/auth/oidc/complete', {}),
|
|
oidcIdentities: () => request('GET', '/auth/oidc/identities'),
|
|
oidcLinkPending: () => request('POST', '/auth/oidc/link-pending', {}),
|
|
oidcUnlink: (id) => request('DELETE', `/auth/oidc/identities/${id}`),
|
|
};
|