feat(auth): implement API key authentication and management

Introduce a new API key authentication mechanism to support service-to-service
communication (e.g., Airflow, CI/CD) without browser-based JWT login.

- Add `APIKey` model and `APIKeyPrincipal` dependency for RBAC.
- Implement `require_api_key_or_jwt` dependency with environment scoping.
- Add admin CRUD endpoints for API key lifecycle management.
- Add API key management UI in System Settings.
- Update maintenance API to support explicit `environment_id` and API key auth.
- Add i18n support for API keys and connection settings.
- Include Python and Bash usage examples in the `examples/` directory.
- Update technical specifications and documentation.
This commit is contained in:
2026-05-25 11:35:27 +03:00
parent 31680a1bc9
commit 320f82ab95
39 changed files with 3359 additions and 707 deletions

View File

@@ -1,6 +1,7 @@
<!-- #region StartupEnvironmentWizard [C:3] [TYPE Component] [SEMANTICS wizard, environment, setup, onboarding, form] -->
<!-- @BRIEF Component component: components/StartupEnvironmentWizard.svelte -->
<!-- @LAYER UI -->
<!-- @RATIONALE Fixed (1) missing connections.json locale — $t.connections?.name/user/pass rendered empty spans causing floating form fields. Created en/ru locales. (2) header bg-slate-50 created visible white strip at top in darkened modal overlay; changed to bg-white, border-b provides sufficient separation. -->
<script>
/**
* @PURPOSE: Blocking startup wizard for creating the first Superset environment from zero-state screens.
@@ -114,7 +115,7 @@
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 px-4 py-6 backdrop-blur-sm">
<div class="w-full max-w-3xl overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl">
<div class="border-b border-slate-200 bg-slate-50 px-6 py-5">
<div class="border-b border-slate-200 bg-white px-6 py-5">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-600">{$t.nav?.dashboards}</p>

View File

@@ -234,6 +234,10 @@ export const api = {
const query = environmentId ? `?env_id=${encodeURIComponent(environmentId)}` : '';
return fetchApi(`/health/summary${query}`, { suppressToast: true });
},
// ── API Key Management ────────────────────────────────────
listApiKeys: () => fetchApi('/admin/api-keys/'),
createApiKey: (payload) => postApi('/admin/api-keys/', payload, { suppressToast: true }),
revokeApiKey: (keyId) => requestApi(`/admin/api-keys/${keyId}`, 'DELETE'),
};
// #endregion ApiModule

View File

@@ -0,0 +1,407 @@
<!-- #region ApiKeysTab [C:3] [TYPE Component] [SEMANTICS settings, api_keys, admin, crud] -->
<!-- @BRIEF API Key management tab — list, generate (one-time reveal), and revoke API keys. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [ApiModule] -->
<!-- @RELATION DEPENDS_ON -> [I18nModule] -->
<!-- @RELATION DEPENDS_ON -> [ToastsModule] -->
<!-- @UX_STATE Loading -> Skeleton rows, generate button disabled -->
<!-- @UX_STATE Loaded -> Keys table with revoke buttons, generate form collapsed -->
<!-- @UX_STATE Generating -> Spinner on generate button -->
<!-- @UX_STATE Revoking -> Confirm dialog visible -->
<!-- @UX_STATE Revealing -> Warning panel with raw key and copy button -->
<!-- @UX_FEEDBACK Toast on generate success/error, revoke success/error -->
<!-- @UX_RECOVERY Retry button on load error -->
<!-- @UX_REACTIVITY Props -> $props(), LocalState -> $state(keys, isLoading, error) -->
<script>
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import { api } from '$lib/api.js';
import { addToast } from '$lib/toasts';
let keys = $state([]);
let environments = $state([]);
let isLoading = $state(true);
let error = $state(null);
let showGenerateForm = $state(false);
let isGenerating = $state(false);
let revokingId = $state(null);
let revealedKey = $state(null);
// Generate form state
let formName = $state('');
let formEnvironmentId = $state('');
let formPermissions = $state([]);
let formExpiresAt = $state('');
const AVAILABLE_PERMISSIONS = [
'maintenance:start',
'maintenance:end',
'maintenance:end_all',
];
const PERMISSION_LABELS = {
'maintenance:start': 'maintenance:start',
'maintenance:end': 'maintenance:end',
'maintenance:end_all': 'maintenance:end_all',
};
onMount(() => {
loadKeys();
loadEnvironments();
});
async function loadKeys() {
isLoading = true;
error = null;
try {
keys = await api.listApiKeys();
} catch (err) {
error = err.message || 'Failed to load API keys';
console.error('[ApiKeysTab] Failed to load keys:', err);
} finally {
isLoading = false;
}
}
async function loadEnvironments() {
try {
environments = await api.getEnvironmentsList();
} catch (err) {
// Non-critical — environments are optional
console.error('[ApiKeysTab] Failed to load environments:', err);
}
}
function togglePermission(perm) {
if (formPermissions.includes(perm)) {
formPermissions = formPermissions.filter(p => p !== perm);
} else {
formPermissions = [...formPermissions, perm];
}
}
function resetForm() {
formName = '';
formEnvironmentId = '';
formPermissions = [];
formExpiresAt = '';
showGenerateForm = false;
}
async function handleGenerate() {
if (!formName.trim()) {
addToast('Name is required', 'error');
return;
}
if (formPermissions.length === 0) {
addToast('At least one permission is required', 'error');
return;
}
isGenerating = true;
try {
const payload = {
name: formName.trim(),
permissions: formPermissions,
environment_id: formEnvironmentId || null,
expires_at: formExpiresAt ? new Date(formExpiresAt).toISOString() : null,
};
const result = await api.createApiKey(payload);
revealedKey = result;
keys = [{
id: result.id,
name: result.name,
prefix: result.prefix,
environment_id: result.environment_id,
permissions: result.permissions,
active: result.active,
created_at: result.created_at,
expires_at: result.expires_at,
last_used_at: null,
}, ...keys];
resetForm();
addToast('API key generated successfully', 'success');
} catch (err) {
addToast(err.message || 'Failed to generate API key', 'error');
} finally {
isGenerating = false;
}
}
async function handleRevoke(keyId) {
revokingId = keyId;
try {
await api.revokeApiKey(keyId);
keys = keys.map(k => k.id === keyId ? { ...k, active: false } : k);
addToast('API key revoked', 'success');
} catch (err) {
addToast(err.message || 'Failed to revoke API key', 'error');
} finally {
revokingId = null;
}
}
function confirmRevoke(key) {
if (confirm('All tools using this key will get 401. Are you sure?')) {
handleRevoke(key.id);
}
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
addToast('Copied!', 'success');
} catch {
addToast('Failed to copy', 'error');
}
}
function dismissReveal() {
revealedKey = null;
}
function formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString();
}
function getStatusDot(active) {
return active
? 'bg-green-500'
: 'bg-red-500';
}
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900">{$t.api_keys?.title || 'API Keys'}</h2>
<p class="text-sm text-gray-500 mt-1">
{$t.api_keys?.description || 'Manage API keys for service-to-service authentication.'}
</p>
</div>
<button
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm"
onclick={() => { showGenerateForm = !showGenerateForm; }}
disabled={isLoading}
>
{$t.api_keys?.generate?.title || 'Generate New API Key'}
</button>
</div>
<!-- Generate Form -->
{#if showGenerateForm}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-4">
<h3 class="font-semibold text-gray-900">{$t.api_keys?.generate?.title || 'Generate New API Key'}</h3>
<!-- Name -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.api_keys?.generate?.name || 'Key Name'} *
</label>
<input
type="text"
bind:value={formName}
placeholder={$t.api_keys?.generate?.name_placeholder || 'e.g. CI/CD Pipeline'}
class="block w-full max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<!-- Environment -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.api_keys?.generate?.environment || 'Environment'}
</label>
<select
bind:value={formEnvironmentId}
class="block w-full max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">{$t.api_keys?.generate?.environment_all || 'All Environments'}</option>
{#each environments as env (env.id)}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<!-- Permissions -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.api_keys?.generate?.permissions || 'Permissions'} *
</label>
<div class="space-y-1">
{#each AVAILABLE_PERMISSIONS as perm}
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={formPermissions.includes(perm)}
onchange={() => togglePermission(perm)}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="font-mono text-xs">{perm}</span>
</label>
{/each}
</div>
</div>
<!-- Expires At -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{$t.api_keys?.generate?.expires_at || 'Expires At (optional)'}
</label>
<input
type="date"
bind:value={formExpiresAt}
class="block w-full max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<!-- Buttons -->
<div class="flex gap-2 pt-2">
<button
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm flex items-center gap-2"
onclick={handleGenerate}
disabled={isGenerating}
>
{#if isGenerating}
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
{/if}
{$t.api_keys?.generate?.generate || 'Generate Key'}
</button>
<button
class="border border-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-50 text-sm"
onclick={resetForm}
>
{$t.api_keys?.generate?.cancel || 'Cancel'}
</button>
</div>
</div>
{/if}
<!-- Revealed Key Panel -->
{#if revealedKey}
<div class="bg-amber-50 border border-amber-400 rounded-lg p-4 space-y-3">
<div class="flex items-start gap-2">
<span class="text-amber-600 text-lg" aria-hidden="true">⚠️</span>
<div>
<p class="text-sm font-medium text-amber-800">
{$t.api_keys?.reveal?.warning || 'This is the ONLY time the key will be shown. Copy it now.'}
</p>
<pre class="mt-2 p-3 bg-amber-100 rounded text-sm font-mono break-all select-all">{revealedKey.raw_key}</pre>
</div>
</div>
<div class="flex gap-2">
<button
class="bg-amber-600 text-white px-4 py-2 rounded-md hover:bg-amber-700 text-sm"
onclick={() => copyToClipboard(revealedKey.raw_key)}
>
{$t.api_keys?.reveal?.copy || 'Copy to Clipboard'}
</button>
<button
class="border border-amber-300 text-amber-700 px-4 py-2 rounded-md hover:bg-amber-100 text-sm"
onclick={dismissReveal}
>
{$t.api_keys?.reveal?.dismiss || 'Dismiss'}
</button>
</div>
</div>
{/if}
<!-- Error State -->
{#if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded flex items-center justify-between">
<span class="text-sm">{error}</span>
<button
class="px-3 py-1 bg-red-600 text-white rounded text-sm hover:bg-red-700"
onclick={loadKeys}
>
Retry
</button>
</div>
{:else if isLoading}
<!-- Loading State -->
<div class="space-y-2">
{#each [1, 2, 3] as _}
<div class="h-12 animate-pulse bg-gray-200 rounded"></div>
{/each}
</div>
{:else if keys.length === 0}
<!-- Empty State -->
<div class="text-center py-8 text-gray-500 text-sm border-2 border-dashed border-gray-200 rounded-lg">
{$t.api_keys?.empty || 'No API keys configured.'}
</div>
{:else}
<!-- Keys Table -->
<div class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.name || 'Name'}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.prefix || 'Prefix'}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.environment || 'Environment'}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.permissions || 'Permissions'}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.status || 'Status'}
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.api_keys?.table?.actions || 'Actions'}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each keys as key (key.id)}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{key.name}</td>
<td class="px-4 py-3 text-sm font-mono text-gray-500">{key.prefix}...</td>
<td class="px-4 py-3 text-sm text-gray-500">{key.environment_id || '—'}</td>
<td class="px-4 py-3 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
{#each key.permissions as perm}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">{perm}</span>
{/each}
</div>
</td>
<td class="px-4 py-3 text-sm">
<span class="flex items-center gap-1.5">
<span class="inline-block w-2.5 h-2.5 rounded-full {getStatusDot(key.active)}" aria-hidden="true"></span>
{key.active
? ($t.api_keys?.status?.active || 'Active')
: ($t.api_keys?.status?.revoked || 'Revoked')}
</span>
</td>
<td class="px-4 py-3 text-sm text-right">
{#if key.active}
<button
class="text-red-600 hover:text-red-800 disabled:opacity-50 text-xs font-medium"
onclick={() => confirmRevoke(key)}
disabled={revokingId === key.id}
>
{#if revokingId === key.id}
<span class="inline-block animate-pulse">Revoking...</span>
{:else}
{$t.api_keys?.revoke?.revoke || 'Revoke'}
{/if}
</button>
{:else}
<span class="text-gray-400 text-xs"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- #endregion ApiKeysTab -->

View File

@@ -18,6 +18,7 @@
import { writable, derived } from 'svelte/store';
// Domain-split locale files (INV_7: each file < 400 lines)
import enApiKeys from './locales/en/api-keys.json';
import enCommon from './locales/en/common.json';
import enNav from './locales/en/nav.json';
import enHealth from './locales/en/health.json';
@@ -40,7 +41,9 @@ import enMapper from './locales/en/mapper.json';
import enMigration from './locales/en/migration.json';
import enStorage from './locales/en/storage.json';
import enMaintenance from './locales/en/maintenance.json';
import enConnections from './locales/en/connections.json';
import ruApiKeys from './locales/ru/api-keys.json';
import ruCommon from './locales/ru/common.json';
import ruNav from './locales/ru/nav.json';
import ruHealth from './locales/ru/health.json';
@@ -63,9 +66,11 @@ import ruMapper from './locales/ru/mapper.json';
import ruMigration from './locales/ru/migration.json';
import ruStorage from './locales/ru/storage.json';
import ruMaintenance from './locales/ru/maintenance.json';
import ruConnections from './locales/ru/connections.json';
// Merge domain objects into locale dictionaries
const en = {
api_keys: enApiKeys,
common: enCommon,
nav: enNav,
health: enHealth,
@@ -88,9 +93,11 @@ const en = {
migration: enMigration,
storage: enStorage,
maintenance: enMaintenance,
connections: enConnections,
};
const ru = {
api_keys: ruApiKeys,
common: ruCommon,
nav: ruNav,
health: ruHealth,
@@ -113,6 +120,7 @@ const ru = {
migration: ruMigration,
storage: ruStorage,
maintenance: ruMaintenance,
connections: ruConnections,
};
const translations = { ru, en };

View File

@@ -0,0 +1,47 @@
{
"title": "API Keys",
"description": "Manage API keys for service-to-service authentication. Keys are used by external tools (CI/CD, Airflow, etc.) to authenticate with the API.",
"table": {
"name": "Name",
"prefix": "Prefix",
"environment": "Environment",
"permissions": "Permissions",
"status": "Status",
"actions": "Actions"
},
"status": {
"active": "Active",
"revoked": "Revoked"
},
"generate": {
"title": "Generate New API Key",
"name": "Key Name",
"name_placeholder": "e.g. CI/CD Pipeline",
"name_required": "Name is required",
"environment": "Environment",
"environment_all": "All Environments",
"permissions": "Permissions",
"permissions_required": "At least one permission required",
"expires_at": "Expires At (optional)",
"cancel": "Cancel",
"generate": "Generate Key",
"generating": "Generating..."
},
"reveal": {
"warning": "This is the ONLY time the key will be shown. Copy it now.",
"copy": "Copy to Clipboard",
"copied": "Copied!",
"dismiss": "Dismiss"
},
"revoke": {
"confirm": "All tools using this key will get 401. Are you sure?",
"revoke": "Revoke",
"revoking": "Revoking..."
},
"empty": "No API keys configured.",
"loading": "Loading API keys...",
"load_failed": "Failed to load API keys.",
"generate_failed": "Failed to generate API key.",
"revoke_failed": "Failed to revoke API key: {error}",
"saved": "API key generated successfully"
}

View File

@@ -0,0 +1,5 @@
{
"name": "Connection name",
"user": "Username",
"pass": "Password"
}

View File

@@ -0,0 +1,47 @@
{
"title": "API ключи",
"description": "Управление API ключами для аутентификации сервис-к-сервису. Ключи используются внешними инструментами (CI/CD, Airflow и т.д.) для аутентификации в API.",
"table": {
"name": "Название",
"prefix": "Префикс",
"environment": "Окружение",
"permissions": "Права",
"status": "Статус",
"actions": "Действия"
},
"status": {
"active": "Активен",
"revoked": "Отозван"
},
"generate": {
"title": "Создать новый API ключ",
"name": "Название ключа",
"name_placeholder": "например, CI/CD Pipeline",
"name_required": "Название обязательно",
"environment": "Окружение",
"environment_all": "Все окружения",
"permissions": "Права доступа",
"permissions_required": "Необходимо хотя бы одно право",
"expires_at": "Срок действия (опционально)",
"cancel": "Отмена",
"generate": "Создать ключ",
"generating": "Создание..."
},
"reveal": {
"warning": "Это ЕДИНСТВЕННЫЙ раз, когда ключ будет показан. Скопируйте его сейчас.",
"copy": "Копировать в буфер",
"copied": "Скопировано!",
"dismiss": "Закрыть"
},
"revoke": {
"confirm": "Все инструменты, использующие этот ключ, получат 401. Вы уверены?",
"revoke": "Отозвать",
"revoking": "Отзыв..."
},
"empty": "API ключи не настроены.",
"loading": "Загрузка API ключей...",
"load_failed": "Не удалось загрузить API ключи.",
"generate_failed": "Не удалось создать API ключ.",
"revoke_failed": "Не удалось отозвать API ключ: {error}",
"saved": "API ключ успешно создан"
}

View File

@@ -0,0 +1,5 @@
{
"name": "Название подключения",
"user": "Имя пользователя",
"pass": "Пароль"
}

View File

@@ -1,9 +1,10 @@
<!-- #region SystemSettings [C:2] [TYPE Component] [SEMANTICS settings,system,timezone,config] -->
<!-- @BRIEF System settings tab: timezone configuration. -->
<!-- #region SystemSettings [C:3] [TYPE Component] [SEMANTICS settings,system,timezone,config] -->
<!-- @BRIEF System settings tab: timezone configuration and API key management. -->
<!-- @PRE: settings object is provided with app_timezone field. -->
<!-- @POST: User can select the application timezone from a dropdown of common IANA timezones. -->
<!-- @POST: User can select the application timezone and manage API keys. -->
<script>
import { t, locale } from "$lib/i18n";
import ApiKeysTab from "$lib/components/settings/ApiKeysTab.svelte";
let { settings = $bindable(), onSave } = $props();
@@ -34,51 +35,59 @@
];
</script>
<div>
<h2 class="text-xl font-bold mb-4">{$t.settings?.system || "System"}</h2>
<p class="text-gray-600 mb-6">{$t.settings?.system_description || "Application-wide system settings including timezone."}</p>
<div class="space-y-8">
<!-- Timezone Section -->
<div>
<h2 class="text-xl font-bold mb-4">{$t.settings?.system || "System"}</h2>
<p class="text-gray-600 mb-6">{$t.settings?.system_description || "Application-wide system settings including timezone."}</p>
<div class="bg-gray-50 p-6 rounded-lg border border-gray-200">
<div class="space-y-6">
<!-- Timezone selector -->
<div>
<label for="app-timezone" class="block text-sm font-medium text-gray-900 mb-1">
{$t.settings?.app_timezone || "Timezone"}
</label>
<p class="text-xs text-gray-500 mb-2">
{$t.settings?.app_timezone_hint || "All schedule times and timestamps will be displayed and executed in this timezone."}
</p>
<select
id="app-timezone"
bind:value={settings.app_timezone}
class="block w-full max-w-md pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
<div class="bg-gray-50 p-6 rounded-lg border border-gray-200">
<div class="space-y-6">
<!-- Timezone selector -->
<div>
<label for="app-timezone" class="block text-sm font-medium text-gray-900 mb-1">
{$t.settings?.app_timezone || "Timezone"}
</label>
<p class="text-xs text-gray-500 mb-2">
{$t.settings?.app_timezone_hint || "All schedule times and timestamps will be displayed and executed in this timezone."}
</p>
<select
id="app-timezone"
bind:value={settings.app_timezone}
class="block w-full max-w-md pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
>
{#each COMMON_TIMEZONES as tz (tz.value)}
<option value={tz.value}>{tz.label}</option>
{/each}
</select>
</div>
<!-- Current time preview -->
<div class="flex items-center gap-2 text-sm text-gray-500 border-t border-gray-200 pt-4">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{settings.app_timezone}:</span>
<span class="font-mono text-gray-700">
{new Date().toLocaleString($locale === 'en' ? 'en-US' : 'ru-RU', { timeZone: settings.app_timezone || 'Europe/Moscow' })}
</span>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
onclick={onSave}
class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-hover"
>
{#each COMMON_TIMEZONES as tz (tz.value)}
<option value={tz.value}>{tz.label}</option>
{/each}
</select>
</div>
<!-- Current time preview -->
<div class="flex items-center gap-2 text-sm text-gray-500 border-t border-gray-200 pt-4">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{settings.app_timezone}:</span>
<span class="font-mono text-gray-700">
{new Date().toLocaleString($locale === 'en' ? 'en-US' : 'ru-RU', { timeZone: settings.app_timezone || 'Europe/Moscow' })}
</span>
{$t.settings?.save_system || $t.settings?.save_logging || "Save"}
</button>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
onclick={onSave}
class="bg-primary text-white px-4 py-2 rounded hover:bg-primary-hover"
>
{$t.settings?.save_system || $t.settings?.save_logging || "Save"}
</button>
</div>
<!-- API Keys Section -->
<div class="border-t border-gray-200 pt-8">
<ApiKeysTab />
</div>
</div>
<!-- #endregion SystemSettings -->

View File

@@ -17,6 +17,7 @@ export const SETTINGS_TABS = [
"storage",
"features",
"automation",
"system",
];
export const DEFAULT_LLM_PROMPTS = {