Adding Rest API token with expiration and revocation options.

This commit is contained in:
Rafael Foster
2026-04-25 12:22:58 -03:00
parent bdd6e559d5
commit f43dee4cc0
22 changed files with 681 additions and 6 deletions
+22
View File
@@ -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": "إضافة اشتراك",
+22
View File
@@ -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": "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-Abonnements", "title": "ICS-Abonnements",
"add": "Abonnement hinzufügen", "add": "Abonnement hinzufügen",
+22
View File
@@ -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": "Προσθήκη συνδρομής",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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": "सदस्यता जोड़ें",
+22
View File
@@ -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",
+22
View File
@@ -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": "サブスクリプションを追加",
+22
View File
@@ -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",
+22
View File
@@ -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": "Добавить подписку",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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": "Додати підписку",
+22
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
-------------------------------------------------------- */ -------------------------------------------------------- */
+146
View File
@@ -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.session = {
userId: apiToken.created_by,
role: apiToken.role,
};
return next();
}
if (req.session && req.session.userId) { if (req.session && req.session.userId) {
req.authMethod = 'session';
return next(); return next();
} }
res.status(401).json({ error: 'Not authenticated.', code: 401 }); res.status(401).json({ error: 'Not authenticated.', code: 401 });
@@ -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);
@@ -300,6 +370,10 @@ router.get('/me', requireAuth, (req, res) => {
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 +411,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.session.userId, 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.
+13
View File
@@ -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 (
+20
View File
@@ -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);
`,
},
]; ];
/** /**
+2
View File
@@ -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();
+24 -3
View File
@@ -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
// -------------------------------------------------------- // --------------------------------------------------------