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:
hungtran24 2026-10-02 10:57:31 +07:00
parent 2268b6a77a
commit ec9f0f911e
19 changed files with 547 additions and 431 deletions

View File

@ -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.

View File

@ -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>

View File

@ -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;
}
});
});

View File

@ -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,
},

View File

@ -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 });
}
},
};

View File

@ -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} />

View File

@ -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>

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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;

View File

@ -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'}

View File

@ -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'}

View File

@ -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,

View 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}.` };
},
};

View 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>

View 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');
});
});

View File

@ -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}.` };
},
};

View File

@ -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>

View File

@ -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();
});
});