feat: update admin web for app access and AI fixture previews
Replace tenant-key administration with app access listing and revocation. Show the canonical conversation and rules before submitting a fixture, support opt-in medium AI scoring, and keep credentials server-only.
This commit is contained in:
parent
2268b6a77a
commit
ec9f0f911e
@ -1,8 +1,10 @@
|
||||
# Admin Web server only. SvelteKit reads apps/web/.env when run from apps/web/.
|
||||
# Docker Compose passes BACKEND_INTERNAL_URL directly to the web container instead.
|
||||
BACKEND_INTERNAL_URL=http://127.0.0.1:8000
|
||||
# Make web-dev/dev derives BACKEND_INTERNAL_URL from BACKEND_PORT in the root .env.
|
||||
# Docker Compose passes its internal backend URL directly to the web container.
|
||||
# Only set this manually when running Bun directly without Make:
|
||||
# BACKEND_INTERNAL_URL=http://127.0.0.1:8000
|
||||
# Label for this Web instance: development, test, or production. Set test when using ai_platform_test.
|
||||
WEB_ENVIRONMENT=development
|
||||
# Optional server-only key for the internal fixture runner (use a separate test tenant).
|
||||
# LEAD_SCORING_FIXTURE_TENANT_KEY=
|
||||
# Optional server-only key for the internal fixture runner (use a separate test app).
|
||||
# LEAD_SCORING_FIXTURE_APP_API_KEY=
|
||||
# Never put credentials or backend URLs in PUBLIC_* variables.
|
||||
|
||||
@ -43,18 +43,18 @@
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
tooltipContent="Mã truy cập"
|
||||
isActive={page.url.pathname.startsWith('/platform/tenant-keys')}
|
||||
tooltipContent="Ứng dụng"
|
||||
isActive={page.url.pathname.startsWith('/platform/apps')}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
{...props}
|
||||
href="/platform/tenant-keys"
|
||||
aria-current={page.url.pathname.startsWith('/platform/tenant-keys')
|
||||
href="/platform/apps"
|
||||
aria-current={page.url.pathname.startsWith('/platform/apps')
|
||||
? 'page'
|
||||
: undefined}
|
||||
>
|
||||
<KeyRound /> <span>Mã truy cập</span>
|
||||
<KeyRound /> <span>Ứng dụng</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
|
||||
@ -1,40 +1,81 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { fixtureRunnerConfigured, submitScoringFixture } from './fixture-runner';
|
||||
import {
|
||||
fixtureRunnerConfigured,
|
||||
FixtureRunnerNotConfigured,
|
||||
scoringFixture,
|
||||
submitScoringFixture,
|
||||
} from './fixture-runner';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: {
|
||||
BACKEND_INTERNAL_URL: 'http://backend:8000',
|
||||
LEAD_SCORING_FIXTURE_TENANT_KEY: 'test-only-tenant-key',
|
||||
LEAD_SCORING_FIXTURE_APP_API_KEY: 'test-only-app-key',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('server-only fixture transport', () => {
|
||||
it('sends the canonical fixture with a new request ID and no V-Key', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(Response.json({ status: 'pending' }, { status: 202 }));
|
||||
const event = { fetch } as unknown as RequestEvent;
|
||||
expect(fixtureRunnerConfigured()).toBe(true);
|
||||
function mockEvent() {
|
||||
const fetch = vi.fn().mockResolvedValue(Response.json({ status: 'pending' }, { status: 202 }));
|
||||
return { fetch, event: { fetch } as unknown as RequestEvent };
|
||||
}
|
||||
|
||||
await submitScoringFixture(event);
|
||||
await submitScoringFixture(event);
|
||||
describe('server-only fixture transport', () => {
|
||||
it.each([false, true])('sends the canonical fixture with AI enabled: %s', async (useAI) => {
|
||||
const { fetch, event } = mockEvent();
|
||||
expect(fixtureRunnerConfigured()).toBe(true);
|
||||
await submitScoringFixture(event, useAI);
|
||||
await submitScoringFixture(event, useAI);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const ids = fetch.mock.calls.map(([url, init]) => {
|
||||
expect(url).toBe('http://backend:8000/v1/lead-scoring/runs');
|
||||
expect(init.headers.authorization).toBe('Bearer test-only-tenant-key');
|
||||
expect(init.headers.authorization).toBe('Bearer test-only-app-key');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(init.headers['Idempotency-Key']).toBe(body.request_id);
|
||||
expect(init.headers).not.toHaveProperty('X-Tenant-VKey');
|
||||
expect(init.cache).toBe('no-store');
|
||||
expect(init.body).not.toContain(env.LEAD_SCORING_FIXTURE_TENANT_KEY);
|
||||
expect(body.subject_ref).toBe('ems-lead-123');
|
||||
expect(body.scoring_config.ai_weight).toBe(0);
|
||||
expect(init.redirect).toBe('error');
|
||||
expect(init.body).not.toContain(env.LEAD_SCORING_FIXTURE_APP_API_KEY);
|
||||
expect(body).toEqual({
|
||||
...scoringFixture,
|
||||
request_id: expect.any(String),
|
||||
ai_profile: useAI ? 'medium' : scoringFixture.ai_profile,
|
||||
scoring_config: {
|
||||
...scoringFixture.scoring_config,
|
||||
rule_weight: useAI ? 0.5 : 1,
|
||||
ai_weight: useAI ? 0.5 : 0,
|
||||
},
|
||||
});
|
||||
return body.request_id;
|
||||
});
|
||||
expect(ids[0]).not.toBe(ids[1]);
|
||||
expect(ids.every((id: string) => /^[0-9a-f-]{36}$/.test(id))).toBe(true);
|
||||
expect(
|
||||
ids.every((id: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(scoringFixture.scoring_config.ai_weight).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the default request rules-only', async () => {
|
||||
const { fetch, event } = mockEvent();
|
||||
await submitScoringFixture(event);
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body).scoring_config.ai_weight).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a missing app key before sending any request', async () => {
|
||||
const { fetch, event } = mockEvent();
|
||||
const original = env.LEAD_SCORING_FIXTURE_APP_API_KEY;
|
||||
try {
|
||||
env.LEAD_SCORING_FIXTURE_APP_API_KEY = ' ';
|
||||
expect(fixtureRunnerConfigured()).toBe(false);
|
||||
await expect(submitScoringFixture(event, true)).rejects.toBeInstanceOf(
|
||||
FixtureRunnerNotConfigured,
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
env.LEAD_SCORING_FIXTURE_APP_API_KEY = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,31 +4,39 @@ import type { components } from '$lib/generated/api';
|
||||
import fixture from '../../../../../contracts/lead-scoring/v2/fixtures/run-request.json';
|
||||
|
||||
export type RunRequest = components['schemas']['RunRequest'];
|
||||
export const scoringFixture = fixture as RunRequest;
|
||||
|
||||
export class FixtureRunnerNotConfigured extends Error {}
|
||||
|
||||
export function fixtureRunnerConfigured(): boolean {
|
||||
return Boolean(env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim());
|
||||
return Boolean(env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim());
|
||||
}
|
||||
|
||||
export async function submitScoringFixture(event: RequestEvent): Promise<Response> {
|
||||
export async function submitScoringFixture(event: RequestEvent, useAI = false): Promise<Response> {
|
||||
const base = env.BACKEND_INTERNAL_URL;
|
||||
const tenantKey = env.LEAD_SCORING_FIXTURE_TENANT_KEY?.trim();
|
||||
if (!tenantKey) throw new FixtureRunnerNotConfigured();
|
||||
const appKey = env.LEAD_SCORING_FIXTURE_APP_API_KEY?.trim();
|
||||
if (!appKey) throw new FixtureRunnerNotConfigured();
|
||||
if (!base || !/^https?:\/\//.test(base)) {
|
||||
throw new Error('BACKEND_INTERNAL_URL must be configured on the server');
|
||||
}
|
||||
|
||||
const request: RunRequest = {
|
||||
...(fixture as RunRequest),
|
||||
...scoringFixture,
|
||||
request_id: crypto.randomUUID(),
|
||||
scoring_config: {
|
||||
...scoringFixture.scoring_config,
|
||||
rule_weight: useAI ? 0.5 : 1,
|
||||
ai_weight: useAI ? 0.5 : 0,
|
||||
},
|
||||
ai_profile: useAI ? 'medium' : scoringFixture.ai_profile,
|
||||
};
|
||||
return event.fetch(`${base.replace(/\/$/, '')}/v1/lead-scoring/runs`, {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${tenantKey}`,
|
||||
authorization: `Bearer ${appKey}`,
|
||||
'content-type': 'application/json',
|
||||
'Idempotency-Key': request.request_id,
|
||||
},
|
||||
|
||||
@ -3,6 +3,7 @@ import { backend, clearAdminSession } from '$lib/server/backend';
|
||||
import {
|
||||
fixtureRunnerConfigured,
|
||||
FixtureRunnerNotConfigured,
|
||||
scoringFixture,
|
||||
submitScoringFixture,
|
||||
} from '$lib/server/fixture-runner';
|
||||
import type { components } from '$lib/generated/api';
|
||||
@ -12,6 +13,7 @@ type RunSummary = components['schemas']['RunSummary'];
|
||||
|
||||
export const load: PageServerLoad = async () => ({
|
||||
fixtureRunnerConfigured: fixtureRunnerConfigured(),
|
||||
fixture: scoringFixture,
|
||||
});
|
||||
|
||||
export const actions: Actions = {
|
||||
@ -31,34 +33,42 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
|
||||
const useAI = (await event.request.formData()).get('use_ai') === 'true';
|
||||
let response: Response;
|
||||
try {
|
||||
response = await submitScoringFixture(event);
|
||||
response = await submitScoringFixture(event, useAI);
|
||||
} catch (error) {
|
||||
if (error instanceof FixtureRunnerNotConfigured) {
|
||||
return fail(503, {
|
||||
message: 'Chưa có mã truy cập cho mẫu này trên server.',
|
||||
useAI,
|
||||
});
|
||||
}
|
||||
return fail(502, { message: 'Không gửi được dữ liệu mẫu.' });
|
||||
return fail(502, { message: 'Không gửi được dữ liệu mẫu.', useAI });
|
||||
}
|
||||
if (response.status === 401) {
|
||||
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.' });
|
||||
return fail(502, { message: 'Mã truy cập của mẫu không còn hợp lệ.', useAI });
|
||||
}
|
||||
if (!response.ok) {
|
||||
return fail(response.status, { message: 'Không nhận được dữ liệu mẫu.' });
|
||||
return fail(response.status, {
|
||||
message: `Backend từ chối dữ liệu mẫu (HTTP ${response.status}).`,
|
||||
useAI,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const run = (await response.json()) as RunSummary;
|
||||
return {
|
||||
success: true,
|
||||
useAI,
|
||||
run_id: run.run_id,
|
||||
status: run.status,
|
||||
message: 'Đã gửi dữ liệu mẫu.',
|
||||
message: useAI
|
||||
? 'Đã gửi mẫu quy tắc và AI. Xem chi tiết để kiểm tra kết quả AI hoặc fallback.'
|
||||
: 'Đã gửi mẫu chỉ chấm quy tắc.',
|
||||
};
|
||||
} catch {
|
||||
return fail(502, { message: 'AI Platform trả về phản hồi không hợp lệ.' });
|
||||
return fail(502, { message: 'AI Platform trả về phản hồi không hợp lệ.', useAI });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -3,21 +3,22 @@
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
import FixturePreview from './FixturePreview.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let useAI = $derived(form?.useAI ?? false);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Thử quy tắc · AI Platform</title></svelte:head>
|
||||
<svelte:head><title>Thử chấm điểm · AI Platform</title></svelte:head>
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Lead Scoring / Kiểm thử</p>
|
||||
<h1 class="text-3xl font-bold">Thử tính điểm theo quy tắc</h1>
|
||||
<h1 class="text-3xl font-bold">Thử chấm điểm với dữ liệu mẫu</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Gửi dữ liệu mẫu để kiểm tra điểm quy tắc. Trang này chưa thử model AI.
|
||||
Xem hội thoại và quy tắc trước khi gửi. Dữ liệu dưới đây hoàn toàn giả lập.
|
||||
</p>
|
||||
</header>
|
||||
<Card.Root class="max-w-2xl">
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Dữ liệu mẫu</h2>
|
||||
</Card.Header>
|
||||
<Card.Root class="mb-5">
|
||||
<Card.Header><h2 class="text-base font-semibold">Chế độ chấm điểm</h2></Card.Header>
|
||||
<Card.Content class="grid gap-4">
|
||||
{#if !data.fixtureRunnerConfigured}
|
||||
<p class="text-sm text-muted-foreground" role="status">
|
||||
@ -34,15 +35,43 @@
|
||||
{/if}
|
||||
{#if form?.success && form.run_id}
|
||||
<p class="text-sm">
|
||||
Trạng thái: {form.status}.
|
||||
Trạng thái lúc nhận: {form.status}.
|
||||
<a
|
||||
class="font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2"
|
||||
href={`/lead-scoring/runs/${form.run_id}`}>Xem lần chấm điểm</a
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
<form method="POST">
|
||||
<Button type="submit" disabled={!data.fixtureRunnerConfigured}>Gửi dữ liệu mẫu</Button>
|
||||
<form method="POST" class="grid gap-4">
|
||||
<label class="flex items-center gap-3 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="use_ai"
|
||||
value="true"
|
||||
bind:checked={useAI}
|
||||
aria-describedby="scoring-mode ai-note"
|
||||
class="size-4 accent-primary focus-visible:outline-2 focus-visible:outline-primary"
|
||||
/>
|
||||
Chấm thêm bằng AI
|
||||
</label>
|
||||
<p id="scoring-mode" class="text-sm" aria-live="polite">
|
||||
{useAI
|
||||
? 'Quy tắc + AI · Quy tắc 50%, AI 50% · medium'
|
||||
: 'Chỉ quy tắc · Quy tắc 100%, AI 0%'}
|
||||
</p>
|
||||
<p id="ai-note" class="max-w-prose text-sm text-muted-foreground">
|
||||
{#if useAI}
|
||||
AI dùng cấu hình đang active và có thể phát sinh chi phí. Với AIHub, mẫu chưa
|
||||
gửi V-Key nên sẽ dự phòng bằng điểm quy tắc. Thiếu cấu hình hoặc provider lỗi
|
||||
cũng có thể dẫn đến fallback; xem kết quả ở trang chi tiết.
|
||||
{:else}
|
||||
Không gọi model AI. Các quy tắc mẫu và dữ liệu khách hàng được giữ nguyên.
|
||||
{/if}
|
||||
</p>
|
||||
<Button type="submit" disabled={!data.fixtureRunnerConfigured} class="w-fit">
|
||||
Gửi dữ liệu mẫu
|
||||
</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<FixturePreview fixture={data.fixture} />
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let { fixture }: { fixture: components['schemas']['RunRequest'] } = $props();
|
||||
const senderLabels = { contact: 'Khách hàng', agent: 'Nhân viên', bot: 'Bot' };
|
||||
</script>
|
||||
|
||||
<div class="grid items-start gap-5 lg:grid-cols-2">
|
||||
<div class="grid min-w-0 gap-5">
|
||||
<Card.Root>
|
||||
<Card.Header><h2 class="text-base font-semibold">Thông tin mẫu</h2></Card.Header>
|
||||
<Card.Content class="grid gap-4 text-sm">
|
||||
<p>Lead: <span class="font-mono">{fixture.subject_ref}</span></p>
|
||||
<p>Account: {fixture.account_id}</p>
|
||||
<dl class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-x-4 gap-y-3">
|
||||
{#each Object.entries(fixture.snapshot.lead_profile.custom_fields ?? {}) as [key, value] (key)}
|
||||
<dt class="break-all font-mono text-xs text-muted-foreground">{key}</dt>
|
||||
<dd class="break-words">{JSON.stringify(value)}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Quy tắc mẫu</h2>
|
||||
<Card.Description>Phiên bản {fixture.scoring_config.version}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<ul class="divide-y divide-border">
|
||||
{#each fixture.scoring_config.rules as rule (rule.id)}
|
||||
<li class="grid min-w-0 gap-2 py-4 first:pt-0 last:pb-0 text-sm">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<strong class="break-all font-medium">{rule.id}</strong>
|
||||
<span class="text-muted-foreground"
|
||||
>{rule.points} điểm khi khớp</span
|
||||
>
|
||||
</div>
|
||||
<code class="break-all text-xs">
|
||||
{rule.field}
|
||||
{rule.operator}
|
||||
{JSON.stringify(rule.value)}
|
||||
</code>
|
||||
{#if rule.description}
|
||||
<p class="text-muted-foreground">{rule.description}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
<Card.Root class="min-w-0">
|
||||
<Card.Header><h2 class="text-base font-semibold">Hội thoại mẫu</h2></Card.Header>
|
||||
<Card.Content class="grid gap-5">
|
||||
{#each fixture.snapshot.conversations ?? [] as conversation (conversation.conversation_id)}
|
||||
<article class="min-w-0">
|
||||
<h3 class="text-sm font-semibold">{conversation.channel ?? 'Hội thoại'}</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{conversation.messages?.length ?? 0} tin nhắn
|
||||
</p>
|
||||
<ol class="mt-4 grid gap-3" aria-label="Tin nhắn mẫu">
|
||||
{#each conversation.messages ?? [] as message (message.message_id)}
|
||||
<li class="min-w-0 rounded-lg bg-muted/50 p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<strong class="text-sm"
|
||||
>{senderLabels[message.sender_type]}</strong
|
||||
>
|
||||
<time
|
||||
datetime={message.sent_at}
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{new Date(message.sent_at).toLocaleTimeString('vi-VN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Asia/Ho_Chi_Minh',
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-wrap break-words text-sm leading-6">
|
||||
{message.content}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</article>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@ -0,0 +1,68 @@
|
||||
import { render } from 'svelte/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fixture from '../../../../../../../contracts/lead-scoring/v2/fixtures/run-request.json';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { PageProps } from './$types';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
function renderPage(form: PageProps['form'] = null, configured = true): string {
|
||||
return render(Page, {
|
||||
props: {
|
||||
data: {
|
||||
admin: { email: 'admin@example.invalid' },
|
||||
environment: 'development',
|
||||
fixture: fixture as components['schemas']['RunRequest'],
|
||||
fixtureRunnerConfigured: configured,
|
||||
},
|
||||
form,
|
||||
params: {},
|
||||
},
|
||||
}).body;
|
||||
}
|
||||
|
||||
describe('fixture preview', () => {
|
||||
it('shows the canonical conversation, custom fields and rules', () => {
|
||||
const html = renderPage();
|
||||
const messages = fixture.snapshot.conversations.flatMap(
|
||||
(conversation) => conversation.messages,
|
||||
);
|
||||
expect(messages).toHaveLength(12);
|
||||
for (const message of messages) expect(html).toContain(message.content);
|
||||
expect(html).toContain('Khách hàng');
|
||||
expect(html).toContain('Nhân viên');
|
||||
for (const [key, value] of Object.entries(fixture.snapshot.lead_profile.custom_fields)) {
|
||||
expect(html).toContain(key);
|
||||
expect(html).toContain(value);
|
||||
}
|
||||
for (const rule of fixture.scoring_config.rules) {
|
||||
expect(html).toContain(rule.id);
|
||||
expect(html).toContain(rule.field);
|
||||
expect(html).toContain(`${rule.points} điểm khi khớp`);
|
||||
}
|
||||
expect(html).toContain('Chỉ quy tắc · Quy tắc 100%, AI 0%');
|
||||
expect(html).not.toMatch(/API_KEY|BACKEND_INTERNAL_URL|Bearer/);
|
||||
});
|
||||
|
||||
it('preserves the submitted AI mode and links to the backend result', () => {
|
||||
const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b';
|
||||
const html = renderPage({
|
||||
success: true,
|
||||
useAI: true,
|
||||
run_id: runId,
|
||||
status: 'pending',
|
||||
message: 'Đã gửi mẫu.',
|
||||
});
|
||||
expect(html).toContain('Quy tắc + AI · Quy tắc 50%, AI 50% · medium');
|
||||
expect(html).toContain('checked');
|
||||
expect(html).toContain(`/lead-scoring/runs/${runId}`);
|
||||
expect(html).toContain('có thể phát sinh chi phí');
|
||||
expect(html).toContain('fallback');
|
||||
});
|
||||
|
||||
it('shows the preview but disables submission when the runner is unconfigured', () => {
|
||||
const html = renderPage(null, false);
|
||||
expect(html).toContain('disabled');
|
||||
expect(html).toContain('Chưa có mã truy cập');
|
||||
expect(html).toContain(fixture.snapshot.conversations[0].messages[0].content);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { backend, clearAdminSession } from '$lib/server/backend';
|
||||
import { fixtureRunnerConfigured, submitScoringFixture } from '$lib/server/fixture-runner';
|
||||
import {
|
||||
fixtureRunnerConfigured,
|
||||
scoringFixture,
|
||||
submitScoringFixture,
|
||||
} from '$lib/server/fixture-runner';
|
||||
import { actions, load } from './+page.server';
|
||||
|
||||
vi.mock('$lib/server/backend', () => ({
|
||||
@ -9,6 +13,7 @@ vi.mock('$lib/server/backend', () => ({
|
||||
}));
|
||||
vi.mock('$lib/server/fixture-runner', () => ({
|
||||
fixtureRunnerConfigured: vi.fn(),
|
||||
scoringFixture: { subject_ref: 'fixture-lead' },
|
||||
submitScoringFixture: vi.fn(),
|
||||
FixtureRunnerNotConfigured: class extends Error {},
|
||||
}));
|
||||
@ -21,9 +26,15 @@ const runId = 'f4a5d7c8-f55f-47d2-b2ea-611a2b78e59b';
|
||||
|
||||
type ActionEvent = Parameters<NonNullable<typeof actions.default>>[0];
|
||||
|
||||
function eventFor(): { event: ActionEvent; setHeaders: ReturnType<typeof vi.fn> } {
|
||||
function eventFor(useAI = false): { event: ActionEvent; setHeaders: ReturnType<typeof vi.fn> } {
|
||||
const setHeaders = vi.fn();
|
||||
return { event: { setHeaders } as unknown as ActionEvent, setHeaders };
|
||||
const data = new FormData();
|
||||
if (useAI) data.set('use_ai', 'true');
|
||||
// Other submitted values cannot replace the server-owned fixture or weights.
|
||||
data.set('ai_weight', '1');
|
||||
data.set('payload', '{"subject_ref":"tampered"}');
|
||||
const request = { formData: vi.fn().mockResolvedValue(data) };
|
||||
return { event: { setHeaders, request } as unknown as ActionEvent, setHeaders };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -33,34 +44,46 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('fixture runner page', () => {
|
||||
it('shows whether a server-only fixture key is configured', async () => {
|
||||
it('returns the canonical preview without credentials even when the runner is unconfigured', async () => {
|
||||
configuredMock.mockReturnValue(false);
|
||||
const page = await load({} as Parameters<typeof load>[0]);
|
||||
expect(page).toEqual({ fixtureRunnerConfigured: false });
|
||||
expect(page).toEqual({ fixtureRunnerConfigured: false, fixture: scoringFixture });
|
||||
expect(JSON.stringify(page)).not.toMatch(/API_KEY|BACKEND_INTERNAL_URL|Bearer/);
|
||||
});
|
||||
|
||||
it('submits the fixture only after checking the admin session', async () => {
|
||||
submitMock.mockResolvedValueOnce(
|
||||
Response.json({ run_id: runId, status: 'pending' }, { status: 202 }),
|
||||
);
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const { event, setHeaders } = eventFor();
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ success: true, run_id: runId, status: 'pending' });
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
|
||||
expect(submitMock).toHaveBeenCalledWith(event);
|
||||
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
submitMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
it.each([false, true])(
|
||||
'submits the chosen AI mode (%s) only after checking the admin session',
|
||||
async (useAI) => {
|
||||
submitMock.mockResolvedValueOnce(
|
||||
Response.json({ run_id: runId, status: 'pending' }, { status: 202 }),
|
||||
);
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const { event, setHeaders } = eventFor(useAI);
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
useAI,
|
||||
run_id: runId,
|
||||
status: 'pending',
|
||||
});
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendMock).toHaveBeenCalledWith(event, '/v1/admin/me');
|
||||
expect(submitMock).toHaveBeenCalledWith(event, useAI);
|
||||
expect(backendMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
submitMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
if (useAI) {
|
||||
expect(result).toMatchObject({ message: expect.stringContaining('fallback') });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an expired admin session without submitting', async () => {
|
||||
backendMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const { event } = eventFor();
|
||||
const { event } = eventFor(true);
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ status: 401 });
|
||||
expect(clearSessionMock).toHaveBeenCalledWith(event);
|
||||
@ -76,12 +99,24 @@ describe('fixture runner page', () => {
|
||||
expect(submitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not expose a rejected tenant key in the response', async () => {
|
||||
submitMock.mockResolvedValueOnce(new Response('{}', { status: 401 }));
|
||||
it('does not expose a rejected app key in the response', async () => {
|
||||
submitMock.mockResolvedValueOnce(new Response('private app key', { status: 401 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const result = await action(eventFor().event);
|
||||
expect(result).toMatchObject({ status: 502 });
|
||||
expect(JSON.stringify(result)).not.toContain('Bearer');
|
||||
expect(JSON.stringify(result)).not.toMatch(/Bearer|private app key/);
|
||||
});
|
||||
|
||||
it('reports backend rejection without claiming that the fixture is missing', async () => {
|
||||
submitMock.mockResolvedValueOnce(new Response('private backend detail', { status: 503 }));
|
||||
const action = actions.default;
|
||||
if (!action) throw new Error('fixture action missing');
|
||||
const result = await action(eventFor(true).event);
|
||||
expect(result).toMatchObject({
|
||||
status: 503,
|
||||
data: { message: 'Backend từ chối dữ liệu mẫu (HTTP 503).', useAI: true },
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain('private backend detail');
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,7 +4,8 @@ import type { PageServerLoad } from './$types';
|
||||
|
||||
export type RunSummary = {
|
||||
run_id: string;
|
||||
tenant_id: string;
|
||||
app_id: string;
|
||||
account_id: string;
|
||||
status: string;
|
||||
final_score: number | null;
|
||||
degraded: boolean;
|
||||
|
||||
@ -45,9 +45,9 @@
|
||||
>
|
||||
<p
|
||||
class="truncate font-mono text-xs text-muted-foreground"
|
||||
title={run.tenant_id}
|
||||
title={`${run.app_id} / ${run.account_id}`}
|
||||
>
|
||||
{run.tenant_id}
|
||||
{run.app_id} / {run.account_id}
|
||||
</p>
|
||||
<a
|
||||
class="w-fit font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2"
|
||||
@ -62,7 +62,7 @@
|
||||
<Table.Root>
|
||||
<Table.Header
|
||||
><Table.Row
|
||||
><Table.Head>Thời điểm</Table.Head><Table.Head>Tenant</Table.Head
|
||||
><Table.Head>Thời điểm</Table.Head><Table.Head>App / Account</Table.Head
|
||||
><Table.Head>Trạng thái</Table.Head><Table.Head>Điểm</Table.Head
|
||||
><Table.Head><span class="sr-only">Chi tiết</span></Table.Head
|
||||
></Table.Row
|
||||
@ -80,7 +80,8 @@
|
||||
>
|
||||
<Table.Cell
|
||||
class="max-w-40 truncate font-mono text-xs"
|
||||
title={run.tenant_id}>{run.tenant_id}</Table.Cell
|
||||
title={`${run.app_id} / ${run.account_id}`}
|
||||
>{run.app_id} / {run.account_id}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
><Badge variant={run.degraded ? 'destructive' : 'secondary'}
|
||||
|
||||
@ -57,8 +57,10 @@
|
||||
<Card.Header><h2 class="text-base font-semibold">Trạng thái</h2></Card.Header>
|
||||
<Card.Content>
|
||||
<dl class="grid grid-cols-[minmax(100px,0.4fr)_minmax(0,1fr)] gap-x-4 gap-y-3 text-sm">
|
||||
<dt class="text-muted-foreground">Tenant</dt>
|
||||
<dd class="min-w-0 break-all">{data.run.tenant_id}</dd>
|
||||
<dt class="text-muted-foreground">App</dt>
|
||||
<dd class="min-w-0 break-all">{data.run.app_id}</dd>
|
||||
<dt class="text-muted-foreground">Account</dt>
|
||||
<dd class="min-w-0 break-all">{data.run.account_id}</dd>
|
||||
<dt class="text-muted-foreground">Trạng thái</dt>
|
||||
<dd>
|
||||
<Badge variant={data.run.degraded ? 'destructive' : 'secondary'}
|
||||
|
||||
@ -6,7 +6,8 @@ import RunAnalysis from './RunAnalysis.svelte';
|
||||
const successfulRun: components['schemas']['RunDetail'] = {
|
||||
run_id: '7d5fe45b-0816-42ca-807a-3869a0227949',
|
||||
request_id: '8a67f3d4-f2a4-40e1-9b9b-4e2913060cf1',
|
||||
tenant_id: 'mock-tenant',
|
||||
app_id: 'mock-app',
|
||||
account_id: '3',
|
||||
status: 'completed',
|
||||
profile_version: 'medium-v1',
|
||||
rule_score: 40,
|
||||
|
||||
34
apps/web/src/routes/(admin)/platform/apps/+page.server.ts
Normal file
34
apps/web/src/routes/(admin)/platform/apps/+page.server.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { backendJson } from '$lib/server/backend';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
type AppSummary = components['schemas']['AppSummary'];
|
||||
type AppList = components['schemas']['AppList'];
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const { response, data } = await backendJson<AppList>(event, '/v1/admin/apps');
|
||||
if (!response.ok || !data) error(502, 'Không tải được danh sách ứng dụng.');
|
||||
return { apps: data.apps };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
revoke: async (event) => {
|
||||
const form = await event.request.formData();
|
||||
const app_id = String(form.get('app_id') ?? '');
|
||||
if (!app_id.trim() || app_id.length > 128)
|
||||
return fail(400, { message: 'App ID không hợp lệ.' });
|
||||
const path = `/v1/admin/apps/${encodeURIComponent(app_id)}` as const;
|
||||
const { response, data } = await backendJson<AppSummary>(event, path, { method: 'DELETE' });
|
||||
if (!response.ok || !data) {
|
||||
const message =
|
||||
response.status === 404
|
||||
? 'Không tìm thấy ứng dụng.'
|
||||
: response.status === 401 || response.status === 403
|
||||
? 'Phiên quản trị hết hạn hoặc CSRF không hợp lệ.'
|
||||
: 'Không thể thu hồi quyền truy cập. Vui lòng thử lại.';
|
||||
return fail(response.status, { message });
|
||||
}
|
||||
return { success: true, message: `Đã thu hồi quyền truy cập của ${data.app_id}.` };
|
||||
},
|
||||
};
|
||||
80
apps/web/src/routes/(admin)/platform/apps/+page.svelte
Normal file
80
apps/web/src/routes/(admin)/platform/apps/+page.svelte
Normal file
@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
function confirmRevoke(event: SubmitEvent, appId: string) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Thu hồi quyền truy cập của ${appId}? Ứng dụng này sẽ không gọi được API.`,
|
||||
)
|
||||
)
|
||||
event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Ứng dụng · AI Platform</title></svelte:head>
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Platform / Truy cập</p>
|
||||
<h1 class="text-3xl font-bold">Ứng dụng tích hợp</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Mỗi bản triển khai Omni dùng một app key chung cho các account. Vận hành cấp hoặc xoay vòng
|
||||
key qua CLI; Web chỉ hiển thị trạng thái và thu hồi quyền truy cập, không nhận raw key.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.message}
|
||||
<p
|
||||
role={form.success ? 'status' : 'alert'}
|
||||
class={form.success ? 'mb-5 text-sm text-primary' : 'mb-5 text-sm text-destructive'}
|
||||
>
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header><h2 class="text-base font-semibold">Ứng dụng đã được cấp quyền</h2></Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.apps.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Chưa có ứng dụng nào được cấp quyền.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each data.apps as app (app.app_id)}
|
||||
<li class="grid gap-3 py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<code class="min-w-0 flex-1 break-all text-sm">{app.app_id}</code>
|
||||
<Badge variant={app.active ? 'secondary' : 'outline'}
|
||||
>{app.active ? 'Đang hoạt động' : 'Đã thu hồi'}</Badge
|
||||
>
|
||||
<Badge variant="outline"
|
||||
>{app.lead_scoring
|
||||
? 'Có quyền Lead Scoring'
|
||||
: 'Không có quyền Lead Scoring'}</Badge
|
||||
>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tạo {new Date(app.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date(
|
||||
app.updated_at,
|
||||
).toLocaleString('vi-VN')}
|
||||
</p>
|
||||
{#if app.active}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/revoke"
|
||||
onsubmit={(event) => confirmRevoke(event, app.app_id)}
|
||||
>
|
||||
<input type="hidden" name="app_id" value={app.app_id} />
|
||||
<Button type="submit" variant="destructive" size="sm"
|
||||
>Thu hồi</Button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
65
apps/web/src/routes/(admin)/platform/apps/apps.test.ts
Normal file
65
apps/web/src/routes/(admin)/platform/apps/apps.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { backendJson } from '$lib/server/backend';
|
||||
import { actions, load } from './+page.server';
|
||||
|
||||
type AppEvent = Parameters<NonNullable<typeof actions.revoke>>[0];
|
||||
vi.mock('$lib/server/backend', () => ({ backendJson: vi.fn() }));
|
||||
const mock = vi.mocked(backendJson);
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function eventFor(app_id: string): AppEvent {
|
||||
return {
|
||||
request: new Request('http://localhost/platform/apps', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ app_id }),
|
||||
}),
|
||||
} as AppEvent;
|
||||
}
|
||||
|
||||
describe('app credential administration', () => {
|
||||
it('only allows revocation from the browser', () => {
|
||||
expect(Object.keys(actions)).toEqual(['revoke']);
|
||||
});
|
||||
it('loads public app status without secrets', async () => {
|
||||
const apps = [
|
||||
{
|
||||
app_id: 'omni-local',
|
||||
active: true,
|
||||
lead_scoring: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
mock.mockResolvedValueOnce({ response: Response.json({ apps }), data: { apps } });
|
||||
expect(await load({} as Parameters<typeof load>[0])).toEqual({ apps });
|
||||
});
|
||||
it('revokes through the server helper and encodes the app ID', async () => {
|
||||
mock.mockResolvedValueOnce({
|
||||
response: Response.json({}),
|
||||
data: {
|
||||
app_id: 'omni/local',
|
||||
active: false,
|
||||
lead_scoring: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
});
|
||||
expect(await actions.revoke!(eventFor('omni/local'))).toMatchObject({ success: true });
|
||||
expect(mock).toHaveBeenCalledWith(expect.anything(), '/v1/admin/apps/omni%2Flocal', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
});
|
||||
it('rejects blank IDs before making a backend request', async () => {
|
||||
expect(await actions.revoke!(eventFor(' '))).toMatchObject({ status: 400 });
|
||||
expect(mock).not.toHaveBeenCalled();
|
||||
});
|
||||
it('uses a safe failure message instead of backend error contents', async () => {
|
||||
mock.mockResolvedValueOnce({
|
||||
response: new Response('sensitive', { status: 403 }),
|
||||
data: null,
|
||||
});
|
||||
const result = await actions.revoke!(eventFor('omni-local'));
|
||||
expect(result).toMatchObject({ status: 403 });
|
||||
expect(JSON.stringify(result)).not.toContain('sensitive');
|
||||
});
|
||||
});
|
||||
@ -1,84 +0,0 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { backendJson } from '$lib/server/backend';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
type TenantKeySummary = components['schemas']['TenantKeySummary'];
|
||||
type TenantKeyList = components['schemas']['TenantKeyList'];
|
||||
type TenantKeySecret = components['schemas']['TenantKeySecret'];
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const { response, data } = await backendJson<TenantKeyList>(event, '/v1/admin/tenant-keys');
|
||||
if (!response.ok || !data) error(502, 'Không tải được danh sách tenant key.');
|
||||
return { tenants: data.tenants };
|
||||
};
|
||||
|
||||
function mutationMessage(status: number, action: 'create' | 'rotate' | 'revoke'): string {
|
||||
if (status === 409 && action === 'create')
|
||||
return 'Tenant này đã có key. Hãy xoay vòng key hiện tại.';
|
||||
if (status === 404) return 'Không tìm thấy tenant key.';
|
||||
if (status === 422) return 'Tenant ID không hợp lệ.';
|
||||
if (status === 401 || status === 403) return 'Phiên quản trị hết hạn hoặc CSRF không hợp lệ.';
|
||||
return 'Không thể cập nhật tenant key. Vui lòng thử lại.';
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
const form = await event.request.formData();
|
||||
const tenant_id = String(form.get('tenant_id') ?? '').trim();
|
||||
if (!tenant_id || tenant_id.length > 128) {
|
||||
return fail(400, { message: 'Nhập Tenant ID từ 1 đến 128 ký tự.' });
|
||||
}
|
||||
const { response, data } = await backendJson<TenantKeySecret>(
|
||||
event,
|
||||
'/v1/admin/tenant-keys',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id }),
|
||||
},
|
||||
);
|
||||
if (!response.ok || !data) {
|
||||
return fail(response.status, { message: mutationMessage(response.status, 'create') });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Đã tạo tenant key.',
|
||||
tenant_id: data.tenant_id,
|
||||
tenant_key: data.tenant_key,
|
||||
};
|
||||
},
|
||||
rotate: async (event) => {
|
||||
event.setHeaders({ 'cache-control': 'no-store' });
|
||||
const form = await event.request.formData();
|
||||
const tenant_id = String(form.get('tenant_id') ?? '');
|
||||
if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
|
||||
const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}/rotate` as const;
|
||||
const { response, data } = await backendJson<TenantKeySecret>(event, path, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok || !data) {
|
||||
return fail(response.status, { message: mutationMessage(response.status, 'rotate') });
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `Đã xoay vòng key cho ${data.tenant_id}.`,
|
||||
tenant_id: data.tenant_id,
|
||||
tenant_key: data.tenant_key,
|
||||
};
|
||||
},
|
||||
revoke: async (event) => {
|
||||
const form = await event.request.formData();
|
||||
const tenant_id = String(form.get('tenant_id') ?? '');
|
||||
if (!tenant_id) return fail(400, { message: 'Thiếu Tenant ID.' });
|
||||
const path = `/v1/admin/tenant-keys/${encodeURIComponent(tenant_id)}` as const;
|
||||
const { response, data } = await backendJson<TenantKeySummary>(event, path, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok || !data) {
|
||||
return fail(response.status, { message: mutationMessage(response.status, 'revoke') });
|
||||
}
|
||||
return { success: true, message: `Đã thu hồi key của ${data.tenant_id}.` };
|
||||
},
|
||||
};
|
||||
@ -1,159 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let copied = $state(false);
|
||||
let copyError = $state(false);
|
||||
|
||||
async function copyKey(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copied = true;
|
||||
copyError = false;
|
||||
} catch {
|
||||
copied = false;
|
||||
copyError = true;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAction(event: SubmitEvent, action: 'rotate' | 'revoke', tenant: string) {
|
||||
const message =
|
||||
action === 'rotate'
|
||||
? `Thay mã cho ${tenant}? Mã cũ sẽ hết hiệu lực ngay.`
|
||||
: `Thu hồi mã của ${tenant}? Tổ chức này sẽ không gọi được API.`;
|
||||
if (!window.confirm(message)) event.preventDefault();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Mã truy cập · AI Platform</title></svelte:head>
|
||||
<header class="mb-8">
|
||||
<p class="mb-2 text-xs font-semibold text-primary">Platform / Truy cập</p>
|
||||
<h1 class="text-3xl font-bold">Mã truy cập của tổ chức</h1>
|
||||
<p class="mt-2 max-w-prose text-sm text-muted-foreground">
|
||||
Tạo mã cho tổ chức gửi yêu cầu chấm điểm.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.message}
|
||||
<p
|
||||
role={form.success ? 'status' : 'alert'}
|
||||
class={form.success ? 'mb-5 text-sm text-primary' : 'mb-5 text-sm text-destructive'}
|
||||
>
|
||||
{form.message}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if form?.tenant_key}
|
||||
<section
|
||||
aria-labelledby="new-key-title"
|
||||
class="mb-6 rounded-lg border border-destructive/40 bg-card p-5"
|
||||
>
|
||||
<h2 id="new-key-title" class="text-base font-semibold">Sao chép mã vừa tạo</h2>
|
||||
<p class="mt-2 text-sm text-destructive">
|
||||
Sao chép mã này. Nếu làm mất, hãy dùng “Thay mã” ở danh sách bên dưới.
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<code class="min-w-0 flex-1 break-all rounded-md bg-muted px-3 py-2 text-sm"
|
||||
>{form.tenant_key}</code
|
||||
>
|
||||
<Button type="button" variant="outline" onclick={() => copyKey(form.tenant_key)}>
|
||||
Sao chép mã
|
||||
</Button>
|
||||
</div>
|
||||
{#if copied}<p class="mt-2 text-sm text-primary" role="status">Đã sao chép mã.</p>{/if}
|
||||
{#if copyError}<p class="mt-2 text-sm text-destructive" role="alert">
|
||||
Chọn và sao chép mã thủ công.
|
||||
</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(18rem,0.75fr)_minmax(0,1.25fr)]">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Tạo mã truy cập</h2>
|
||||
<Card.Description>Dành cho tổ chức chưa có mã.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" action="?/create" class="grid gap-3">
|
||||
<label for="tenant-id" class="text-sm font-medium">Mã tổ chức</label>
|
||||
<input
|
||||
id="tenant-id"
|
||||
name="tenant_id"
|
||||
required
|
||||
maxlength="128"
|
||||
autocomplete="off"
|
||||
class="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||
/>
|
||||
<Button type="submit" class="mt-1 w-fit">Tạo mã</Button>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<h2 class="text-base font-semibold">Tổ chức đã có mã</h2>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.tenants.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Chưa có tổ chức nào được cấp mã.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each data.tenants as tenant (tenant.tenant_id)}
|
||||
<li class="grid gap-3 py-4 first:pt-0 last:pb-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<code class="min-w-0 flex-1 break-all text-sm"
|
||||
>{tenant.tenant_id}</code
|
||||
>
|
||||
<Badge variant={tenant.active ? 'secondary' : 'outline'}>
|
||||
{tenant.active ? 'Đang hoạt động' : 'Đã thu hồi'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Tạo {new Date(tenant.created_at).toLocaleString('vi-VN')} · Cập nhật {new Date(
|
||||
tenant.updated_at,
|
||||
).toLocaleString('vi-VN')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/rotate"
|
||||
onsubmit={(event) =>
|
||||
confirmAction(event, 'rotate', tenant.tenant_id)}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="tenant_id"
|
||||
value={tenant.tenant_id}
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
Thay mã
|
||||
</Button>
|
||||
</form>
|
||||
{#if tenant.active}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/revoke"
|
||||
onsubmit={(event) =>
|
||||
confirmAction(event, 'revoke', tenant.tenant_id)}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="tenant_id"
|
||||
value={tenant.tenant_id}
|
||||
/>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
Thu hồi
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@ -1,108 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { backend, backendJson } from '$lib/server/backend';
|
||||
import { actions } from './+page.server';
|
||||
|
||||
type TenantKeyRequestEvent = Parameters<NonNullable<typeof actions.create>>[0];
|
||||
|
||||
vi.mock('$lib/server/backend', () => ({
|
||||
backend: vi.fn(),
|
||||
backendJson: vi.fn(),
|
||||
}));
|
||||
|
||||
function eventFor(values: Record<string, string>): {
|
||||
event: TenantKeyRequestEvent;
|
||||
setHeaders: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const setHeaders = vi.fn();
|
||||
return {
|
||||
event: {
|
||||
request: new Request('http://localhost/platform/tenant-keys', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(values),
|
||||
}),
|
||||
setHeaders,
|
||||
} as unknown as TenantKeyRequestEvent,
|
||||
setHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
const backendJsonMock = vi.mocked(backendJson);
|
||||
const backendMock = vi.mocked(backend);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('tenant key server actions', () => {
|
||||
it('returns a newly-created key only in the action result', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 201 } as Response,
|
||||
data: { tenant_id: 'tenant-a', tenant_key: 'tnt-once' },
|
||||
});
|
||||
const action = actions.create;
|
||||
if (!action) throw new Error('create action missing');
|
||||
const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-once' });
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/v1/admin/tenant-keys',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rotates a key through the Admin API and returns the new value once', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 } as Response,
|
||||
data: { tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' },
|
||||
});
|
||||
const action = actions.rotate;
|
||||
if (!action) throw new Error('rotate action missing');
|
||||
const { event, setHeaders } = eventFor({ tenant_id: 'tenant-a' });
|
||||
const result = await action(event);
|
||||
expect(result).toMatchObject({ tenant_id: 'tenant-a', tenant_key: 'tnt-rotated' });
|
||||
expect(setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' });
|
||||
expect(backendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/v1/admin/tenant-keys/tenant-a/rotate',
|
||||
{ method: 'POST' },
|
||||
);
|
||||
});
|
||||
|
||||
it('reports an existing tenant conflict without returning a key', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: false, status: 409 } as Response,
|
||||
data: null,
|
||||
});
|
||||
const action = actions.create;
|
||||
if (!action) throw new Error('create action missing');
|
||||
const result = await action(eventFor({ tenant_id: 'tenant-a' }).event);
|
||||
expect(result).toMatchObject({
|
||||
status: 409,
|
||||
data: { message: expect.stringContaining('đã có key') },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends revoke only through the backend server helper', async () => {
|
||||
backendJsonMock.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 } as Response,
|
||||
data: {
|
||||
tenant_id: 'tenant/a',
|
||||
active: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-02T00:00:00Z',
|
||||
},
|
||||
});
|
||||
const action = actions.revoke;
|
||||
if (!action) throw new Error('revoke action missing');
|
||||
const result = await action(eventFor({ tenant_id: 'tenant/a' }).event);
|
||||
expect(result).toMatchObject({ success: true });
|
||||
expect(backendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/v1/admin/tenant-keys/tenant%2Fa',
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
expect(backendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user