Two related fixes after the user saw a raw 400 JSON dump in the sheet
("API 400: { error: true, message: 'Eintrag bereits vorhanden' … }").
1. apiFetch now extracts the prettiest available message from the
response body (data.message → message → statusMessage → raw text →
bare status code) and throws an Error whose .message is that string
only. Stashes the structured pieces on the Error too (.code, .data,
.status) so callers that switch on error codes still have them, but
the default `e?.message` path delivers a clean human sentence.
2. AddDomainSheet maps the known error codes to localized strings —
WEB_LIMIT_REACHED / MAIL_LIMIT_REACHED / INVALID_MAIL_DOMAIN /
DISPLAY_NAME_NOT_SUPPORTED / INVALID_DOMAIN / "Eintrag bereits
vorhanden" (duplicate) — and falls back to a generic copy if the
code is unknown. The raw API JSON never appears in the UI again.
Plus the kind-override checkbox: the auto-detect (input contains "@" →
mail, contains "." → web) is fine for the typical case but a user can
type a clean domain and still want it filtered against mail senders
(e.g. they know "casino.de" is also their casino's sender domain).
The new pill below the preview toggles between mail and web, defaults
to whatever auto-detect said, and resets when the input is cleared. The
local-part strip still runs for mail-mode so the stored value stays a
domain.
i18n: error_invalid_mail / error_invalid_input / error_duplicate /
kind_override_label across DE/EN/FR.
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
import Constants from 'expo-constants';
|
|
import { supabase } from './supabase';
|
|
import { getDeviceInfo } from './deviceId';
|
|
import { useDeviceLimitStore } from '../stores/deviceLimit';
|
|
|
|
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
|
|
|
|
type FetchOptions = Omit<RequestInit, 'body'> & {
|
|
body?: any;
|
|
/** Set true on bootstrap calls (device register) to skip x-device-id injection */
|
|
skipDeviceHeader?: boolean;
|
|
};
|
|
|
|
let cachedDeviceHeaders: Record<string, string> | null = null;
|
|
|
|
async function getDeviceHeaders(): Promise<Record<string, string>> {
|
|
if (cachedDeviceHeaders) return cachedDeviceHeaders;
|
|
const info = await getDeviceInfo().catch(() => null);
|
|
if (!info) return {};
|
|
cachedDeviceHeaders = {
|
|
'x-device-id': info.deviceId,
|
|
'x-platform': info.platform,
|
|
'x-device-name': encodeURIComponent(info.name),
|
|
'x-device-model': encodeURIComponent(info.model),
|
|
'x-device-os': encodeURIComponent(info.osVersion),
|
|
};
|
|
return cachedDeviceHeaders;
|
|
}
|
|
|
|
/**
|
|
* Wrapper für Backend-API-Calls mit automatischem Auth-Token.
|
|
* Pendant zum Nuxt-`useSafeFetch` aus apps/rebreak/.
|
|
*
|
|
* Backend antwortet mit { success, data, status } — wir entpacken `data`.
|
|
*/
|
|
export async function apiFetch<T = any>(
|
|
path: string,
|
|
options: FetchOptions = {}
|
|
): Promise<T> {
|
|
const session = (await supabase.auth.getSession()).data.session;
|
|
|
|
const { skipDeviceHeader, ...fetchOptions } = options;
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...(fetchOptions.headers as Record<string, string>),
|
|
};
|
|
|
|
if (session?.access_token) {
|
|
headers.Authorization = `Bearer ${session.access_token}`;
|
|
}
|
|
|
|
if (!skipDeviceHeader) {
|
|
const deviceHeaders = await getDeviceHeaders();
|
|
Object.assign(headers, deviceHeaders);
|
|
}
|
|
|
|
const res = await fetch(`${apiUrl}${path}`, {
|
|
...fetchOptions,
|
|
headers,
|
|
body: fetchOptions.body ? JSON.stringify(fetchOptions.body) : undefined,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
|
|
if (res.status === 403) {
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (
|
|
parsed?.statusMessage === 'device_limit_reached' ||
|
|
parsed?.data?.error === 'device_limit_reached'
|
|
) {
|
|
const { devices, max, plan } = parsed.data;
|
|
useDeviceLimitStore.getState().show(devices ?? [], max ?? 0, plan ?? 'free');
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Throw a human-readable Error.message. Backend `createError({ data: { error, message } })`
|
|
// serialises into { error: true, statusCode, statusMessage, message?, data: {…} }.
|
|
// Caller code only ever displays `e.message`, so collapse the prettiest field into
|
|
// it; stash the rest on the Error so callers that want to switch on error_code
|
|
// (e.g. WEB_LIMIT_REACHED, ALREADY_GLOBAL) can still inspect `(e as any).code`.
|
|
let humanMessage = `API ${res.status}`;
|
|
let errorCode: string | undefined;
|
|
let errorData: any;
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
errorData = parsed?.data ?? parsed;
|
|
errorCode = errorData?.error ?? parsed?.statusMessage;
|
|
humanMessage =
|
|
errorData?.message ??
|
|
parsed?.message ??
|
|
parsed?.statusMessage ??
|
|
humanMessage;
|
|
} catch {
|
|
if (text) humanMessage = text;
|
|
}
|
|
const err = new Error(humanMessage);
|
|
(err as any).status = res.status;
|
|
(err as any).code = errorCode;
|
|
(err as any).data = errorData;
|
|
throw err;
|
|
}
|
|
|
|
const json = await res.json();
|
|
|
|
// Unwrap { success, data, status } — siehe useSafeFetch-Pattern in der Vue-App
|
|
if (json && typeof json === 'object' && 'success' in json && 'data' in json) {
|
|
return json.data as T;
|
|
}
|
|
return json as T;
|
|
}
|