CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 38s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 15s
CI / release (push) Failing after 3m7s
Refactored the project structure to support a monorepo setup, moving the web application to `apps/web/` and updating related configurations. Adjusted pre-commit hooks to use `pnpm` for linting and formatting. Updated CI workflows to reflect the new directory structure and dependencies. Removed legacy files and configurations from the previous `web/` directory, streamlining the project for better maintainability and clarity.
285 lines
8.8 KiB
Svelte
285 lines
8.8 KiB
Svelte
<script lang="ts">
|
|
import { apiMutate } from '$lib/api/client.js';
|
|
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '$lib/api/types.js';
|
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle
|
|
} from '@evobgp/ui/components/card/index.js';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle
|
|
} from '@evobgp/ui/components/dialog/index.js';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger
|
|
} from '@evobgp/ui/components/select/index.js';
|
|
import FormField from '$lib/components/patterns/form/form-field.svelte';
|
|
import AppInput from '$lib/components/patterns/form/app-input.svelte';
|
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
|
import { confirm } from '$lib/components/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>
|