feat(api): implement API key management and authentication enhancements
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 28s
CI / go (push) Failing after 24s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 28s
CI / go (push) Failing after 24s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Added endpoints for managing API keys, including creation, retrieval, updating, and revocation. - Introduced a new Auth session endpoint to retrieve current tenant and role information. - Updated the authentication middleware to support API key-based authentication and track last used timestamps. - Enhanced documentation to reflect new API key functionalities and usage guidelines. - Improved logging for demo authentication scenarios.
This commit is contained in:
@@ -253,3 +253,33 @@ export type JobsResponse = Page<JobRow>;
|
||||
|
||||
// ---- Settings ----
|
||||
export type AppSettings = Record<string, unknown>;
|
||||
|
||||
// ---- Auth / API keys ----
|
||||
export type AuthSession = {
|
||||
tenant_id: string;
|
||||
role: 'viewer' | 'editor' | 'operator' | 'node';
|
||||
};
|
||||
|
||||
export type ApiKeyRole = AuthSession['role'];
|
||||
|
||||
export type ApiKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: ApiKeyRole;
|
||||
prefix: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string | null;
|
||||
revoked_at: string | null;
|
||||
last_used_at: string | null;
|
||||
};
|
||||
|
||||
export type ApiKeysResponse = Page<ApiKey>;
|
||||
|
||||
export type ApiKeyCreate = {
|
||||
name: string;
|
||||
role: ApiKeyRole;
|
||||
expires_at?: string | null;
|
||||
};
|
||||
|
||||
export type ApiKeyCreated = ApiKey & { token: string };
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '$lib/api/types.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '$lib/ui/core/dialog/index.js';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
|
||||
type Props = {
|
||||
items: ApiKey[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
||||
|
||||
const roleOptions: Array<{ value: ApiKeyRole; label: string }> = [
|
||||
{ value: 'viewer', label: 'viewer — только чтение' },
|
||||
{ value: 'editor', label: 'editor — CRUD без apply' },
|
||||
{ value: 'operator', label: 'operator — полный доступ' },
|
||||
{ value: 'node', label: 'node — только API ноды' }
|
||||
];
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let tokenDialogOpen = $state(false);
|
||||
let revealedToken = $state('');
|
||||
let form = $state<ApiKeyCreate>({ name: '', role: 'editor' });
|
||||
let expiresLocal = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
const columns = [
|
||||
{ id: 'name', label: 'Имя', sortable: true, sortValue: (k: ApiKey) => k.name },
|
||||
{ id: 'role', label: 'Роль', sortable: true, sortValue: (k: ApiKey) => k.role },
|
||||
{ id: 'prefix', label: 'Префикс', sortable: true, sortValue: (k: ApiKey) => k.prefix },
|
||||
{
|
||||
id: 'revoked',
|
||||
label: 'Статус',
|
||||
sortable: true,
|
||||
sortValue: (k: ApiKey) => (k.revoked_at ? 1 : 0)
|
||||
},
|
||||
{ id: 'actions', label: '', class: 'w-24' }
|
||||
] as const;
|
||||
|
||||
function openCreate() {
|
||||
form = { name: '', role: 'editor' };
|
||||
expiresLocal = '';
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function showToken(created: ApiKeyCreated) {
|
||||
revealedToken = created.token;
|
||||
tokenDialogOpen = true;
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(revealedToken);
|
||||
notify.success('Скопировано');
|
||||
} catch {
|
||||
notify.error('Не удалось скопировать');
|
||||
}
|
||||
}
|
||||
|
||||
function requestRevoke(k: ApiKey) {
|
||||
if (k.revoked_at) return;
|
||||
void confirm({
|
||||
title: 'Отозвать API-ключ?',
|
||||
description: `${k.name} (${k.prefix}…)`,
|
||||
confirmLabel: 'Отозвать',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await apiMutate(`/v1/api-keys/${k.id}`, 'DELETE', undefined, { idempotent: false });
|
||||
notify.success('Ключ отозван');
|
||||
await onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function requestRotate(k: ApiKey) {
|
||||
if (k.revoked_at) return;
|
||||
void confirm({
|
||||
title: 'Ротировать ключ?',
|
||||
description: 'Старый токен перестанет работать сразу.',
|
||||
confirmLabel: 'Ротировать',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const out = await apiMutate<ApiKeyCreated>(
|
||||
`/v1/api-keys/${k.id}/rotate`,
|
||||
'POST',
|
||||
undefined,
|
||||
{ idempotent: false }
|
||||
);
|
||||
notify.success('Ключ обновлён');
|
||||
showToken(out);
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim()) {
|
||||
notify.error('Укажите имя');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const body: ApiKeyCreate = {
|
||||
name: form.name.trim(),
|
||||
role: form.role
|
||||
};
|
||||
if (expiresLocal.trim()) {
|
||||
const d = new Date(expiresLocal);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
notify.error('Некорректная дата истечения');
|
||||
return;
|
||||
}
|
||||
body.expires_at = d.toISOString();
|
||||
}
|
||||
const created = await apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body);
|
||||
notify.success('Ключ создан');
|
||||
dialogOpen = false;
|
||||
showToken(created);
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">API-ключи</CardTitle>
|
||||
<CardDescription>
|
||||
Управление ключами tenant. Полный токен показывается только при создании и ротации.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onclick={() => onRefresh()} disabled={loading}>
|
||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" onclick={openCreate}><Plus />Создать</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...columns]}
|
||||
rows={items}
|
||||
rowKey={(k) => k.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет ключей"
|
||||
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
|
||||
>
|
||||
{#snippet cell({ row: k, column })}
|
||||
{#if column.id === 'name'}
|
||||
<span class="font-medium">{k.name}</span>
|
||||
{:else if column.id === 'role'}
|
||||
<span class="font-mono text-sm">{k.role}</span>
|
||||
{:else if column.id === 'prefix'}
|
||||
<span class="font-mono text-xs text-muted-foreground">{k.prefix}…</span>
|
||||
{:else if column.id === 'revoked'}
|
||||
{#if k.revoked_at}
|
||||
<span class="text-sm text-destructive">отозван</span>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground">активен</span>
|
||||
{/if}
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title="Ротировать"
|
||||
disabled={!!k.revoked_at}
|
||||
onclick={() => requestRotate(k)}
|
||||
>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
disabled={!!k.revoked_at}
|
||||
onclick={() => requestRevoke(k)}
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Новый API-ключ</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<FormField label="Имя" id="key-name" required>
|
||||
<AppInput id="key-name" bind:value={form.name} placeholder="CI / оператор UI" />
|
||||
</FormField>
|
||||
<FormField label="Роль" id="key-role" required>
|
||||
<Select
|
||||
type="single"
|
||||
value={form.role}
|
||||
onValueChange={(v) => (form.role = v as ApiKeyRole)}
|
||||
>
|
||||
<SelectTrigger id="key-role" class="w-full">
|
||||
{roleOptions.find((o) => o.value === form.role)?.label ?? form.role}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each roleOptions as opt (opt.value)}
|
||||
<SelectItem value={opt.value} label={opt.label}>{opt.label}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Истекает (опционально)" id="key-expires">
|
||||
<AppInput id="key-expires" type="datetime-local" bind:value={expiresLocal} />
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog bind:open={tokenDialogOpen}>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Сохраните токен</DialogTitle>
|
||||
<DialogDescription
|
||||
>Он больше не будет показан. Скопируйте в безопасное хранилище.</DialogDescription
|
||||
>
|
||||
</DialogHeader>
|
||||
<div class="rounded-md border bg-muted/40 p-3 font-mono text-xs break-all">{revealedToken}</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={copyToken}><Copy />Копировать</Button>
|
||||
<Button onclick={() => (tokenDialogOpen = false)}>Готово</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -7,6 +7,7 @@ import Gauge from '@lucide/svelte/icons/gauge';
|
||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||
import Network from '@lucide/svelte/icons/network';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Shield from '@lucide/svelte/icons/shield';
|
||||
export type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
@@ -23,4 +24,7 @@ export const mainNav: NavItem[] = [
|
||||
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
|
||||
];
|
||||
|
||||
export const bottomNav: NavItem[] = [{ href: '/settings', label: 'Настройки', icon: Settings }];
|
||||
export const bottomNav: NavItem[] = [
|
||||
{ href: '/access', label: 'Права доступа', icon: Shield },
|
||||
{ href: '/settings', label: 'Настройки', icon: Settings }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { apiJSON } from '$lib/api/client.js';
|
||||
import type { ApiKey, ApiKeysResponse, AuthSession } from '$lib/api/types.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import AccessApiKeysCard from '$lib/components/access/AccessApiKeysCard.svelte';
|
||||
import Shield from '@lucide/svelte/icons/shield';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
|
||||
let session = $state<AuthSession | null>(null);
|
||||
let apiKeys = $state<ApiKey[]>([]);
|
||||
let keysLoading = $state(false);
|
||||
let keysInitial = $state(true);
|
||||
let keysError = $state<string | null>(null);
|
||||
|
||||
const isOperator = $derived(session?.role === 'operator');
|
||||
|
||||
async function loadSession() {
|
||||
try {
|
||||
session = await apiJSON<AuthSession>('/v1/auth/session');
|
||||
} catch {
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeys() {
|
||||
keysLoading = true;
|
||||
keysError = null;
|
||||
try {
|
||||
const page = await apiJSON<ApiKeysResponse>('/v1/api-keys?limit=500');
|
||||
apiKeys = page.items ?? [];
|
||||
} catch (e) {
|
||||
apiKeys = [];
|
||||
keysError = e instanceof Error ? e.message : 'Ошибка загрузки';
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
keysLoading = false;
|
||||
keysInitial = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
await loadSession();
|
||||
if (session?.role === 'operator') await loadApiKeys();
|
||||
else keysInitial = false;
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex max-w-4xl flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Права доступа"
|
||||
description="API-ключи control plane и текущая сессия Bearer-токена."
|
||||
icon={Shield}
|
||||
iconClass="bg-primary/10 text-primary"
|
||||
/>
|
||||
|
||||
{#if session}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Текущая сессия</CardTitle>
|
||||
<CardDescription>Tenant и роль ключа, с которым открыта панель.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Tenant</p>
|
||||
<p class="font-mono text-xs break-all">{session.tenant_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Роль</p>
|
||||
<p class="font-mono">{session.role}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if isOperator}
|
||||
<AccessApiKeysCard
|
||||
items={apiKeys}
|
||||
loading={keysLoading}
|
||||
initialLoading={keysInitial}
|
||||
error={keysError}
|
||||
onRefresh={loadApiKeys}
|
||||
/>
|
||||
{:else if session}
|
||||
<Card>
|
||||
<CardContent class="py-6 text-sm text-muted-foreground">
|
||||
Управление API-ключами доступно только роли <strong>operator</strong>. Текущая роль:
|
||||
<span class="font-mono">{session.role}</span>. Для выдачи ключей войдите с operator-ключом
|
||||
или создайте ключ через API / переменную <code class="text-xs">EVOBGP_API_KEYS</code>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<CardContent class="py-6 text-sm text-muted-foreground">
|
||||
Не удалось определить сессию. Укажите Bearer-токен в
|
||||
<a href={resolve('/settings')} class="text-primary underline-offset-4 hover:underline"
|
||||
>настройках</a
|
||||
>
|
||||
интерфейса.
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { TOKEN_STORAGE_KEY } from '$lib/api/client.js';
|
||||
import { themeState } from '$lib/theme-preferences.svelte.js';
|
||||
@@ -53,23 +54,24 @@
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Параметры браузера и подключения к API."
|
||||
description="Параметры интерфейса и подключения браузера к API."
|
||||
icon={SettingsIcon}
|
||||
iconClass="bg-muted text-muted-foreground"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API-ключ</CardTitle>
|
||||
<CardTitle>Подключение к API</CardTitle>
|
||||
<CardDescription>
|
||||
Bearer-токен хранится только в localStorage браузера. Для локального демо с
|
||||
<code class="text-xs">EVOBGP_DEV_INSECURE=1</code> используйте токен
|
||||
<code class="text-xs">dev</code>.
|
||||
Bearer-токен хранится только в этом браузере (localStorage). Управление ключами tenant — в
|
||||
разделе <a href={resolve('/access')} class="text-primary underline-offset-4 hover:underline"
|
||||
>Права доступа</a
|
||||
>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="token">Токен</Label>
|
||||
<Label for="token">Токен для запросов</Label>
|
||||
<Input
|
||||
id="token"
|
||||
type="password"
|
||||
|
||||
Reference in New Issue
Block a user