diff --git a/public/locales/ar.json b/public/locales/ar.json index c436961..b2144a0 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -624,6 +624,28 @@ "currencyLabel": "العملة", "currencyHint": "تحدد العملة المستخدمة في منطقة الميزانية بأكملها.", "currencySaved": "تم حفظ العملة.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "اشتراكات ICS", "add": "إضافة اشتراك", diff --git a/public/locales/de.json b/public/locales/de.json index b6b6501..82ba5a1 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -630,6 +630,28 @@ "currencyLabel": "Währung", "currencyHint": "Legt die Währung für den gesamten Budget-Bereich fest.", "currencySaved": "Währung gespeichert.", + "apiTokensTitle": "API-Tokens", + "apiTokensCardTitle": "Zugriffstoken", + "apiTokensHint": "Erstelle API-Tokens für externe Integrationen. Der vollständige Token wird nach der Erstellung nur einmal angezeigt.", + "apiTokenNameLabel": "Tokenname", + "apiTokenExpiresLabel": "Ablaufdatum", + "apiTokenExpiresHint": "Leer lassen, um einen Token ohne Ablaufdatum zu erstellen.", + "apiTokenCreatedLabel": "Neuer API-Token", + "apiTokenCreatedHint": "Speichere diesen Token sicher. Er kann nicht erneut angezeigt werden.", + "apiTokenCreate": "Token erstellen", + "apiTokenInvalidExpiration": "Bitte gib ein gültiges Ablaufdatum ein.", + "apiTokenCreatedToast": "API-Token erstellt.", + "apiTokenRevokedToast": "API-Token widerrufen.", + "apiTokenRevokeConfirm": "API-Token \"{{name}}\" widerrufen?", + "apiTokenRevoke": "Token widerrufen", + "apiTokenRevoked": "Widerrufen", + "apiTokenExpired": "Abgelaufen", + "apiTokenActive": "Aktiv", + "apiTokenPrefix": "Präfix", + "apiTokenExpires": "Läuft ab", + "apiTokenNeverExpires": "Kein Ablaufdatum", + "apiTokenLastUsed": "Zuletzt verwendet", + "apiTokenNeverUsed": "Nie verwendet", "ics": { "title": "ICS-Abonnements", "add": "Abonnement hinzufügen", diff --git a/public/locales/el.json b/public/locales/el.json index 309c515..2ed0b72 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -624,6 +624,28 @@ "currencyLabel": "Νόμισμα", "currencyHint": "Ορίζει το νόμισμα για ολόκληρη την ενότητα προϋπολογισμού.", "currencySaved": "Το νόμισμα αποθηκεύτηκε.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "Συνδρομές ICS", "add": "Προσθήκη συνδρομής", diff --git a/public/locales/en.json b/public/locales/en.json index b72af7f..76505d6 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -624,6 +624,28 @@ "currencyLabel": "Currency", "currencyHint": "Sets the currency used throughout the budget section.", "currencySaved": "Currency saved.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "ICS Subscriptions", "add": "Add subscription", diff --git a/public/locales/es.json b/public/locales/es.json index 3a1e8c9..e774628 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -624,6 +624,28 @@ "currencyLabel": "Moneda", "currencyHint": "Establece la moneda para toda la sección de presupuesto.", "currencySaved": "Moneda guardada.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "Suscripciones ICS", "add": "Añadir suscripción", diff --git a/public/locales/fr.json b/public/locales/fr.json index af83829..d30fb93 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -624,6 +624,28 @@ "currencyLabel": "Devise", "currencyHint": "Définit la devise utilisée dans toute la section budget.", "currencySaved": "Devise enregistrée.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "Abonnements ICS", "add": "Ajouter un abonnement", diff --git a/public/locales/hi.json b/public/locales/hi.json index fdb4036..b42c0e8 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -624,6 +624,28 @@ "currencyLabel": "मुद्रा", "currencyHint": "पूरे बजट अनुभाग में उपयोग की जाने वाली मुद्रा सेट करता है।", "currencySaved": "मुद्रा सहेजी गई।", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "ICS सदस्यताएं", "add": "सदस्यता जोड़ें", diff --git a/public/locales/it.json b/public/locales/it.json index 8824541..c6c13db 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -624,6 +624,28 @@ "currencyLabel": "Valuta", "currencyHint": "Imposta la valuta utilizzata in tutta la sezione budget.", "currencySaved": "Valuta salvata.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "Abbonamenti ICS", "add": "Aggiungi abbonamento", diff --git a/public/locales/ja.json b/public/locales/ja.json index 6cf9582..31d97f9 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -624,6 +624,28 @@ "currencyLabel": "通貨", "currencyHint": "家計全体で使用する通貨を設定します。", "currencySaved": "通貨を保存しました。", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "ICSサブスクリプション", "add": "サブスクリプションを追加", diff --git a/public/locales/pt.json b/public/locales/pt.json index 5c86ccf..ff5808c 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -624,6 +624,28 @@ "currencyLabel": "Moeda", "currencyHint": "Define a moeda usada em toda a área de orçamento.", "currencySaved": "Moeda salva.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "Assinaturas ICS", "add": "Adicionar assinatura", diff --git a/public/locales/ru.json b/public/locales/ru.json index 01762ca..9e8b56e 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -624,6 +624,28 @@ "currencyLabel": "Валюта", "currencyHint": "Устанавливает валюту для всего раздела бюджета.", "currencySaved": "Валюта сохранена.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "ICS-подписки", "add": "Добавить подписку", diff --git a/public/locales/sv.json b/public/locales/sv.json index 0ddfd0b..fdde958 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -624,6 +624,28 @@ "currencyLabel": "Valuta", "currencyHint": "Ställer in valutan som används i hela budgetavsnittet.", "currencySaved": "Valuta sparad.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "ICS-prenumerationer", "add": "Lägg till prenumeration", diff --git a/public/locales/tr.json b/public/locales/tr.json index 4f98a3f..63da054 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -624,6 +624,28 @@ "currencyLabel": "Para birimi", "currencyHint": "Bütçe bölümünde kullanılan para birimini belirler.", "currencySaved": "Para birimi kaydedildi.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "ICS Abonelikleri", "add": "Abonelik ekle", diff --git a/public/locales/uk.json b/public/locales/uk.json index 31c9cb9..edc73b9 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -624,6 +624,28 @@ "currencyLabel": "Валюта", "currencyHint": "Встановлює валюту, що використовується в розділі бюджету.", "currencySaved": "Валюту збережено.", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "ICS-підписки", "add": "Додати підписку", diff --git a/public/locales/zh.json b/public/locales/zh.json index 20d2d7f..cf18b5b 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -624,6 +624,28 @@ "currencyLabel": "货币", "currencyHint": "设置整个预算区域使用的货币。", "currencySaved": "货币已保存。", + "apiTokensTitle": "API Tokens", + "apiTokensCardTitle": "Access Tokens", + "apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.", + "apiTokenNameLabel": "Token name", + "apiTokenExpiresLabel": "Expiration date", + "apiTokenExpiresHint": "Leave empty to create a token without expiration.", + "apiTokenCreatedLabel": "New API token", + "apiTokenCreatedHint": "Store this token securely. It cannot be shown again.", + "apiTokenCreate": "Create token", + "apiTokenInvalidExpiration": "Please enter a valid expiration date.", + "apiTokenCreatedToast": "API token created.", + "apiTokenRevokedToast": "API token revoked.", + "apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?", + "apiTokenRevoke": "Revoke token", + "apiTokenRevoked": "Revoked", + "apiTokenExpired": "Expired", + "apiTokenActive": "Active", + "apiTokenPrefix": "Prefix", + "apiTokenExpires": "Expires", + "apiTokenNeverExpires": "No expiration", + "apiTokenLastUsed": "Last used", + "apiTokenNeverUsed": "Never used", "ics": { "title": "ICS 订阅", "add": "添加订阅", diff --git a/public/pages/settings.js b/public/pages/settings.js index 96dd357..8a3d45d 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -59,15 +59,17 @@ export async function render(container, { user }) { let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' }; let categories = []; let icsSubscriptions = []; + let apiTokens = []; try { - const [usersRes, gStatus, aStatus, prefsRes, catsRes, icsRes] = await Promise.allSettled([ + const [usersRes, gStatus, aStatus, prefsRes, catsRes, icsRes, apiTokensRes] = await Promise.allSettled([ user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }), api.get('/calendar/google/status'), api.get('/calendar/apple/status'), api.get('/preferences'), api.get('/shopping/categories'), api.get('/calendar/subscriptions'), + user.role === 'admin' ? api.get('/auth/api-tokens') : Promise.resolve({ data: [] }), ]); if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? []; if (gStatus.status === 'fulfilled') googleStatus = gStatus.value; @@ -75,6 +77,7 @@ export async function render(container, { user }) { if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs; if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? []; if (icsRes.status === 'fulfilled') icsSubscriptions = icsRes.value.data ?? []; + if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? []; } catch (_) { /* non-critical */ } const googleStatusText = googleStatus.connected @@ -364,6 +367,35 @@ export async function render(container, { user }) { ${user?.role === 'admin' ? ` +
+

${t('settings.apiTokensTitle')}

+
+

${t('settings.apiTokensCardTitle')}

+

${t('settings.apiTokensHint')}

+ +
+
+ + +
+
+ + +

${t('settings.apiTokenExpiresHint')}

+
+ + + +
+
+
+

${t('settings.sectionFamily')}

@@ -424,17 +456,18 @@ export async function render(container, { user }) { }); } - bindEvents(container, user, categories, icsSubscriptions); + bindEvents(container, user, categories, icsSubscriptions, apiTokens); } // -------------------------------------------------------- // Event-Binding // -------------------------------------------------------- -function bindEvents(container, user, categories, icsSubscriptions) { +function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { bindTabEvents(container); bindCategoryEvents(container); bindIcsEvents(container, user, icsSubscriptions); + bindApiTokenEvents(container, apiTokens); // Theme-Toggle const themeToggle = container.querySelector('#theme-toggle'); if (themeToggle) { @@ -722,6 +755,109 @@ function bindDeleteButtons(container, user) { }); } +function apiTokenHtml(token) { + const status = token.revoked_at + ? t('settings.apiTokenRevoked') + : token.expires_at && new Date(token.expires_at).getTime() <= Date.now() + ? t('settings.apiTokenExpired') + : t('settings.apiTokenActive'); + const meta = [ + `${t('settings.apiTokenPrefix')}: ${token.token_prefix}...`, + token.expires_at ? `${t('settings.apiTokenExpires')}: ${formatDateTime(token.expires_at)}` : t('settings.apiTokenNeverExpires'), + token.last_used_at ? `${t('settings.apiTokenLastUsed')}: ${formatDateTime(token.last_used_at)}` : t('settings.apiTokenNeverUsed'), + status, + ].join(' · '); + + return ` +
  • +
    + ${esc(token.name)} + ${esc(meta)} +
    + +
  • + `; +} + +function renderApiTokenList(container, tokens) { + const list = container.querySelector('#api-token-list'); + if (!list) return; + list.replaceChildren(); + tokens.forEach((token) => { + const tmp = document.createElement('template'); + tmp.innerHTML = apiTokenHtml(token); + list.appendChild(tmp.content.firstElementChild); + }); + if (window.lucide) window.lucide.createIcons(); +} + +function datetimeLocalToIso(value) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function bindApiTokenEvents(container, initialTokens) { + const form = container.querySelector('#api-token-form'); + const list = container.querySelector('#api-token-list'); + if (!form || !list) return; + + let tokens = [...initialTokens]; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = container.querySelector('#api-token-error'); + const output = container.querySelector('#api-token-created'); + const outputValue = container.querySelector('#api-token-created-value'); + errorEl.hidden = true; + output.hidden = true; + + const name = container.querySelector('#api-token-name').value.trim(); + const expiresValue = container.querySelector('#api-token-expires').value; + const expires_at = datetimeLocalToIso(expiresValue); + if (expiresValue && !expires_at) { + showError(errorEl, t('settings.apiTokenInvalidExpiration')); + return; + } + + const btn = form.querySelector('[type=submit]'); + btn.disabled = true; + try { + const res = await api.post('/auth/api-tokens', { name, expires_at }); + tokens.unshift(res.data); + renderApiTokenList(container, tokens); + form.reset(); + outputValue.value = res.token; + output.hidden = false; + outputValue.focus(); + outputValue.select(); + window.oikos?.showToast(t('settings.apiTokenCreatedToast'), 'success'); + } catch (err) { + showError(errorEl, err.message); + } finally { + btn.disabled = false; + } + }); + + list.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-revoke-api-token]'); + if (!btn) return; + const id = Number(btn.dataset.revokeApiToken); + const name = btn.dataset.name; + if (!await confirmModal(t('settings.apiTokenRevokeConfirm', { name }), { danger: true, confirmLabel: t('settings.apiTokenRevoke') })) return; + try { + await api.delete(`/auth/api-tokens/${id}`); + tokens = tokens.map((token) => token.id === id ? { ...token, revoked_at: new Date().toISOString() } : token); + renderApiTokenList(container, tokens); + window.oikos?.showToast(t('settings.apiTokenRevokedToast'), 'default'); + } catch (err) { + window.oikos?.showToast(err.message, 'danger'); + } + }); +} + // -------------------------------------------------------- // Kategorie-Verwaltung diff --git a/public/styles/settings.css b/public/styles/settings.css index b66fecd..7bc7704 100644 --- a/public/styles/settings.css +++ b/public/styles/settings.css @@ -321,6 +321,13 @@ width: 100%; } +.settings-token-output { + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-2); +} + /* -------------------------------------------------------- Theme-Toggle -------------------------------------------------------- */ diff --git a/server/auth.js b/server/auth.js index 31a62ec..3d5713a 100644 --- a/server/auth.js +++ b/server/auth.js @@ -8,12 +8,14 @@ import express from 'express'; import bcrypt from 'bcrypt'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; +import crypto from 'node:crypto'; import * as db from './db.js'; import { generateToken, csrfMiddleware } from './middleware/csrf.js'; import { createLogger } from './logger.js'; const log = createLogger('Auth'); const router = express.Router(); +const API_TOKEN_PREFIX = 'oikos_'; // -------------------------------------------------------- // Session-Store (better-sqlite3, gleiche DB-Instanz wie App) @@ -124,6 +126,60 @@ const loginLimiter = rateLimit({ message: { error: 'Zu viele Login-Versuche. Bitte warte kurz.', code: 429 }, }); +function hashApiToken(token) { + return crypto.createHash('sha256').update(token, 'utf8').digest('hex'); +} + +function extractApiToken(req) { + const auth = req.headers.authorization || ''; + if (auth.toLowerCase().startsWith('bearer ')) return auth.slice(7).trim(); + return String(req.headers['x-api-key'] || '').trim(); +} + +function publicApiToken(row) { + return { + id: row.id, + name: row.name, + token_prefix: row.token_prefix, + created_by: row.created_by, + creator_name: row.creator_name, + expires_at: row.expires_at, + revoked_at: row.revoked_at, + last_used_at: row.last_used_at, + created_at: row.created_at, + }; +} + +function authenticateApiToken(req) { + const token = extractApiToken(req); + if (!token) return null; + + const tokenHash = hashApiToken(token); + const row = db.get().prepare(` + SELECT t.*, u.role, u.username, u.display_name, u.avatar_color + FROM api_tokens t + JOIN users u ON u.id = t.created_by + WHERE t.token_hash = ? + AND t.revoked_at IS NULL + AND (t.expires_at IS NULL OR t.expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + `).get(tokenHash); + if (!row) return null; + + db.get().prepare(` + UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? + `).run(row.id); + + req.apiToken = publicApiToken(row); + req.user = { + id: row.created_by, + username: row.username, + display_name: row.display_name, + avatar_color: row.avatar_color, + role: row.role, + }; + return row; +} + // -------------------------------------------------------- // Auth-Guard Middleware // -------------------------------------------------------- @@ -133,7 +189,18 @@ const loginLimiter = rateLimit({ * Schützt alle API-Routen außer /auth/login. */ function requireAuth(req, res, next) { + const apiToken = authenticateApiToken(req); + if (apiToken) { + req.authMethod = 'api_token'; + req.authUserId = apiToken.created_by; + req.authRole = apiToken.role; + return next(); + } + if (req.session && req.session.userId) { + req.authMethod = 'session'; + req.authUserId = req.session.userId; + req.authRole = req.session.role; return next(); } res.status(401).json({ error: 'Not authenticated.', code: 401 }); @@ -143,7 +210,7 @@ function requireAuth(req, res, next) { * Prüft ob der authentifizierte User Admin-Rolle hat. */ function requireAdmin(req, res, next) { - if (req.session && req.session.role === 'admin') { + if (req.authRole === 'admin') { return next(); } res.status(403).json({ error: 'Permission denied.', code: 403 }); @@ -225,6 +292,9 @@ router.post('/login', loginLimiter, async (req, res) => { * Response: { ok: true } */ router.post('/logout', requireAuth, csrfMiddleware, (req, res) => { + if (req.authMethod === 'api_token') { + return res.json({ ok: true }); + } req.session.destroy((err) => { if (err) { log.error('Logout error:', err); @@ -293,13 +363,19 @@ router.get('/me', requireAuth, (req, res) => { try { const user = db.get() .prepare('SELECT id, username, display_name, avatar_color, role FROM users WHERE id = ?') - .get(req.session.userId); + .get(req.authUserId); if (!user) { - req.session.destroy(() => {}); + if (req.authMethod === 'session' && typeof req.session.destroy === 'function') { + req.session.destroy(() => {}); + } return res.status(401).json({ error: 'User not found.', code: 401 }); } + if (req.authMethod === 'api_token') { + return res.json({ user }); + } + // CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume: // iOS kann den CSRF-Cookie verwerfen waehrend die Session-Cookie erhalten bleibt. // /me ist der erste API-Call nach App-Resume, also hier den Cookie wiederherstellen.) @@ -337,6 +413,78 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => { } }); +router.get('/api-tokens', requireAuth, requireAdmin, (req, res) => { + try { + const rows = db.get().prepare(` + SELECT t.*, u.display_name AS creator_name + FROM api_tokens t + LEFT JOIN users u ON u.id = t.created_by + ORDER BY t.created_at DESC + `).all(); + res.json({ data: rows.map(publicApiToken) }); + } catch (err) { + log.error('API token list error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.post('/api-tokens', requireAuth, requireAdmin, csrfMiddleware, (req, res) => { + try { + const name = String(req.body.name || '').trim(); + const expiresAt = req.body.expires_at ? String(req.body.expires_at).trim() : null; + + if (!name) return res.status(400).json({ error: 'Token name is required.', code: 400 }); + if (name.length > 100) return res.status(400).json({ error: 'Token name may be at most 100 characters long.', code: 400 }); + if (expiresAt && Number.isNaN(Date.parse(expiresAt))) { + return res.status(400).json({ error: 'expires_at must be a valid ISO date/time.', code: 400 }); + } + if (expiresAt && new Date(expiresAt).getTime() <= Date.now()) { + return res.status(400).json({ error: 'Expiration date must be in the future.', code: 400 }); + } + + const token = API_TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url'); + const tokenHash = hashApiToken(token); + const tokenPrefix = token.slice(0, 12); + const normalizedExpiresAt = expiresAt ? new Date(expiresAt).toISOString() : null; + + const result = db.get().prepare(` + INSERT INTO api_tokens (name, token_hash, token_prefix, created_by, expires_at) + VALUES (?, ?, ?, ?, ?) + `).run(name, tokenHash, tokenPrefix, req.authUserId, normalizedExpiresAt); + + const row = db.get().prepare(` + SELECT t.*, u.display_name AS creator_name + FROM api_tokens t + LEFT JOIN users u ON u.id = t.created_by + WHERE t.id = ? + `).get(result.lastInsertRowid); + + res.status(201).json({ data: publicApiToken(row), token }); + } catch (err) { + log.error('API token creation error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + +router.delete('/api-tokens/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid token ID.', code: 400 }); + + const result = db.get().prepare(` + UPDATE api_tokens + SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + WHERE id = ? + `).run(id); + + if (result.changes === 0) return res.status(404).json({ error: 'API token not found.', code: 404 }); + res.json({ ok: true }); + } catch (err) { + log.error('API token revocation error:', err); + res.status(500).json({ error: 'Internal server error.', code: 500 }); + } +}); + /** * POST /api/v1/auth/users * Admin only. Erstellt neues Familienmitglied. @@ -405,14 +553,14 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => { return res.status(400).json({ error: 'New password must be at least 8 characters long.', code: 400 }); } - const user = db.get().prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.userId); + const user = db.get().prepare('SELECT password_hash FROM users WHERE id = ?').get(req.authUserId); if (!user) return res.status(404).json({ error: 'User not found.', code: 404 }); const valid = await bcrypt.compare(current_password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Current password is incorrect.', code: 401 }); const hash = await bcrypt.hash(new_password, 12); - db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.session.userId); + db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.authUserId); // Alle anderen Sessions dieses Users invalidieren (aktuelle behalten) const currentSid = req.sessionID; @@ -421,7 +569,7 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => { if (row.sid === currentSid) continue; try { const sess = JSON.parse(row.sess); - if (sess.userId === req.session.userId) { + if (sess.userId === req.authUserId) { db.get().prepare('DELETE FROM sessions WHERE sid = ?').run(row.sid); } } catch { /* ignore malformed session */ } @@ -443,7 +591,7 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res try { const userId = parseInt(req.params.id, 10); - if (userId === req.session.userId) { + if (userId === req.authUserId) { return res.status(400).json({ error: 'You cannot delete your own account.', code: 400 }); } diff --git a/server/db-schema-test.js b/server/db-schema-test.js index e34e67c..981dde1 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -144,6 +144,17 @@ const MIGRATIONS_SQL = { created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), UNIQUE(category_key, name) ); + CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT, + revoked_at TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); CREATE TRIGGER IF NOT EXISTS trg_users_updated_at AFTER UPDATE ON users FOR EACH ROW BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; @@ -185,6 +196,8 @@ const MIGRATIONS_SQL = { CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned); CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date); CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by); + CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by); `, 2: ` CREATE TABLE IF NOT EXISTS sync_config ( diff --git a/server/db.js b/server/db.js index 4ef2853..57253e1 100644 --- a/server/db.js +++ b/server/db.js @@ -671,6 +671,26 @@ const MIGRATIONS = [ GROUP BY category, subcategory; `, }, + { + version: 17, + description: 'API tokens for non-interactive authentication', + up: ` + CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT, + revoked_at TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by); + `, + }, ]; /** diff --git a/server/index.js b/server/index.js index 120d75b..d09af0e 100644 --- a/server/index.js +++ b/server/index.js @@ -13,6 +13,7 @@ import { createLogger } from './logger.js'; import * as db from './db.js'; import { router as authRouter, sessionMiddleware, requireAuth } from './auth.js'; import { csrfMiddleware } from './middleware/csrf.js'; +import { buildOpenApiSpec } from './openapi.js'; import * as googleCalendar from './services/google-calendar.js'; import * as appleCalendar from './services/apple-calendar.js'; import * as icsSubscription from './services/ics-subscription.js'; @@ -166,6 +167,16 @@ app.get('/api/v1/version', (req, res) => { res.json({ version: APP_VERSION }); }); +function sendOpenApi(req, res) { + if (req.query.download === '1') { + res.setHeader('Content-Disposition', 'attachment; filename="openapi.json"'); + } + res.json(buildOpenApiSpec(req, APP_VERSION)); +} + +app.get('/api/v1/openapi.json', sendOpenApi); +app.get('/openapi.json', sendOpenApi); + // Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz app.use('/api/v1', requireAuth); app.use('/api/v1', csrfMiddleware); diff --git a/server/logger.js b/server/logger.js index f1ccc29..a05b325 100644 --- a/server/logger.js +++ b/server/logger.js @@ -12,14 +12,22 @@ const isProduction = process.env.NODE_ENV === 'production'; function emit(level, mod, msg, extra) { if (LEVELS[level] < currentLevel) return; + const normalizedExtra = extra instanceof Error + ? { + name: extra.name, + message: extra.message, + stack: extra.stack, + } + : extra; + if (isProduction) { const entry = { ts: new Date().toISOString(), level, mod, msg }; - if (extra !== undefined) entry.extra = extra; + if (normalizedExtra !== undefined) entry.extra = normalizedExtra; process.stdout.write(JSON.stringify(entry) + '\n'); } else { const prefix = `[${mod}]`; - if (extra !== undefined) { - console[level === 'debug' ? 'log' : level](prefix, msg, extra); + if (normalizedExtra !== undefined) { + console[level === 'debug' ? 'log' : level](prefix, msg, normalizedExtra); } else { console[level === 'debug' ? 'log' : level](prefix, msg); } diff --git a/server/middleware/csrf.js b/server/middleware/csrf.js index 82ea63b..35f301d 100644 --- a/server/middleware/csrf.js +++ b/server/middleware/csrf.js @@ -28,6 +28,8 @@ function generateToken() { * Muss NACH requireAuth eingebunden werden. */ function csrfMiddleware(req, res, next) { + if (req.authMethod === 'api_token') return next(); + // Token generieren falls noch nicht vorhanden (erste Request nach Login) if (!req.session.csrfToken) { req.session.csrfToken = generateToken(); diff --git a/server/openapi.js b/server/openapi.js new file mode 100644 index 0000000..b9d8039 --- /dev/null +++ b/server/openapi.js @@ -0,0 +1,607 @@ +function authSecurity() { + return [{ bearerAuth: [] }, { apiKeyAuth: [] }, { cookieAuth: [] }]; +} + +function csrfHeaderParam() { + return { + name: 'X-CSRF-Token', + in: 'header', + required: false, + description: 'Required for state-changing requests when using session/cookie authentication. Not required for API-token authentication.', + schema: { type: 'string' }, + }; +} + +function jsonBody(schemaRef, description = 'JSON request body') { + return { + required: true, + description, + content: { + 'application/json': { + schema: schemaRef ? { $ref: schemaRef } : { type: 'object', additionalProperties: true }, + }, + }, + }; +} + +function op({ + summary, + tag, + description, + auth = true, + admin = false, + params = [], + requestBody = null, + responses = null, + stateChanging = false, +}) { + const operation = { + tags: [tag], + summary, + responses: responses ?? { + 200: { description: 'Successful response' }, + 401: { $ref: '#/components/responses/Unauthorized' }, + 500: { $ref: '#/components/responses/InternalServerError' }, + }, + }; + + if (description) operation.description = description; + if (auth) operation.security = authSecurity(); + if (admin) { + operation.description = `${operation.description ? `${operation.description}\n\n` : ''}Admin-only endpoint.`; + operation.responses[403] = { $ref: '#/components/responses/Forbidden' }; + } + if (params.length || stateChanging) { + operation.parameters = [...params]; + if (stateChanging) operation.parameters.push(csrfHeaderParam()); + } + if (requestBody) operation.requestBody = requestBody; + return operation; +} + +function idParam(name = 'id', description = 'Resource ID') { + return { + name, + in: 'path', + required: true, + description, + schema: { type: 'integer' }, + }; +} + +function langParam() { + return { + name: 'lang', + in: 'query', + required: false, + description: 'Language code for localized labels. Supported values: ar, de, el, en, es, fr, hi, it, ja, pt, ru, sv, tr, uk, zh. Defaults to en.', + schema: { + type: 'string', + default: 'en', + enum: ['ar', 'de', 'el', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'pt', 'ru', 'sv', 'tr', 'uk', 'zh'], + }, + }; +} + +function buildPaths() { + return { + '/health': { + get: op({ + summary: 'Health check', + tag: 'System', + auth: false, + responses: { + 200: { + description: 'Service health status', + content: { 'application/json': { schema: { $ref: '#/components/schemas/HealthResponse' } } }, + }, + }, + }), + }, + '/api/v1/version': { + get: op({ + summary: 'Get application version', + tag: 'System', + auth: false, + responses: { + 200: { + description: 'Application version', + content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionResponse' } } }, + }, + }, + }), + }, + '/api/v1/openapi.json': { + get: op({ + summary: 'Get OpenAPI specification', + tag: 'System', + auth: false, + description: 'Use `?download=1` to receive the OpenAPI document as a downloadable file.', + }), + }, + '/openapi.json': { + get: op({ + summary: 'Get OpenAPI specification', + tag: 'System', + auth: false, + description: 'Alias for `/api/v1/openapi.json`. Use `?download=1` to download the JSON file.', + }), + }, + '/docs': { + get: op({ + summary: 'Swagger UI documentation', + tag: 'System', + auth: false, + responses: { 200: { description: 'Swagger UI HTML page' } }, + }), + }, + '/api/v1/auth/login': { + post: op({ + summary: 'Login with username and password', + tag: 'Auth', + auth: false, + requestBody: jsonBody('#/components/schemas/LoginRequest'), + responses: { + 200: { + description: 'Authenticated user and CSRF token', + content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } }, + }, + 401: { $ref: '#/components/responses/Unauthorized' }, + }, + }), + }, + '/api/v1/auth/logout': { + post: op({ summary: 'Logout current session', tag: 'Auth', stateChanging: true }), + }, + '/api/v1/auth/setup': { + post: op({ + summary: 'Initial setup: create first admin', + tag: 'Auth', + auth: false, + requestBody: jsonBody('#/components/schemas/SetupRequest'), + responses: { + 201: { description: 'Admin user created' }, + 403: { $ref: '#/components/responses/Forbidden' }, + 409: { description: 'Username already taken' }, + }, + }), + }, + '/api/v1/auth/me': { + get: op({ + summary: 'Get current authenticated user', + tag: 'Auth', + responses: { + 200: { + description: 'Current user', + content: { 'application/json': { schema: { $ref: '#/components/schemas/MeResponse' } } }, + }, + 401: { $ref: '#/components/responses/Unauthorized' }, + }, + }), + }, + '/api/v1/auth/me/password': { + patch: op({ + summary: 'Change current user password', + tag: 'Auth', + stateChanging: true, + requestBody: jsonBody('#/components/schemas/PasswordChangeRequest'), + }), + }, + '/api/v1/auth/users': { + get: op({ summary: 'List users', tag: 'Auth', admin: true }), + post: op({ + summary: 'Create user', + tag: 'Auth', + admin: true, + stateChanging: true, + requestBody: jsonBody('#/components/schemas/UserCreateRequest'), + responses: { + 201: { description: 'User created' }, + 400: { $ref: '#/components/responses/BadRequest' }, + 403: { $ref: '#/components/responses/Forbidden' }, + 409: { description: 'Username already taken' }, + 500: { $ref: '#/components/responses/InternalServerError' }, + }, + }), + }, + '/api/v1/auth/users/{id}': { + delete: op({ + summary: 'Delete user', + tag: 'Auth', + admin: true, + stateChanging: true, + params: [idParam('id', 'User ID')], + }), + }, + '/api/v1/auth/api-tokens': { + get: op({ summary: 'List API tokens', tag: 'Auth', admin: true }), + post: op({ + summary: 'Create API token', + tag: 'Auth', + admin: true, + stateChanging: true, + requestBody: jsonBody('#/components/schemas/ApiTokenCreateRequest'), + responses: { + 201: { + description: 'API token created. The plaintext token is returned only once.', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiTokenCreateResponse' } } }, + }, + 400: { $ref: '#/components/responses/BadRequest' }, + 403: { $ref: '#/components/responses/Forbidden' }, + 500: { $ref: '#/components/responses/InternalServerError' }, + }, + }), + }, + '/api/v1/auth/api-tokens/{id}': { + delete: op({ + summary: 'Revoke API token', + tag: 'Auth', + admin: true, + stateChanging: true, + params: [idParam('id', 'API token ID')], + }), + }, + '/api/v1/dashboard': { get: op({ summary: 'Get dashboard data', tag: 'Dashboard' }) }, + '/api/v1/tasks': { + get: op({ summary: 'List tasks', tag: 'Tasks' }), + post: op({ summary: 'Create task', tag: 'Tasks', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/tasks/meta/options': { get: op({ summary: 'Get task metadata', tag: 'Tasks' }) }, + '/api/v1/tasks/{id}': { + get: op({ summary: 'Get task', tag: 'Tasks', params: [idParam()] }), + put: op({ summary: 'Update task', tag: 'Tasks', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete task', tag: 'Tasks', params: [idParam()], stateChanging: true }), + }, + '/api/v1/tasks/{id}/status': { + patch: op({ summary: 'Update task status', tag: 'Tasks', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/shopping': { + get: op({ summary: 'List shopping lists', tag: 'Shopping' }), + post: op({ summary: 'Create shopping list', tag: 'Shopping', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/shopping/categories': { + get: op({ summary: 'List shopping categories', tag: 'Shopping' }), + post: op({ summary: 'Create shopping category', tag: 'Shopping', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/shopping/categories/{catId}': { + put: op({ summary: 'Update shopping category', tag: 'Shopping', params: [idParam('catId', 'Category ID')], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete shopping category', tag: 'Shopping', params: [idParam('catId', 'Category ID')], stateChanging: true }), + }, + '/api/v1/shopping/categories/reorder': { + patch: op({ summary: 'Reorder shopping categories', tag: 'Shopping', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/shopping/suggestions': { get: op({ summary: 'Get shopping suggestions', tag: 'Shopping' }) }, + '/api/v1/shopping/items/{itemId}': { + patch: op({ summary: 'Update shopping item', tag: 'Shopping', params: [idParam('itemId', 'Item ID')], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete shopping item', tag: 'Shopping', params: [idParam('itemId', 'Item ID')], stateChanging: true }), + }, + '/api/v1/shopping/{listId}': { + put: op({ summary: 'Rename shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true }), + }, + '/api/v1/shopping/{listId}/items': { + get: op({ summary: 'List items in shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')] }), + post: op({ summary: 'Add item to shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/shopping/{listId}/items/checked': { + delete: op({ summary: 'Delete checked shopping items', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true }), + }, + '/api/v1/meals': { + get: op({ summary: 'List meal plan entries', tag: 'Meals' }), + post: op({ summary: 'Create meal plan entry', tag: 'Meals', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/meals/suggestions': { get: op({ summary: 'Get meal suggestions', tag: 'Meals' }) }, + '/api/v1/meals/{id}': { + put: op({ summary: 'Update meal plan entry', tag: 'Meals', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete meal plan entry', tag: 'Meals', params: [idParam()], stateChanging: true }), + }, + '/api/v1/meals/{id}/ingredients': { + post: op({ summary: 'Add meal ingredient', tag: 'Meals', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/meals/ingredients/{ingId}': { + patch: op({ summary: 'Update meal ingredient', tag: 'Meals', params: [idParam('ingId', 'Ingredient ID')], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete meal ingredient', tag: 'Meals', params: [idParam('ingId', 'Ingredient ID')], stateChanging: true }), + }, + '/api/v1/meals/{id}/to-shopping-list': { + post: op({ summary: 'Transfer meal ingredients to shopping list', tag: 'Meals', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/meals/week-to-shopping-list': { + post: op({ summary: 'Transfer weekly meal ingredients to shopping list', tag: 'Meals', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/recipes': { + get: op({ summary: 'List recipes', tag: 'Recipes' }), + post: op({ summary: 'Create recipe', tag: 'Recipes', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/recipes/{id}': { + put: op({ summary: 'Update recipe', tag: 'Recipes', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete recipe', tag: 'Recipes', params: [idParam()], stateChanging: true }), + }, + '/api/v1/calendar': { + get: op({ summary: 'List calendar events', tag: 'Calendar' }), + post: op({ summary: 'Create calendar event', tag: 'Calendar', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/calendar/upcoming': { get: op({ summary: 'List upcoming events', tag: 'Calendar' }) }, + '/api/v1/calendar/google/auth': { get: op({ summary: 'Start Google Calendar OAuth', tag: 'Calendar', admin: true }) }, + '/api/v1/calendar/google/callback': { get: op({ summary: 'Google Calendar OAuth callback', tag: 'Calendar', auth: false }) }, + '/api/v1/calendar/google/sync': { post: op({ summary: 'Run Google Calendar sync', tag: 'Calendar', admin: true, stateChanging: true }) }, + '/api/v1/calendar/google/status': { get: op({ summary: 'Get Google Calendar status', tag: 'Calendar' }) }, + '/api/v1/calendar/google/disconnect': { delete: op({ summary: 'Disconnect Google Calendar', tag: 'Calendar', admin: true, stateChanging: true }) }, + '/api/v1/calendar/apple/status': { get: op({ summary: 'Get Apple Calendar status', tag: 'Calendar' }) }, + '/api/v1/calendar/apple/sync': { post: op({ summary: 'Run Apple Calendar sync', tag: 'Calendar', admin: true, stateChanging: true }) }, + '/api/v1/calendar/apple/connect': { post: op({ summary: 'Connect Apple Calendar', tag: 'Calendar', admin: true, stateChanging: true, requestBody: jsonBody(null) }) }, + '/api/v1/calendar/apple/disconnect': { delete: op({ summary: 'Disconnect Apple Calendar', tag: 'Calendar', admin: true, stateChanging: true }) }, + '/api/v1/calendar/subscriptions': { + get: op({ summary: 'List ICS subscriptions', tag: 'Calendar' }), + post: op({ summary: 'Create ICS subscription', tag: 'Calendar', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/calendar/subscriptions/{id}': { + patch: op({ summary: 'Update ICS subscription', tag: 'Calendar', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete ICS subscription', tag: 'Calendar', params: [idParam()], stateChanging: true }), + }, + '/api/v1/calendar/subscriptions/{id}/sync': { + post: op({ summary: 'Sync ICS subscription', tag: 'Calendar', params: [idParam()], stateChanging: true }), + }, + '/api/v1/calendar/{id}': { + get: op({ summary: 'Get calendar event', tag: 'Calendar', params: [idParam()] }), + put: op({ summary: 'Update calendar event', tag: 'Calendar', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete calendar event', tag: 'Calendar', params: [idParam()], stateChanging: true }), + }, + '/api/v1/calendar/{id}/reset': { + post: op({ summary: 'Reset external calendar event to source state', tag: 'Calendar', params: [idParam()], stateChanging: true }), + }, + '/api/v1/notes': { + get: op({ summary: 'List notes', tag: 'Notes' }), + post: op({ summary: 'Create note', tag: 'Notes', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/notes/{id}': { + put: op({ summary: 'Update note', tag: 'Notes', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete note', tag: 'Notes', params: [idParam()], stateChanging: true }), + }, + '/api/v1/notes/{id}/pin': { + patch: op({ summary: 'Toggle note pin state', tag: 'Notes', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/contacts': { + get: op({ summary: 'List contacts', tag: 'Contacts' }), + post: op({ summary: 'Create contact', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/contacts/meta': { get: op({ summary: 'Get contact metadata', tag: 'Contacts' }) }, + '/api/v1/contacts/{id}': { + put: op({ summary: 'Update contact', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }), + }, + '/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) }, + '/api/v1/budget/summary': { get: op({ summary: 'Get budget summary', tag: 'Budget' }) }, + '/api/v1/budget/export': { get: op({ summary: 'Export budget entries as CSV', tag: 'Budget' }) }, + '/api/v1/budget/meta': { get: op({ summary: 'Get budget categories and subcategories', tag: 'Budget' }) }, + '/api/v1/budget/categories': { + get: op({ summary: 'List budget categories', tag: 'Budget', params: [langParam()] }), + post: op({ summary: 'Create budget category', tag: 'Budget', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/budget/categories/{categoryKey}/subcategories': { + get: op({ summary: 'List subcategories for a budget category', tag: 'Budget', params: [{ name: 'categoryKey', in: 'path', required: true, schema: { type: 'string' } }, langParam()] }), + post: op({ summary: 'Create budget subcategory', tag: 'Budget', params: [{ name: 'categoryKey', in: 'path', required: true, schema: { type: 'string' } }], stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/budget': { + get: op({ summary: 'List budget entries', tag: 'Budget' }), + post: op({ summary: 'Create budget entry', tag: 'Budget', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/budget/{id}': { + put: op({ summary: 'Update budget entry', tag: 'Budget', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete budget entry', tag: 'Budget', params: [idParam()], stateChanging: true }), + }, + '/api/v1/weather': { get: op({ summary: 'Get weather data', tag: 'Weather' }) }, + '/api/v1/weather/icon/{code}': { + get: op({ summary: 'Get weather icon asset', tag: 'Weather', params: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }] }), + }, + '/api/v1/preferences': { + get: op({ summary: 'Get user preferences', tag: 'Preferences' }), + put: op({ summary: 'Update user preferences', tag: 'Preferences', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/reminders/pending': { get: op({ summary: 'List pending reminders', tag: 'Reminders' }) }, + '/api/v1/reminders': { + get: op({ summary: 'List reminders', tag: 'Reminders' }), + post: op({ summary: 'Create reminder', tag: 'Reminders', stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete reminders by filter', tag: 'Reminders', stateChanging: true }), + }, + '/api/v1/reminders/{id}/dismiss': { + patch: op({ summary: 'Dismiss reminder', tag: 'Reminders', params: [idParam()], stateChanging: true }), + }, + '/api/v1/reminders/{id}': { + delete: op({ summary: 'Delete reminder', tag: 'Reminders', params: [idParam()], stateChanging: true }), + }, + '/api/v1/search': { get: op({ summary: 'Search across modules', tag: 'Search' }) }, + }; +} + +function buildOpenApiSpec(req, appVersion) { + const origin = `${req.protocol}://${req.get('host')}`; + + return { + openapi: '3.1.0', + info: { + title: 'Oikos API', + version: appVersion, + description: 'OpenAPI documentation for the Oikos family organizer backend.', + }, + servers: [ + { url: origin, description: 'Current server' }, + ], + tags: [ + { name: 'System' }, + { name: 'Auth' }, + { name: 'Dashboard' }, + { name: 'Tasks' }, + { name: 'Shopping' }, + { name: 'Meals' }, + { name: 'Recipes' }, + { name: 'Calendar' }, + { name: 'Notes' }, + { name: 'Contacts' }, + { name: 'Budget' }, + { name: 'Weather' }, + { name: 'Preferences' }, + { name: 'Reminders' }, + { name: 'Search' }, + ], + paths: buildPaths(), + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'API token sent in the Authorization header as `Bearer `.', + }, + apiKeyAuth: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + description: 'API token sent in the `X-API-Key` header.', + }, + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'oikos.sid', + description: 'Browser session cookie. State-changing requests also require `X-CSRF-Token`.', + }, + }, + responses: { + BadRequest: { + description: 'Bad request', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } }, + }, + Unauthorized: { + description: 'Authentication required or invalid credentials/token', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } }, + }, + Forbidden: { + description: 'Permission denied', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } }, + }, + InternalServerError: { + description: 'Internal server error', + content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } }, + }, + }, + schemas: { + ApiError: { + type: 'object', + properties: { + error: { type: 'string' }, + code: { type: 'integer' }, + }, + }, + HealthResponse: { + type: 'object', + properties: { + status: { type: 'string', example: 'ok' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + required: ['status', 'timestamp'], + }, + VersionResponse: { + type: 'object', + properties: { + version: { type: 'string' }, + }, + required: ['version'], + }, + User: { + type: 'object', + properties: { + id: { type: 'integer' }, + username: { type: 'string' }, + display_name: { type: 'string' }, + avatar_color: { type: 'string' }, + role: { type: 'string', enum: ['admin', 'member'] }, + }, + required: ['id', 'username', 'display_name', 'avatar_color', 'role'], + }, + LoginRequest: { + type: 'object', + properties: { + username: { type: 'string' }, + password: { type: 'string' }, + }, + required: ['username', 'password'], + }, + LoginResponse: { + type: 'object', + properties: { + user: { $ref: '#/components/schemas/User' }, + csrfToken: { type: 'string' }, + }, + required: ['user', 'csrfToken'], + }, + MeResponse: { + type: 'object', + properties: { + user: { $ref: '#/components/schemas/User' }, + csrfToken: { type: 'string' }, + }, + required: ['user'], + }, + SetupRequest: { + type: 'object', + properties: { + username: { type: 'string' }, + display_name: { type: 'string' }, + password: { type: 'string' }, + }, + required: ['username', 'display_name', 'password'], + }, + PasswordChangeRequest: { + type: 'object', + properties: { + currentPassword: { type: 'string' }, + newPassword: { type: 'string' }, + }, + required: ['currentPassword', 'newPassword'], + }, + UserCreateRequest: { + type: 'object', + properties: { + username: { type: 'string' }, + display_name: { type: 'string' }, + password: { type: 'string' }, + avatar_color: { type: 'string' }, + role: { type: 'string', enum: ['admin', 'member'] }, + }, + required: ['username', 'display_name', 'password'], + }, + ApiToken: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + token_prefix: { type: 'string' }, + created_by: { type: 'integer' }, + creator_name: { type: 'string' }, + expires_at: { type: ['string', 'null'], format: 'date-time' }, + revoked_at: { type: ['string', 'null'], format: 'date-time' }, + last_used_at: { type: ['string', 'null'], format: 'date-time' }, + created_at: { type: 'string', format: 'date-time' }, + }, + required: ['id', 'name', 'token_prefix', 'created_by', 'created_at'], + }, + ApiTokenCreateRequest: { + type: 'object', + properties: { + name: { type: 'string' }, + expires_at: { type: ['string', 'null'], format: 'date-time' }, + }, + required: ['name'], + }, + ApiTokenCreateResponse: { + type: 'object', + properties: { + data: { $ref: '#/components/schemas/ApiToken' }, + token: { type: 'string' }, + }, + required: ['data', 'token'], + }, + }, + }, + }; +} + +export { buildOpenApiSpec }; diff --git a/server/routes/budget.js b/server/routes/budget.js index 304b3df..36c7386 100644 --- a/server/routes/budget.js +++ b/server/routes/budget.js @@ -6,12 +6,101 @@ import { createLogger } from '../logger.js'; import express from 'express'; +import { readFileSync } from 'node:fs'; +import path from 'path'; import * as db from '../db.js'; import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js'; const log = createLogger('Budget'); const router = express.Router(); +const LOCALE_CACHE = new Map(); +const SUPPORTED_LANGS = new Set(['ar', 'de', 'el', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'pt', 'ru', 'sv', 'tr', 'uk', 'zh']); +const CATEGORY_LABEL_KEYS = { + housing: 'catHousing', + food: 'catFood', + transport: 'catTransport', + personal_health: 'catPersonalHealth', + leisure: 'catLeisure', + shopping_clothing: 'catShoppingClothing', + education: 'catEducation', + financial_other: 'catFinancialOther', + 'Erwerbseinkommen': 'catEarnedIncome', + 'Kapitalerträge': 'catInvestmentIncome', + 'Geschenke & Transfers': 'catTransferGiftIncome', + 'Sozialleistungen': 'catGovernmentBenefits', + 'Sonstiges Einkommen': 'catOtherIncome', +}; +const SUBCATEGORY_LABEL_KEYS = { + rent_mortgage: 'subcatRentMortgage', + condominium: 'subcatCondominium', + utilities: 'subcatUtilities', + internet_tv_phone: 'subcatInternetTvPhone', + renovation_maintenance: 'subcatRenovationMaintenance', + cleaning: 'subcatCleaning', + groceries: 'subcatGroceries', + restaurants_bars: 'subcatRestaurantsBars', + snacks_fast_food: 'subcatSnacksFastFood', + bakery: 'subcatBakery', + fuel: 'subcatFuel', + parking_tolls: 'subcatParkingTolls', + public_transport: 'subcatPublicTransport', + apps_taxi: 'subcatAppsTaxi', + maintenance_insurance: 'subcatMaintenanceInsurance', + pharmacy: 'subcatPharmacy', + health_insurance: 'subcatHealthInsurance', + gym_sports: 'subcatGymSports', + beauty_cosmetics: 'subcatBeautyCosmetics', + travel: 'subcatTravel', + streaming: 'subcatStreaming', + events: 'subcatEvents', + hobbies: 'subcatHobbies', + clothes_shoes: 'subcatClothesShoes', + electronics: 'subcatElectronics', + gifts: 'subcatGifts', + courses_college: 'subcatCoursesCollege', + school_supplies: 'subcatSchoolSupplies', + languages: 'subcatLanguages', + loans_interest: 'subcatLoansInterest', + bank_fees: 'subcatBankFees', + insurance_other: 'subcatInsuranceOther', + investments: 'subcatInvestments', + taxes: 'subcatTaxes', +}; + +function normalizeLang(raw) { + const lang = String(raw || 'en').trim().toLowerCase(); + const base = lang.split(/[-_]/)[0]; + return SUPPORTED_LANGS.has(base) ? base : 'en'; +} + +function budgetMessages(lang) { + const normalized = normalizeLang(lang); + if (!LOCALE_CACHE.has(normalized)) { + const localePath = path.join(import.meta.dirname, '..', '..', 'public', 'locales', `${normalized}.json`); + const parsed = JSON.parse(readFileSync(localePath, 'utf-8')); + LOCALE_CACHE.set(normalized, parsed.budget || {}); + } + return LOCALE_CACHE.get(normalized); +} + +function localizedCategory(category, lang) { + const budget = budgetMessages(lang); + const labelKey = CATEGORY_LABEL_KEYS[category.key]; + return { + ...category, + label: labelKey ? (budget[labelKey] || category.name) : category.name, + }; +} + +function localizedSubcategory(subcategory, lang) { + const budget = budgetMessages(lang); + const labelKey = SUBCATEGORY_LABEL_KEYS[subcategory.key]; + return { + ...subcategory, + label: labelKey ? (budget[labelKey] || subcategory.name) : subcategory.name, + }; +} // -------------------------------------------------------- // Wiederkehrende Einträge: fehlende Instanzen für einen Monat erzeugen @@ -255,6 +344,53 @@ router.get('/meta', (req, res) => { res.json({ data: loadBudgetMeta() }); }); +router.get('/categories', (req, res) => { + try { + const lang = normalizeLang(req.query.lang); + const categories = db.get().prepare(` + SELECT key, name, type, sort_order + FROM budget_categories + ORDER BY type DESC, sort_order ASC, name COLLATE NOCASE ASC + `).all(); + + res.json({ + data: categories.map((category) => localizedCategory(category, lang)), + lang, + }); + } catch (err) { + log.error('GET /categories error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + +router.get('/categories/:categoryKey/subcategories', (req, res) => { + try { + const lang = normalizeLang(req.query.lang); + const category = db.get().prepare(` + SELECT key, name, type, sort_order + FROM budget_categories + WHERE key = ? + `).get(req.params.categoryKey); + if (!category) return res.status(404).json({ error: 'Category not found.', code: 404 }); + + const subcategories = db.get().prepare(` + SELECT key, category_key, name, sort_order + FROM budget_subcategories + WHERE category_key = ? + ORDER BY sort_order ASC, name COLLATE NOCASE ASC + `).all(category.key); + + res.json({ + data: subcategories.map((subcategory) => localizedSubcategory(subcategory, lang)), + category: localizedCategory(category, lang), + lang, + }); + } catch (err) { + log.error('GET /categories/:categoryKey/subcategories error:', err); + res.status(500).json({ error: 'Internal error', code: 500 }); + } +}); + router.post('/categories', (req, res) => { try { const vName = str(req.body.name, 'Name', { max: MAX_SHORT }); diff --git a/test-db.js b/test-db.js index ac11f83..0ab8a51 100644 --- a/test-db.js +++ b/test-db.js @@ -74,6 +74,7 @@ const EXPECTED_TABLES = [ 'users', 'tasks', 'shopping_lists', 'shopping_items', 'meals', 'meal_ingredients', 'calendar_events', 'notes', 'contacts', 'budget_entries', + 'budget_categories', 'budget_subcategories', 'api_tokens', ]; EXPECTED_TABLES.forEach((table) => { @@ -88,9 +89,18 @@ EXPECTED_TABLES.forEach((table) => { // -------------------------------------------------------- // Test 4: Alle updated_at-Triggers vorhanden // -------------------------------------------------------- -const EXPECTED_TRIGGERS = EXPECTED_TABLES.filter((t) => t !== 'schema_migrations').map( - (t) => `trg_${t}_updated_at` -); +const EXPECTED_TRIGGERS = [ + 'users', + 'tasks', + 'shopping_lists', + 'shopping_items', + 'meals', + 'meal_ingredients', + 'calendar_events', + 'notes', + 'contacts', + 'budget_entries', +].map((t) => `trg_${t}_updated_at`); EXPECTED_TRIGGERS.forEach((trigger) => { test(`Trigger "${trigger}" existiert`, () => { @@ -179,6 +189,17 @@ test('Idempotenz: Migration zweimal ausführen ändert nichts', () => { assert(tables.n > 0, 'Tabellen sollten noch vorhanden sein'); }); +test('API-Token anlegen und lesen', () => { + const result = db.prepare(` + INSERT INTO api_tokens (name, token_hash, token_prefix, created_by, expires_at) + VALUES ('MCP integration', 'hash-123', 'oikos_abc123', 1, '2026-12-31T23:59:59.000Z') + `).run(); + const token = db.prepare('SELECT * FROM api_tokens WHERE id = ?').get(result.lastInsertRowid); + assert(token.name === 'MCP integration', 'Token name stimmt nicht'); + assert(token.created_by === 1, 'Token creator stimmt nicht'); + assert(token.revoked_at === null, 'Token sollte nicht widerrufen sein'); +}); + // -------------------------------------------------------- // Ergebnis // --------------------------------------------------------