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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
407
frontend/src/lib/components/settings/ApiKeysTab.svelte
Normal file
407
frontend/src/lib/components/settings/ApiKeysTab.svelte
Normal 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 -->
|
||||
@@ -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 };
|
||||
|
||||
47
frontend/src/lib/i18n/locales/en/api-keys.json
Normal file
47
frontend/src/lib/i18n/locales/en/api-keys.json
Normal 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"
|
||||
}
|
||||
5
frontend/src/lib/i18n/locales/en/connections.json
Normal file
5
frontend/src/lib/i18n/locales/en/connections.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Connection name",
|
||||
"user": "Username",
|
||||
"pass": "Password"
|
||||
}
|
||||
47
frontend/src/lib/i18n/locales/ru/api-keys.json
Normal file
47
frontend/src/lib/i18n/locales/ru/api-keys.json
Normal 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 ключ успешно создан"
|
||||
}
|
||||
5
frontend/src/lib/i18n/locales/ru/connections.json
Normal file
5
frontend/src/lib/i18n/locales/ru/connections.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Название подключения",
|
||||
"user": "Имя пользователя",
|
||||
"pass": "Пароль"
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
@@ -17,6 +17,7 @@ export const SETTINGS_TABS = [
|
||||
"storage",
|
||||
"features",
|
||||
"automation",
|
||||
"system",
|
||||
];
|
||||
|
||||
export const DEFAULT_LLM_PROMPTS = {
|
||||
|
||||
Reference in New Issue
Block a user