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": "العملة",
|
||||
"currencyHint": "تحدد العملة المستخدمة في منطقة الميزانية بأكملها.",
|
||||
"currencySaved": "تم حفظ العملة.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "اشتراكات ICS",
|
||||
"add": "إضافة اشتراك",
|
||||
|
||||
@@ -630,6 +630,28 @@
|
||||
"currencyLabel": "Währung",
|
||||
"currencyHint": "Legt die Währung für den gesamten Budget-Bereich fest.",
|
||||
"currencySaved": "Währung gespeichert.",
|
||||
"apiTokensTitle": "API-Tokens",
|
||||
"apiTokensCardTitle": "Zugriffstoken",
|
||||
"apiTokensHint": "Erstelle API-Tokens für externe Integrationen. Der vollständige Token wird nach der Erstellung nur einmal angezeigt.",
|
||||
"apiTokenNameLabel": "Tokenname",
|
||||
"apiTokenExpiresLabel": "Ablaufdatum",
|
||||
"apiTokenExpiresHint": "Leer lassen, um einen Token ohne Ablaufdatum zu erstellen.",
|
||||
"apiTokenCreatedLabel": "Neuer API-Token",
|
||||
"apiTokenCreatedHint": "Speichere diesen Token sicher. Er kann nicht erneut angezeigt werden.",
|
||||
"apiTokenCreate": "Token erstellen",
|
||||
"apiTokenInvalidExpiration": "Bitte gib ein gültiges Ablaufdatum ein.",
|
||||
"apiTokenCreatedToast": "API-Token erstellt.",
|
||||
"apiTokenRevokedToast": "API-Token widerrufen.",
|
||||
"apiTokenRevokeConfirm": "API-Token \"{{name}}\" widerrufen?",
|
||||
"apiTokenRevoke": "Token widerrufen",
|
||||
"apiTokenRevoked": "Widerrufen",
|
||||
"apiTokenExpired": "Abgelaufen",
|
||||
"apiTokenActive": "Aktiv",
|
||||
"apiTokenPrefix": "Präfix",
|
||||
"apiTokenExpires": "Läuft ab",
|
||||
"apiTokenNeverExpires": "Kein Ablaufdatum",
|
||||
"apiTokenLastUsed": "Zuletzt verwendet",
|
||||
"apiTokenNeverUsed": "Nie verwendet",
|
||||
"ics": {
|
||||
"title": "ICS-Abonnements",
|
||||
"add": "Abonnement hinzufügen",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Νόμισμα",
|
||||
"currencyHint": "Ορίζει το νόμισμα για ολόκληρη την ενότητα προϋπολογισμού.",
|
||||
"currencySaved": "Το νόμισμα αποθηκεύτηκε.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "Συνδρομές ICS",
|
||||
"add": "Προσθήκη συνδρομής",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Currency",
|
||||
"currencyHint": "Sets the currency used throughout the budget section.",
|
||||
"currencySaved": "Currency saved.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "ICS Subscriptions",
|
||||
"add": "Add subscription",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Moneda",
|
||||
"currencyHint": "Establece la moneda para toda la sección de presupuesto.",
|
||||
"currencySaved": "Moneda guardada.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "Suscripciones ICS",
|
||||
"add": "Añadir suscripción",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Devise",
|
||||
"currencyHint": "Définit la devise utilisée dans toute la section budget.",
|
||||
"currencySaved": "Devise enregistrée.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "Abonnements ICS",
|
||||
"add": "Ajouter un abonnement",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "मुद्रा",
|
||||
"currencyHint": "पूरे बजट अनुभाग में उपयोग की जाने वाली मुद्रा सेट करता है।",
|
||||
"currencySaved": "मुद्रा सहेजी गई।",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "ICS सदस्यताएं",
|
||||
"add": "सदस्यता जोड़ें",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Valuta",
|
||||
"currencyHint": "Imposta la valuta utilizzata in tutta la sezione budget.",
|
||||
"currencySaved": "Valuta salvata.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "Abbonamenti ICS",
|
||||
"add": "Aggiungi abbonamento",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "通貨",
|
||||
"currencyHint": "家計全体で使用する通貨を設定します。",
|
||||
"currencySaved": "通貨を保存しました。",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "ICSサブスクリプション",
|
||||
"add": "サブスクリプションを追加",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Moeda",
|
||||
"currencyHint": "Define a moeda usada em toda a área de orçamento.",
|
||||
"currencySaved": "Moeda salva.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "Assinaturas ICS",
|
||||
"add": "Adicionar assinatura",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Валюта",
|
||||
"currencyHint": "Устанавливает валюту для всего раздела бюджета.",
|
||||
"currencySaved": "Валюта сохранена.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "ICS-подписки",
|
||||
"add": "Добавить подписку",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Valuta",
|
||||
"currencyHint": "Ställer in valutan som används i hela budgetavsnittet.",
|
||||
"currencySaved": "Valuta sparad.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "ICS-prenumerationer",
|
||||
"add": "Lägg till prenumeration",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Para birimi",
|
||||
"currencyHint": "Bütçe bölümünde kullanılan para birimini belirler.",
|
||||
"currencySaved": "Para birimi kaydedildi.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "ICS Abonelikleri",
|
||||
"add": "Abonelik ekle",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "Валюта",
|
||||
"currencyHint": "Встановлює валюту, що використовується в розділі бюджету.",
|
||||
"currencySaved": "Валюту збережено.",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "ICS-підписки",
|
||||
"add": "Додати підписку",
|
||||
|
||||
@@ -624,6 +624,28 @@
|
||||
"currencyLabel": "货币",
|
||||
"currencyHint": "设置整个预算区域使用的货币。",
|
||||
"currencySaved": "货币已保存。",
|
||||
"apiTokensTitle": "API Tokens",
|
||||
"apiTokensCardTitle": "Access Tokens",
|
||||
"apiTokensHint": "Create API tokens for external integrations. The full token is shown only once after creation.",
|
||||
"apiTokenNameLabel": "Token name",
|
||||
"apiTokenExpiresLabel": "Expiration date",
|
||||
"apiTokenExpiresHint": "Leave empty to create a token without expiration.",
|
||||
"apiTokenCreatedLabel": "New API token",
|
||||
"apiTokenCreatedHint": "Store this token securely. It cannot be shown again.",
|
||||
"apiTokenCreate": "Create token",
|
||||
"apiTokenInvalidExpiration": "Please enter a valid expiration date.",
|
||||
"apiTokenCreatedToast": "API token created.",
|
||||
"apiTokenRevokedToast": "API token revoked.",
|
||||
"apiTokenRevokeConfirm": "Revoke API token \"{{name}}\"?",
|
||||
"apiTokenRevoke": "Revoke token",
|
||||
"apiTokenRevoked": "Revoked",
|
||||
"apiTokenExpired": "Expired",
|
||||
"apiTokenActive": "Active",
|
||||
"apiTokenPrefix": "Prefix",
|
||||
"apiTokenExpires": "Expires",
|
||||
"apiTokenNeverExpires": "No expiration",
|
||||
"apiTokenLastUsed": "Last used",
|
||||
"apiTokenNeverUsed": "Never used",
|
||||
"ics": {
|
||||
"title": "ICS 订阅",
|
||||
"add": "添加订阅",
|
||||
|
||||
+139
-3
@@ -59,15 +59,17 @@ export async function render(container, { user }) {
|
||||
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' };
|
||||
let categories = [];
|
||||
let icsSubscriptions = [];
|
||||
let apiTokens = [];
|
||||
|
||||
try {
|
||||
const [usersRes, gStatus, aStatus, prefsRes, catsRes, icsRes] = await Promise.allSettled([
|
||||
const [usersRes, gStatus, aStatus, prefsRes, catsRes, icsRes, apiTokensRes] = await Promise.allSettled([
|
||||
user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
|
||||
api.get('/calendar/google/status'),
|
||||
api.get('/calendar/apple/status'),
|
||||
api.get('/preferences'),
|
||||
api.get('/shopping/categories'),
|
||||
api.get('/calendar/subscriptions'),
|
||||
user.role === 'admin' ? api.get('/auth/api-tokens') : Promise.resolve({ data: [] }),
|
||||
]);
|
||||
if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
|
||||
if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
|
||||
@@ -75,6 +77,7 @@ export async function render(container, { user }) {
|
||||
if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs;
|
||||
if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? [];
|
||||
if (icsRes.status === 'fulfilled') icsSubscriptions = icsRes.value.data ?? [];
|
||||
if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? [];
|
||||
} catch (_) { /* non-critical */ }
|
||||
|
||||
const googleStatusText = googleStatus.connected
|
||||
@@ -364,6 +367,35 @@ export async function render(container, { user }) {
|
||||
</section>
|
||||
|
||||
${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">
|
||||
<h2 class="settings-section__title">${t('settings.sectionFamily')}</h2>
|
||||
<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
|
||||
// --------------------------------------------------------
|
||||
|
||||
function bindEvents(container, user, categories, icsSubscriptions) {
|
||||
function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
|
||||
bindTabEvents(container);
|
||||
bindCategoryEvents(container);
|
||||
bindIcsEvents(container, user, icsSubscriptions);
|
||||
bindApiTokenEvents(container, apiTokens);
|
||||
// Theme-Toggle
|
||||
const themeToggle = container.querySelector('#theme-toggle');
|
||||
if (themeToggle) {
|
||||
@@ -722,6 +755,109 @@ function bindDeleteButtons(container, user) {
|
||||
});
|
||||
}
|
||||
|
||||
function apiTokenHtml(token) {
|
||||
const status = token.revoked_at
|
||||
? t('settings.apiTokenRevoked')
|
||||
: token.expires_at && new Date(token.expires_at).getTime() <= Date.now()
|
||||
? t('settings.apiTokenExpired')
|
||||
: t('settings.apiTokenActive');
|
||||
const meta = [
|
||||
`${t('settings.apiTokenPrefix')}: ${token.token_prefix}...`,
|
||||
token.expires_at ? `${t('settings.apiTokenExpires')}: ${formatDateTime(token.expires_at)}` : t('settings.apiTokenNeverExpires'),
|
||||
token.last_used_at ? `${t('settings.apiTokenLastUsed')}: ${formatDateTime(token.last_used_at)}` : t('settings.apiTokenNeverUsed'),
|
||||
status,
|
||||
].join(' · ');
|
||||
|
||||
return `
|
||||
<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
|
||||
|
||||
@@ -321,6 +321,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-token-output {
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
Theme-Toggle
|
||||
-------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user