feat: API token authentication (PR #87 by rafaelfoster)
Adds non-interactive API token authentication for external integrations: - SHA-256-hashed tokens with prefix, expiry, revocation, and last-used tracking - Bearer / X-API-Key header support; CSRF bypass for token-authenticated requests - Admin UI in Settings to create and revoke tokens (one-time plaintext display) - OpenAPI 3.0 spec served at /api/v1/openapi.json and /openapi.json - Migration #17: api_tokens table - Structured error logging in server/logger.js - Removed CDN-backed Swagger UI (hard constraint), reverted CSP - Translated all apiToken i18n keys to German Co-Authored-By: rafaelfoster <rafaelfoster@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "العملة",
|
"currencyLabel": "العملة",
|
||||||
"currencyHint": "تحدد العملة المستخدمة في منطقة الميزانية بأكملها.",
|
"currencyHint": "تحدد العملة المستخدمة في منطقة الميزانية بأكملها.",
|
||||||
"currencySaved": "تم حفظ العملة.",
|
"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": {
|
"ics": {
|
||||||
"title": "اشتراكات ICS",
|
"title": "اشتراكات ICS",
|
||||||
"add": "إضافة اشتراك",
|
"add": "إضافة اشتراك",
|
||||||
|
|||||||
@@ -630,6 +630,28 @@
|
|||||||
"currencyLabel": "Währung",
|
"currencyLabel": "Währung",
|
||||||
"currencyHint": "Legt die Währung für den gesamten Budget-Bereich fest.",
|
"currencyHint": "Legt die Währung für den gesamten Budget-Bereich fest.",
|
||||||
"currencySaved": "Währung gespeichert.",
|
"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": {
|
"ics": {
|
||||||
"title": "ICS-Abonnements",
|
"title": "ICS-Abonnements",
|
||||||
"add": "Abonnement hinzufügen",
|
"add": "Abonnement hinzufügen",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Νόμισμα",
|
"currencyLabel": "Νόμισμα",
|
||||||
"currencyHint": "Ορίζει το νόμισμα για ολόκληρη την ενότητα προϋπολογισμού.",
|
"currencyHint": "Ορίζει το νόμισμα για ολόκληρη την ενότητα προϋπολογισμού.",
|
||||||
"currencySaved": "Το νόμισμα αποθηκεύτηκε.",
|
"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": {
|
"ics": {
|
||||||
"title": "Συνδρομές ICS",
|
"title": "Συνδρομές ICS",
|
||||||
"add": "Προσθήκη συνδρομής",
|
"add": "Προσθήκη συνδρομής",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Currency",
|
"currencyLabel": "Currency",
|
||||||
"currencyHint": "Sets the currency used throughout the budget section.",
|
"currencyHint": "Sets the currency used throughout the budget section.",
|
||||||
"currencySaved": "Currency saved.",
|
"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": {
|
"ics": {
|
||||||
"title": "ICS Subscriptions",
|
"title": "ICS Subscriptions",
|
||||||
"add": "Add subscription",
|
"add": "Add subscription",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Moneda",
|
"currencyLabel": "Moneda",
|
||||||
"currencyHint": "Establece la moneda para toda la sección de presupuesto.",
|
"currencyHint": "Establece la moneda para toda la sección de presupuesto.",
|
||||||
"currencySaved": "Moneda guardada.",
|
"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": {
|
"ics": {
|
||||||
"title": "Suscripciones ICS",
|
"title": "Suscripciones ICS",
|
||||||
"add": "Añadir suscripción",
|
"add": "Añadir suscripción",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Devise",
|
"currencyLabel": "Devise",
|
||||||
"currencyHint": "Définit la devise utilisée dans toute la section budget.",
|
"currencyHint": "Définit la devise utilisée dans toute la section budget.",
|
||||||
"currencySaved": "Devise enregistrée.",
|
"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": {
|
"ics": {
|
||||||
"title": "Abonnements ICS",
|
"title": "Abonnements ICS",
|
||||||
"add": "Ajouter un abonnement",
|
"add": "Ajouter un abonnement",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "मुद्रा",
|
"currencyLabel": "मुद्रा",
|
||||||
"currencyHint": "पूरे बजट अनुभाग में उपयोग की जाने वाली मुद्रा सेट करता है।",
|
"currencyHint": "पूरे बजट अनुभाग में उपयोग की जाने वाली मुद्रा सेट करता है।",
|
||||||
"currencySaved": "मुद्रा सहेजी गई।",
|
"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": {
|
"ics": {
|
||||||
"title": "ICS सदस्यताएं",
|
"title": "ICS सदस्यताएं",
|
||||||
"add": "सदस्यता जोड़ें",
|
"add": "सदस्यता जोड़ें",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Valuta",
|
"currencyLabel": "Valuta",
|
||||||
"currencyHint": "Imposta la valuta utilizzata in tutta la sezione budget.",
|
"currencyHint": "Imposta la valuta utilizzata in tutta la sezione budget.",
|
||||||
"currencySaved": "Valuta salvata.",
|
"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": {
|
"ics": {
|
||||||
"title": "Abbonamenti ICS",
|
"title": "Abbonamenti ICS",
|
||||||
"add": "Aggiungi abbonamento",
|
"add": "Aggiungi abbonamento",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "通貨",
|
"currencyLabel": "通貨",
|
||||||
"currencyHint": "家計全体で使用する通貨を設定します。",
|
"currencyHint": "家計全体で使用する通貨を設定します。",
|
||||||
"currencySaved": "通貨を保存しました。",
|
"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": {
|
"ics": {
|
||||||
"title": "ICSサブスクリプション",
|
"title": "ICSサブスクリプション",
|
||||||
"add": "サブスクリプションを追加",
|
"add": "サブスクリプションを追加",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Moeda",
|
"currencyLabel": "Moeda",
|
||||||
"currencyHint": "Define a moeda usada em toda a área de orçamento.",
|
"currencyHint": "Define a moeda usada em toda a área de orçamento.",
|
||||||
"currencySaved": "Moeda salva.",
|
"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": {
|
"ics": {
|
||||||
"title": "Assinaturas ICS",
|
"title": "Assinaturas ICS",
|
||||||
"add": "Adicionar assinatura",
|
"add": "Adicionar assinatura",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Валюта",
|
"currencyLabel": "Валюта",
|
||||||
"currencyHint": "Устанавливает валюту для всего раздела бюджета.",
|
"currencyHint": "Устанавливает валюту для всего раздела бюджета.",
|
||||||
"currencySaved": "Валюта сохранена.",
|
"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": {
|
"ics": {
|
||||||
"title": "ICS-подписки",
|
"title": "ICS-подписки",
|
||||||
"add": "Добавить подписку",
|
"add": "Добавить подписку",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Valuta",
|
"currencyLabel": "Valuta",
|
||||||
"currencyHint": "Ställer in valutan som används i hela budgetavsnittet.",
|
"currencyHint": "Ställer in valutan som används i hela budgetavsnittet.",
|
||||||
"currencySaved": "Valuta sparad.",
|
"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": {
|
"ics": {
|
||||||
"title": "ICS-prenumerationer",
|
"title": "ICS-prenumerationer",
|
||||||
"add": "Lägg till prenumeration",
|
"add": "Lägg till prenumeration",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Para birimi",
|
"currencyLabel": "Para birimi",
|
||||||
"currencyHint": "Bütçe bölümünde kullanılan para birimini belirler.",
|
"currencyHint": "Bütçe bölümünde kullanılan para birimini belirler.",
|
||||||
"currencySaved": "Para birimi kaydedildi.",
|
"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": {
|
"ics": {
|
||||||
"title": "ICS Abonelikleri",
|
"title": "ICS Abonelikleri",
|
||||||
"add": "Abonelik ekle",
|
"add": "Abonelik ekle",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "Валюта",
|
"currencyLabel": "Валюта",
|
||||||
"currencyHint": "Встановлює валюту, що використовується в розділі бюджету.",
|
"currencyHint": "Встановлює валюту, що використовується в розділі бюджету.",
|
||||||
"currencySaved": "Валюту збережено.",
|
"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": {
|
"ics": {
|
||||||
"title": "ICS-підписки",
|
"title": "ICS-підписки",
|
||||||
"add": "Додати підписку",
|
"add": "Додати підписку",
|
||||||
|
|||||||
@@ -624,6 +624,28 @@
|
|||||||
"currencyLabel": "货币",
|
"currencyLabel": "货币",
|
||||||
"currencyHint": "设置整个预算区域使用的货币。",
|
"currencyHint": "设置整个预算区域使用的货币。",
|
||||||
"currencySaved": "货币已保存。",
|
"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": {
|
"ics": {
|
||||||
"title": "ICS 订阅",
|
"title": "ICS 订阅",
|
||||||
"add": "添加订阅",
|
"add": "添加订阅",
|
||||||
|
|||||||
+139
-3
@@ -59,15 +59,17 @@ export async function render(container, { user }) {
|
|||||||
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' };
|
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' };
|
||||||
let categories = [];
|
let categories = [];
|
||||||
let icsSubscriptions = [];
|
let icsSubscriptions = [];
|
||||||
|
let apiTokens = [];
|
||||||
|
|
||||||
try {
|
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: [] }),
|
user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
|
||||||
api.get('/calendar/google/status'),
|
api.get('/calendar/google/status'),
|
||||||
api.get('/calendar/apple/status'),
|
api.get('/calendar/apple/status'),
|
||||||
api.get('/preferences'),
|
api.get('/preferences'),
|
||||||
api.get('/shopping/categories'),
|
api.get('/shopping/categories'),
|
||||||
api.get('/calendar/subscriptions'),
|
api.get('/calendar/subscriptions'),
|
||||||
|
user.role === 'admin' ? api.get('/auth/api-tokens') : Promise.resolve({ data: [] }),
|
||||||
]);
|
]);
|
||||||
if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
|
if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
|
||||||
if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
|
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 (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs;
|
||||||
if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? [];
|
if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? [];
|
||||||
if (icsRes.status === 'fulfilled') icsSubscriptions = icsRes.value.data ?? [];
|
if (icsRes.status === 'fulfilled') icsSubscriptions = icsRes.value.data ?? [];
|
||||||
|
if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? [];
|
||||||
} catch (_) { /* non-critical */ }
|
} catch (_) { /* non-critical */ }
|
||||||
|
|
||||||
const googleStatusText = googleStatus.connected
|
const googleStatusText = googleStatus.connected
|
||||||
@@ -364,6 +367,35 @@ export async function render(container, { user }) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
${user?.role === 'admin' ? `
|
${user?.role === 'admin' ? `
|
||||||
|
<section class="settings-section">
|
||||||
|
<h2 class="settings-section__title">${t('settings.apiTokensTitle')}</h2>
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 class="settings-card__title">${t('settings.apiTokensCardTitle')}</h3>
|
||||||
|
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.apiTokensHint')}</p>
|
||||||
|
<ul class="settings-members" id="api-token-list">
|
||||||
|
${apiTokens.map(apiTokenHtml).join('')}
|
||||||
|
</ul>
|
||||||
|
<form id="api-token-form" class="settings-form" autocomplete="off">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="api-token-name">${t('settings.apiTokenNameLabel')}</label>
|
||||||
|
<input class="form-input" type="text" id="api-token-name" maxlength="100" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="api-token-expires">${t('settings.apiTokenExpiresLabel')}</label>
|
||||||
|
<input class="form-input" type="datetime-local" id="api-token-expires" />
|
||||||
|
<p class="form-hint">${t('settings.apiTokenExpiresHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div id="api-token-created" class="settings-token-output" hidden>
|
||||||
|
<label class="form-label" for="api-token-created-value">${t('settings.apiTokenCreatedLabel')}</label>
|
||||||
|
<input class="form-input" id="api-token-created-value" type="text" readonly />
|
||||||
|
<p class="form-hint">${t('settings.apiTokenCreatedHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div id="api-token-error" class="form-error" hidden></div>
|
||||||
|
<button type="submit" class="btn btn--primary">${t('settings.apiTokenCreate')}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2 class="settings-section__title">${t('settings.sectionFamily')}</h2>
|
<h2 class="settings-section__title">${t('settings.sectionFamily')}</h2>
|
||||||
<div class="settings-card" id="members-card">
|
<div class="settings-card" id="members-card">
|
||||||
@@ -424,17 +456,18 @@ export async function render(container, { user }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bindEvents(container, user, categories, icsSubscriptions);
|
bindEvents(container, user, categories, icsSubscriptions, apiTokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Event-Binding
|
// Event-Binding
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
function bindEvents(container, user, categories, icsSubscriptions) {
|
function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
|
||||||
bindTabEvents(container);
|
bindTabEvents(container);
|
||||||
bindCategoryEvents(container);
|
bindCategoryEvents(container);
|
||||||
bindIcsEvents(container, user, icsSubscriptions);
|
bindIcsEvents(container, user, icsSubscriptions);
|
||||||
|
bindApiTokenEvents(container, apiTokens);
|
||||||
// Theme-Toggle
|
// Theme-Toggle
|
||||||
const themeToggle = container.querySelector('#theme-toggle');
|
const themeToggle = container.querySelector('#theme-toggle');
|
||||||
if (themeToggle) {
|
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 `
|
||||||
|
<li class="settings-member" data-api-token-id="${token.id}">
|
||||||
|
<div class="settings-member__info">
|
||||||
|
<span class="settings-member__name">${esc(token.name)}</span>
|
||||||
|
<span class="settings-member__meta">${esc(meta)}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--icon btn--danger-outline" data-revoke-api-token="${token.id}" data-name="${esc(token.name)}" ${token.revoked_at ? 'disabled' : ''} aria-label="${t('settings.apiTokenRevoke')}">
|
||||||
|
<i data-lucide="ban" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Kategorie-Verwaltung
|
||||||
|
|||||||
@@ -321,6 +321,13 @@
|
|||||||
width: 100%;
|
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
|
Theme-Toggle
|
||||||
-------------------------------------------------------- */
|
-------------------------------------------------------- */
|
||||||
|
|||||||
+155
-7
@@ -8,12 +8,14 @@ import express from 'express';
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { generateToken, csrfMiddleware } from './middleware/csrf.js';
|
import { generateToken, csrfMiddleware } from './middleware/csrf.js';
|
||||||
import { createLogger } from './logger.js';
|
import { createLogger } from './logger.js';
|
||||||
|
|
||||||
const log = createLogger('Auth');
|
const log = createLogger('Auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const API_TOKEN_PREFIX = 'oikos_';
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Session-Store (better-sqlite3, gleiche DB-Instanz wie App)
|
// 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 },
|
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
|
// Auth-Guard Middleware
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -133,7 +189,18 @@ const loginLimiter = rateLimit({
|
|||||||
* Schützt alle API-Routen außer /auth/login.
|
* Schützt alle API-Routen außer /auth/login.
|
||||||
*/
|
*/
|
||||||
function requireAuth(req, res, next) {
|
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) {
|
if (req.session && req.session.userId) {
|
||||||
|
req.authMethod = 'session';
|
||||||
|
req.authUserId = req.session.userId;
|
||||||
|
req.authRole = req.session.role;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
res.status(401).json({ error: 'Not authenticated.', code: 401 });
|
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.
|
* Prüft ob der authentifizierte User Admin-Rolle hat.
|
||||||
*/
|
*/
|
||||||
function requireAdmin(req, res, next) {
|
function requireAdmin(req, res, next) {
|
||||||
if (req.session && req.session.role === 'admin') {
|
if (req.authRole === 'admin') {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
res.status(403).json({ error: 'Permission denied.', code: 403 });
|
res.status(403).json({ error: 'Permission denied.', code: 403 });
|
||||||
@@ -225,6 +292,9 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|||||||
* Response: { ok: true }
|
* Response: { ok: true }
|
||||||
*/
|
*/
|
||||||
router.post('/logout', requireAuth, csrfMiddleware, (req, res) => {
|
router.post('/logout', requireAuth, csrfMiddleware, (req, res) => {
|
||||||
|
if (req.authMethod === 'api_token') {
|
||||||
|
return res.json({ ok: true });
|
||||||
|
}
|
||||||
req.session.destroy((err) => {
|
req.session.destroy((err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
log.error('Logout error:', err);
|
log.error('Logout error:', err);
|
||||||
@@ -293,13 +363,19 @@ router.get('/me', requireAuth, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const user = db.get()
|
const user = db.get()
|
||||||
.prepare('SELECT id, username, display_name, avatar_color, role FROM users WHERE id = ?')
|
.prepare('SELECT id, username, display_name, avatar_color, role FROM users WHERE id = ?')
|
||||||
.get(req.session.userId);
|
.get(req.authUserId);
|
||||||
|
|
||||||
if (!user) {
|
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 });
|
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:
|
// CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume:
|
||||||
// iOS kann den CSRF-Cookie verwerfen waehrend die Session-Cookie erhalten bleibt.
|
// 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.)
|
// /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
|
* POST /api/v1/auth/users
|
||||||
* Admin only. Erstellt neues Familienmitglied.
|
* 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 });
|
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 });
|
if (!user) return res.status(404).json({ error: 'User not found.', code: 404 });
|
||||||
|
|
||||||
const valid = await bcrypt.compare(current_password, user.password_hash);
|
const valid = await bcrypt.compare(current_password, user.password_hash);
|
||||||
if (!valid) return res.status(401).json({ error: 'Current password is incorrect.', code: 401 });
|
if (!valid) return res.status(401).json({ error: 'Current password is incorrect.', code: 401 });
|
||||||
|
|
||||||
const hash = await bcrypt.hash(new_password, 12);
|
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)
|
// Alle anderen Sessions dieses Users invalidieren (aktuelle behalten)
|
||||||
const currentSid = req.sessionID;
|
const currentSid = req.sessionID;
|
||||||
@@ -421,7 +569,7 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => {
|
|||||||
if (row.sid === currentSid) continue;
|
if (row.sid === currentSid) continue;
|
||||||
try {
|
try {
|
||||||
const sess = JSON.parse(row.sess);
|
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);
|
db.get().prepare('DELETE FROM sessions WHERE sid = ?').run(row.sid);
|
||||||
}
|
}
|
||||||
} catch { /* ignore malformed session */ }
|
} catch { /* ignore malformed session */ }
|
||||||
@@ -443,7 +591,7 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res
|
|||||||
try {
|
try {
|
||||||
const userId = parseInt(req.params.id, 10);
|
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 });
|
return res.status(400).json({ error: 'You cannot delete your own account.', code: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,17 @@ const MIGRATIONS_SQL = {
|
|||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
UNIQUE(category_key, name)
|
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
|
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
|
||||||
AFTER UPDATE ON users FOR EACH ROW
|
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;
|
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_notes_pinned ON notes(pinned);
|
||||||
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
|
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_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: `
|
2: `
|
||||||
CREATE TABLE IF NOT EXISTS sync_config (
|
CREATE TABLE IF NOT EXISTS sync_config (
|
||||||
|
|||||||
@@ -671,6 +671,26 @@ const MIGRATIONS = [
|
|||||||
GROUP BY category, subcategory;
|
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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createLogger } from './logger.js';
|
|||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { router as authRouter, sessionMiddleware, requireAuth } from './auth.js';
|
import { router as authRouter, sessionMiddleware, requireAuth } from './auth.js';
|
||||||
import { csrfMiddleware } from './middleware/csrf.js';
|
import { csrfMiddleware } from './middleware/csrf.js';
|
||||||
|
import { buildOpenApiSpec } from './openapi.js';
|
||||||
import * as googleCalendar from './services/google-calendar.js';
|
import * as googleCalendar from './services/google-calendar.js';
|
||||||
import * as appleCalendar from './services/apple-calendar.js';
|
import * as appleCalendar from './services/apple-calendar.js';
|
||||||
import * as icsSubscription from './services/ics-subscription.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 });
|
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
|
// Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz
|
||||||
app.use('/api/v1', requireAuth);
|
app.use('/api/v1', requireAuth);
|
||||||
app.use('/api/v1', csrfMiddleware);
|
app.use('/api/v1', csrfMiddleware);
|
||||||
|
|||||||
+11
-3
@@ -12,14 +12,22 @@ const isProduction = process.env.NODE_ENV === 'production';
|
|||||||
function emit(level, mod, msg, extra) {
|
function emit(level, mod, msg, extra) {
|
||||||
if (LEVELS[level] < currentLevel) return;
|
if (LEVELS[level] < currentLevel) return;
|
||||||
|
|
||||||
|
const normalizedExtra = extra instanceof Error
|
||||||
|
? {
|
||||||
|
name: extra.name,
|
||||||
|
message: extra.message,
|
||||||
|
stack: extra.stack,
|
||||||
|
}
|
||||||
|
: extra;
|
||||||
|
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
const entry = { ts: new Date().toISOString(), level, mod, msg };
|
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');
|
process.stdout.write(JSON.stringify(entry) + '\n');
|
||||||
} else {
|
} else {
|
||||||
const prefix = `[${mod}]`;
|
const prefix = `[${mod}]`;
|
||||||
if (extra !== undefined) {
|
if (normalizedExtra !== undefined) {
|
||||||
console[level === 'debug' ? 'log' : level](prefix, msg, extra);
|
console[level === 'debug' ? 'log' : level](prefix, msg, normalizedExtra);
|
||||||
} else {
|
} else {
|
||||||
console[level === 'debug' ? 'log' : level](prefix, msg);
|
console[level === 'debug' ? 'log' : level](prefix, msg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ function generateToken() {
|
|||||||
* Muss NACH requireAuth eingebunden werden.
|
* Muss NACH requireAuth eingebunden werden.
|
||||||
*/
|
*/
|
||||||
function csrfMiddleware(req, res, next) {
|
function csrfMiddleware(req, res, next) {
|
||||||
|
if (req.authMethod === 'api_token') return next();
|
||||||
|
|
||||||
// Token generieren falls noch nicht vorhanden (erste Request nach Login)
|
// Token generieren falls noch nicht vorhanden (erste Request nach Login)
|
||||||
if (!req.session.csrfToken) {
|
if (!req.session.csrfToken) {
|
||||||
req.session.csrfToken = generateToken();
|
req.session.csrfToken = generateToken();
|
||||||
|
|||||||
@@ -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 <token>`.',
|
||||||
|
},
|
||||||
|
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 };
|
||||||
@@ -6,12 +6,101 @@
|
|||||||
|
|
||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'path';
|
||||||
import * as db from '../db.js';
|
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';
|
import { str, oneOf, date as validateDate, num, rrule, collectErrors, MAX_TITLE, MAX_SHORT, MONTH_RE } from '../middleware/validate.js';
|
||||||
|
|
||||||
const log = createLogger('Budget');
|
const log = createLogger('Budget');
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
// Wiederkehrende Einträge: fehlende Instanzen für einen Monat erzeugen
|
||||||
@@ -255,6 +344,53 @@ router.get('/meta', (req, res) => {
|
|||||||
res.json({ data: loadBudgetMeta() });
|
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) => {
|
router.post('/categories', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
|
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
|
||||||
|
|||||||
+24
-3
@@ -74,6 +74,7 @@ const EXPECTED_TABLES = [
|
|||||||
'users', 'tasks', 'shopping_lists', 'shopping_items',
|
'users', 'tasks', 'shopping_lists', 'shopping_items',
|
||||||
'meals', 'meal_ingredients', 'calendar_events',
|
'meals', 'meal_ingredients', 'calendar_events',
|
||||||
'notes', 'contacts', 'budget_entries',
|
'notes', 'contacts', 'budget_entries',
|
||||||
|
'budget_categories', 'budget_subcategories', 'api_tokens',
|
||||||
];
|
];
|
||||||
|
|
||||||
EXPECTED_TABLES.forEach((table) => {
|
EXPECTED_TABLES.forEach((table) => {
|
||||||
@@ -88,9 +89,18 @@ EXPECTED_TABLES.forEach((table) => {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Test 4: Alle updated_at-Triggers vorhanden
|
// Test 4: Alle updated_at-Triggers vorhanden
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
const EXPECTED_TRIGGERS = EXPECTED_TABLES.filter((t) => t !== 'schema_migrations').map(
|
const EXPECTED_TRIGGERS = [
|
||||||
(t) => `trg_${t}_updated_at`
|
'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) => {
|
EXPECTED_TRIGGERS.forEach((trigger) => {
|
||||||
test(`Trigger "${trigger}" existiert`, () => {
|
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');
|
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
|
// Ergebnis
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user