12
packages/admin-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Waggle Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
packages/admin-web/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@waggle/admin-web",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/admin-web/tests/admin-pages.test.ts",
|
||||
"test:rendered": "npm run build && node ../../node_modules/playwright/cli.js test -c playwright.config.ts --project=chromium --reporter=list"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"tailwindcss": "^4.0.0"
|
||||
}
|
||||
}
|
||||
30
packages/admin-web/playwright.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = Number(process.env.ADMIN_WEB_E2E_PORT ?? '4182');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
testMatch: 'admin-rendered.spec.ts',
|
||||
timeout: 60_000,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'off',
|
||||
viewport: { width: 1200, height: 800 },
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: `npm run preview -- --host 127.0.0.1 --port ${port}`,
|
||||
port,
|
||||
reuseExistingServer: true,
|
||||
timeout: 60_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
});
|
||||
163
packages/admin-web/src/App.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Dashboard } from './pages/Dashboard.js';
|
||||
import { Jobs } from './pages/Jobs.js';
|
||||
import { Audit } from './pages/Audit.js';
|
||||
import { Members } from './pages/Members.js';
|
||||
import { Capabilities } from './pages/Capabilities.js';
|
||||
import { TeamSettings } from './pages/TeamSettings.js';
|
||||
import { Analytics } from './pages/Analytics.js';
|
||||
|
||||
type Page = 'dashboard' | 'analytics' | 'jobs' | 'audit' | 'members' | 'capabilities' | 'settings';
|
||||
|
||||
const NAV_ITEMS: { key: Page; label: string }[] = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'analytics', label: 'Analytics' },
|
||||
{ key: 'members', label: 'Members' },
|
||||
{ key: 'capabilities', label: 'Capabilities' },
|
||||
{ key: 'jobs', label: 'Jobs' },
|
||||
{ key: 'audit', label: 'Audit Log' },
|
||||
{ key: 'settings', label: 'Team Settings' },
|
||||
];
|
||||
|
||||
function pageFromHash(hash: string): Page {
|
||||
const value = hash.replace(/^#/, '');
|
||||
return NAV_ITEMS.some((item) => item.key === value) ? (value as Page) : 'dashboard';
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [page, setPage] = useState<Page>(() => pageFromHash(window.location.hash));
|
||||
const [token, setToken] = useState('');
|
||||
const [teamSlug, setTeamSlug] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromHash = () => setPage(pageFromHash(window.location.hash));
|
||||
window.addEventListener('hashchange', syncFromHash);
|
||||
return () => window.removeEventListener('hashchange', syncFromHash);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
const main = document.querySelector<HTMLElement>('.admin-main');
|
||||
if (main) {
|
||||
main.scrollTop = 0;
|
||||
main.scrollLeft = 0;
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
const navigateTo = (nextPage: Page) => {
|
||||
setPage(nextPage);
|
||||
window.location.hash = nextPage;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-shell" style={{ fontFamily: 'Inter, system-ui, sans-serif' }}>
|
||||
<nav
|
||||
className="admin-sidebar"
|
||||
aria-label="Admin sections"
|
||||
style={{
|
||||
background: '#0d0e12',
|
||||
color: '#f0f2f7',
|
||||
borderRight: '1px solid #2a2d36',
|
||||
}}
|
||||
>
|
||||
<div className="admin-brand" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 20, lineHeight: 1 }}>⬡</span>
|
||||
<h2 style={{ fontSize: 16, margin: 0, letterSpacing: 1, color: '#f0f2f7', fontWeight: 600 }}>Waggle Admin</h2>
|
||||
</div>
|
||||
<ul className="admin-nav-list">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item.key} style={{ marginBottom: 2 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateTo(item.key)}
|
||||
aria-current={page === item.key ? 'page' : undefined}
|
||||
style={{
|
||||
background: page === item.key ? 'rgba(229, 160, 0, 0.08)' : 'transparent',
|
||||
color: page === item.key ? '#f0f2f7' : '#9ca3af',
|
||||
border: 'none',
|
||||
borderLeft: page === item.key ? '2px solid #e5a000' : '2px solid transparent',
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
fontSize: 14,
|
||||
fontWeight: page === item.key ? 500 : 400,
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Connection config at bottom of sidebar */}
|
||||
<div className="admin-connection" style={{ borderTop: '1px solid #2a2d36' }}>
|
||||
<label
|
||||
htmlFor="admin-team-slug"
|
||||
className="admin-field-label"
|
||||
>
|
||||
Team Slug
|
||||
</label>
|
||||
<input
|
||||
id="admin-team-slug"
|
||||
name="teamSlug"
|
||||
type="text"
|
||||
autoComplete="organization"
|
||||
value={teamSlug}
|
||||
onChange={(e) => setTeamSlug(e.target.value)}
|
||||
placeholder="my-team"
|
||||
className="admin-field"
|
||||
style={{
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
color: '#f0f2f7',
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="admin-auth-token"
|
||||
className="admin-field-label"
|
||||
>
|
||||
Auth Token
|
||||
</label>
|
||||
<input
|
||||
id="admin-auth-token"
|
||||
name="authToken"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Bearer token"
|
||||
className="admin-field"
|
||||
style={{
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
color: '#f0f2f7',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="admin-main">
|
||||
<div className="admin-content">
|
||||
{!token || !teamSlug ? (
|
||||
<div style={{ color: '#9ca3af', marginTop: 40, textAlign: 'center' }}>
|
||||
<h2 style={{ color: '#f0f2f7' }}>Connect to a Team</h2>
|
||||
<p>Enter your team slug and auth token in the sidebar to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{page === 'dashboard' && <Dashboard token={token} teamSlug={teamSlug} />}
|
||||
{page === 'analytics' && <Analytics token={token} teamSlug={teamSlug} />}
|
||||
{page === 'members' && <Members token={token} teamSlug={teamSlug} />}
|
||||
{page === 'capabilities' && <Capabilities token={token} teamSlug={teamSlug} />}
|
||||
{page === 'jobs' && <Jobs token={token} teamSlug={teamSlug} />}
|
||||
{page === 'audit' && <Audit token={token} teamSlug={teamSlug} />}
|
||||
{page === 'settings' && <TeamSettings token={token} teamSlug={teamSlug} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
packages/admin-web/src/admin.css
Normal file
@@ -0,0 +1,146 @@
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: #08090c;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: #08090c;
|
||||
color: #f0f2f7;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 220px;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-nav-list {
|
||||
flex: 1;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-connection {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.admin-field-label {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-field {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background: #08090c;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-table-scroll {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.admin-table-scroll:focus-visible {
|
||||
outline: 2px solid #e5a000;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
align-self: stretch;
|
||||
position: static;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow-y: visible;
|
||||
padding: 12px;
|
||||
border-right: 0 !important;
|
||||
border-bottom: 1px solid #2a2d36;
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-nav-list {
|
||||
display: grid;
|
||||
flex: 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-nav-list li {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.admin-connection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.admin-table-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
210
packages/admin-web/src/api.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* API client for the Waggle Admin Dashboard.
|
||||
* Calls the Waggle server REST API.
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3100';
|
||||
|
||||
/**
|
||||
* Extracts a human-readable message from an unknown thrown value.
|
||||
* Use in `catch (err)` blocks where `err` is `unknown` under strict mode.
|
||||
*/
|
||||
export function getErrorMessage(error: unknown, fallback = 'Unexpected error'): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === 'string') return error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function apiFetch<T = unknown>(path: string, token: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function apiMutate<T = unknown>(
|
||||
path: string,
|
||||
token: string,
|
||||
method: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`API error: ${res.status} ${text}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export interface TeamMemberResponse {
|
||||
userId: string;
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
joinedAt?: string;
|
||||
}
|
||||
|
||||
export interface TeamResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
ownerId: string;
|
||||
createdAt: string;
|
||||
members?: TeamMemberResponse[];
|
||||
}
|
||||
|
||||
export interface TaskResponse {
|
||||
id: string;
|
||||
teamId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assignedTo?: string;
|
||||
createdBy: string;
|
||||
parentTaskId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AuditEntryResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
teamId?: string;
|
||||
agentName: string;
|
||||
actionType: string;
|
||||
description: string;
|
||||
requiresApproval: boolean;
|
||||
approved?: boolean;
|
||||
approvedBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface JobResponse {
|
||||
id: string;
|
||||
teamId: string;
|
||||
userId: string;
|
||||
jobType: string;
|
||||
status: string;
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CapabilityPolicyResponse {
|
||||
id: string;
|
||||
teamId: string;
|
||||
role: string;
|
||||
allowedSources: string[];
|
||||
blockedTools: string[];
|
||||
approvalThreshold: string;
|
||||
updatedBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CapabilityOverrideResponse {
|
||||
id: string;
|
||||
teamId: string;
|
||||
capabilityName: string;
|
||||
capabilityType: string;
|
||||
decision: string;
|
||||
reason: string;
|
||||
decidedBy: string;
|
||||
createdAt: string;
|
||||
decidedAt: string;
|
||||
}
|
||||
|
||||
export interface CapabilityRequestResponse {
|
||||
id: string;
|
||||
teamId: string;
|
||||
requestedBy: string;
|
||||
capabilityName: string;
|
||||
capabilityType: string;
|
||||
justification: string;
|
||||
status: string;
|
||||
decidedBy?: string;
|
||||
decisionReason?: string;
|
||||
createdAt: string;
|
||||
decidedAt?: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
activeUsers: { daily: number; weekly: number; monthly: number };
|
||||
tokenUsage: {
|
||||
total: number;
|
||||
byUser: Array<{ userId: string; name: string; tokens: number; cost: number }>;
|
||||
};
|
||||
topTools: Array<{ name: string; invocations: number }>;
|
||||
topCommands: Array<{ name: string; count: number }>;
|
||||
capabilityGaps: Array<{ tool: string; requestCount: number; suggestion: string }>;
|
||||
performanceTrends: {
|
||||
correctionRate: number;
|
||||
correctionTrend: number;
|
||||
avgResponseTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listTeams: (token: string) => apiFetch<TeamResponse[]>('/api/teams', token),
|
||||
getTeam: (token: string, slug: string) =>
|
||||
apiFetch<TeamResponse>(`/api/teams/${encodeURIComponent(slug)}`, token),
|
||||
updateTeam: (token: string, slug: string, data: { name: string }) =>
|
||||
apiMutate<TeamResponse>(`/api/teams/${encodeURIComponent(slug)}`, token, 'PATCH', data),
|
||||
inviteMember: (token: string, slug: string, email: string, role: string) =>
|
||||
apiMutate<TeamMemberResponse>(
|
||||
`/api/teams/${encodeURIComponent(slug)}/members`, token, 'POST', { email, role },
|
||||
),
|
||||
removeMember: (token: string, slug: string, userId: string) =>
|
||||
apiMutate<void>(
|
||||
`/api/teams/${encodeURIComponent(slug)}/members/${encodeURIComponent(userId)}`,
|
||||
token, 'DELETE',
|
||||
),
|
||||
updateMemberRole: (token: string, slug: string, userId: string, role: string) =>
|
||||
apiMutate<TeamMemberResponse>(
|
||||
`/api/teams/${encodeURIComponent(slug)}/members/${encodeURIComponent(userId)}`,
|
||||
token, 'PATCH', { role },
|
||||
),
|
||||
listJobs: (token: string, slug: string) =>
|
||||
apiFetch<JobResponse[]>(`/api/jobs?teamSlug=${encodeURIComponent(slug)}`, token),
|
||||
listCron: (token: string, slug: string) =>
|
||||
apiFetch(`/api/teams/${encodeURIComponent(slug)}/cron`, token),
|
||||
listAudit: (token: string, slug: string) =>
|
||||
apiFetch<AuditEntryResponse[]>(`/api/admin/teams/${encodeURIComponent(slug)}/audit`, token),
|
||||
getStats: (token: string, slug: string) =>
|
||||
apiFetch(`/api/admin/teams/${encodeURIComponent(slug)}/usage`, token),
|
||||
getAnalytics: (token: string, slug: string) =>
|
||||
apiFetch<AnalyticsResponse>(`/api/admin/teams/${encodeURIComponent(slug)}/analytics`, token),
|
||||
listTasks: (token: string, slug: string) =>
|
||||
apiFetch<TaskResponse[]>(`/api/teams/${encodeURIComponent(slug)}/tasks`, token),
|
||||
listScoutFindings: (token: string) => apiFetch('/api/scout/findings', token),
|
||||
listSuggestions: (token: string) => apiFetch('/api/suggestions', token),
|
||||
// Capability Governance
|
||||
listCapabilityPolicies: (token: string, slug: string) =>
|
||||
apiFetch<CapabilityPolicyResponse[]>(`/api/teams/${encodeURIComponent(slug)}/capability-policies`, token),
|
||||
updateCapabilityPolicy: (token: string, slug: string, role: string, data: { allowedSources: string[]; blockedTools: string[]; approvalThreshold: string }) =>
|
||||
apiMutate<CapabilityPolicyResponse>(`/api/teams/${encodeURIComponent(slug)}/capability-policies/${encodeURIComponent(role)}`, token, 'PUT', data),
|
||||
listCapabilityOverrides: (token: string, slug: string) =>
|
||||
apiFetch<CapabilityOverrideResponse[]>(`/api/teams/${encodeURIComponent(slug)}/capability-overrides`, token),
|
||||
createCapabilityOverride: (token: string, slug: string, data: { capabilityName: string; capabilityType: string; decision: string; reason: string }) =>
|
||||
apiMutate<CapabilityOverrideResponse>(`/api/teams/${encodeURIComponent(slug)}/capability-overrides`, token, 'POST', data),
|
||||
deleteCapabilityOverride: (token: string, slug: string, id: string) =>
|
||||
apiMutate<void>(`/api/teams/${encodeURIComponent(slug)}/capability-overrides/${encodeURIComponent(id)}`, token, 'DELETE'),
|
||||
listCapabilityRequests: (token: string, slug: string, status?: string) =>
|
||||
apiFetch<CapabilityRequestResponse[]>(`/api/teams/${encodeURIComponent(slug)}/capability-requests${status ? `?status=${status}` : ''}`, token),
|
||||
decideCapabilityRequest: (token: string, slug: string, id: string, decision: { status: string; reason?: string }) =>
|
||||
apiMutate<CapabilityRequestResponse>(`/api/teams/${encodeURIComponent(slug)}/capability-requests/${encodeURIComponent(id)}`, token, 'PATCH', decision),
|
||||
};
|
||||
10
packages/admin-web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App.js';
|
||||
import './admin.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
416
packages/admin-web/src/pages/Analytics.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Analytics page — team usage analytics dashboard.
|
||||
*
|
||||
* Shows active users, token usage, top tools, capability gaps,
|
||||
* and performance trends. Admin-only.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, getErrorMessage, type AnalyticsResponse } from '../api.js';
|
||||
|
||||
interface AnalyticsProps {
|
||||
token: string;
|
||||
teamSlug: string;
|
||||
}
|
||||
|
||||
/* ─── Shared styles ─── */
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
padding: 20,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
};
|
||||
|
||||
const sectionTitle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: '#9ca3af',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
margin: '0 0 12px',
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
color: '#9ca3af',
|
||||
fontWeight: 600,
|
||||
borderBottom: '1px solid #2a2d36',
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '10px 12px',
|
||||
fontSize: 14,
|
||||
color: '#cbd5e1',
|
||||
borderBottom: '1px solid #1a1d25',
|
||||
};
|
||||
|
||||
const tableStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
};
|
||||
|
||||
/* ─── Sub-components ─── */
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function isAnalyticsResponse(value: unknown): value is AnalyticsResponse {
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
const activeUsers = value.activeUsers;
|
||||
const tokenUsage = value.tokenUsage;
|
||||
const performanceTrends = value.performanceTrends;
|
||||
|
||||
return (
|
||||
isRecord(activeUsers)
|
||||
&& typeof activeUsers.daily === 'number'
|
||||
&& typeof activeUsers.weekly === 'number'
|
||||
&& typeof activeUsers.monthly === 'number'
|
||||
&& isRecord(tokenUsage)
|
||||
&& typeof tokenUsage.total === 'number'
|
||||
&& Array.isArray(tokenUsage.byUser)
|
||||
&& Array.isArray(value.topTools)
|
||||
&& Array.isArray(value.topCommands)
|
||||
&& Array.isArray(value.capabilityGaps)
|
||||
&& isRecord(performanceTrends)
|
||||
&& typeof performanceTrends.correctionRate === 'number'
|
||||
&& typeof performanceTrends.correctionTrend === 'number'
|
||||
&& typeof performanceTrends.avgResponseTime === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveUsersCard({ data }: { data: AnalyticsResponse['activeUsers'] }) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={sectionTitle}>Active Users</h3>
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 'bold', color: '#e5a000' }}>{data.daily}</div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af' }}>Last 24h</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 'bold', color: '#e5a000' }}>{data.weekly}</div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af' }}>Last 7d</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 'bold', color: '#e5a000' }}>{data.monthly}</div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af' }}>Last 30d</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenUsageCard({ data }: { data: AnalyticsResponse['tokenUsage'] }) {
|
||||
const formattedTotal = data.total >= 1000000
|
||||
? `${(data.total / 1000000).toFixed(1)}M`
|
||||
: data.total >= 1000
|
||||
? `${(data.total / 1000).toFixed(1)}K`
|
||||
: String(data.total);
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={sectionTitle}>Token Usage</h3>
|
||||
<div style={{ fontSize: 28, fontWeight: 'bold', color: '#e5a000', marginBottom: 16 }}>
|
||||
{formattedTotal} tokens
|
||||
</div>
|
||||
{data.byUser.length === 0 ? (
|
||||
<p style={{ color: '#9ca3af', fontSize: 13 }}>No usage data yet.</p>
|
||||
) : (
|
||||
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Token usage table" tabIndex={0}>
|
||||
<table style={{ ...tableStyle, minWidth: 480 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>User</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right' }}>Tokens</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right' }}>Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.byUser.map((u) => (
|
||||
<tr key={u.userId}>
|
||||
<td style={tdStyle}>{u.name}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{u.tokens.toLocaleString()}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||
${u.cost.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopToolsCard({ data }: { data: AnalyticsResponse['topTools'] }) {
|
||||
const maxInvocations = data.length > 0 ? Math.max(...data.map((t) => t.invocations)) : 1;
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={sectionTitle}>Top Tools</h3>
|
||||
{data.length === 0 ? (
|
||||
<p style={{ color: '#9ca3af', fontSize: 13 }}>No tool usage data yet.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((tool) => {
|
||||
const widthPercent = Math.max((tool.invocations / maxInvocations) * 100, 2);
|
||||
return (
|
||||
<div key={tool.name}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 500, color: '#f0f2f7' }}>{tool.name}</span>
|
||||
<span style={{ color: '#9ca3af', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{tool.invocations}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
height: 8,
|
||||
background: '#1a1d25',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${widthPercent}%`,
|
||||
height: '100%',
|
||||
background: '#e5a000',
|
||||
borderRadius: 4,
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopCommandsCard({ data }: { data: AnalyticsResponse['topCommands'] }) {
|
||||
const maxCount = data.length > 0 ? Math.max(...data.map((c) => c.count)) : 1;
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={sectionTitle}>Top Commands</h3>
|
||||
{data.length === 0 ? (
|
||||
<p style={{ color: '#9ca3af', fontSize: 13 }}>No command usage data yet.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((cmd) => {
|
||||
const widthPercent = Math.max((cmd.count / maxCount) * 100, 2);
|
||||
return (
|
||||
<div key={cmd.name}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 500, color: '#f0f2f7', fontFamily: 'monospace' }}>{cmd.name}</span>
|
||||
<span style={{ color: '#9ca3af', fontVariantNumeric: 'tabular-nums' }}>{cmd.count}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
height: 8,
|
||||
background: '#1a1d25',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${widthPercent}%`,
|
||||
height: '100%',
|
||||
background: '#a78bfa',
|
||||
borderRadius: 4,
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityGapsCard({ data }: { data: AnalyticsResponse['capabilityGaps'] }) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={sectionTitle}>Capability Gaps</h3>
|
||||
{data.length === 0 ? (
|
||||
<p style={{ color: '#9ca3af', fontSize: 13 }}>No capability gaps detected.</p>
|
||||
) : (
|
||||
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Capability gaps table" tabIndex={0}>
|
||||
<table style={{ ...tableStyle, minWidth: 560 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>Tool</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right' }}>Requests</th>
|
||||
<th style={thStyle}>Suggestion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((gap) => (
|
||||
<tr key={gap.tool}>
|
||||
<td style={tdStyle}>
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
background: 'rgba(229, 160, 0, 0.15)',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#e5a000',
|
||||
}}>
|
||||
{gap.tool}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{gap.requestCount}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontSize: 13, color: '#9ca3af' }}>{gap.suggestion}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceTrendsCard({ data }: { data: AnalyticsResponse['performanceTrends'] }) {
|
||||
const trendColor = data.correctionTrend < 0 ? '#10b981' : data.correctionTrend > 0 ? '#ef4444' : '#6b7280';
|
||||
const trendArrow = data.correctionTrend < 0 ? 'v' : data.correctionTrend > 0 ? '^' : '-';
|
||||
const trendLabel = data.correctionTrend < 0 ? 'improving' : data.correctionTrend > 0 ? 'worsening' : 'stable';
|
||||
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
<h3 style={sectionTitle}>Performance Trends</h3>
|
||||
<div style={{ display: 'flex', gap: 32 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af', marginBottom: 4 }}>Correction Rate</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 'bold', color: '#e5a000' }}>
|
||||
{(data.correctionRate * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: trendColor, fontWeight: 500 }}>
|
||||
{trendArrow} {Math.abs(data.correctionTrend * 100).toFixed(1)}% ({trendLabel})
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af', marginBottom: 4 }}>Avg Response Time</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 'bold', color: '#e5a000' }}>
|
||||
{data.avgResponseTime.toFixed(1)}s
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#9ca3af' }}>per completed job</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Main page ─── */
|
||||
|
||||
export function Analytics({ token, teamSlug }: AnalyticsProps) {
|
||||
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !teamSlug) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData(null);
|
||||
const result = await api.getAnalytics(token, teamSlug);
|
||||
if (!isAnalyticsResponse(result)) {
|
||||
throw new Error('Analytics data is incomplete. Refresh or check the team server version.');
|
||||
}
|
||||
if (!cancelled) setData(result);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setData(null);
|
||||
setError(getErrorMessage(err, 'Failed to load analytics'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [token, teamSlug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Analytics</h1>
|
||||
<p style={{ color: '#9ca3af' }}>Loading analytics...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Usage Analytics</h1>
|
||||
|
||||
{error && (
|
||||
<div role="alert" style={{
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#f87171',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Row 1: Active Users + Performance Trends */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: 16,
|
||||
marginTop: 16,
|
||||
}}>
|
||||
<ActiveUsersCard data={data.activeUsers} />
|
||||
<PerformanceTrendsCard data={data.performanceTrends} />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Token Usage */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<TokenUsageCard data={data.tokenUsage} />
|
||||
</div>
|
||||
|
||||
{/* Row 3: Top Tools + Top Commands */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: 16,
|
||||
marginTop: 24,
|
||||
}}>
|
||||
<TopToolsCard data={data.topTools} />
|
||||
<TopCommandsCard data={data.topCommands} />
|
||||
</div>
|
||||
|
||||
{/* Row 4: Capability Gaps */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<CapabilityGapsCard data={data.capabilityGaps} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
packages/admin-web/src/pages/Audit.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, type AuditEntryResponse } from '../api.js';
|
||||
|
||||
interface AuditProps {
|
||||
token: string;
|
||||
teamSlug: string;
|
||||
}
|
||||
|
||||
const TABLE_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
marginTop: 16,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
};
|
||||
|
||||
const TH_STYLE: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #2a2d36',
|
||||
fontSize: 13,
|
||||
color: '#9ca3af',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
};
|
||||
|
||||
const TD_STYLE: React.CSSProperties = {
|
||||
padding: '10px 16px',
|
||||
fontSize: 14,
|
||||
color: '#cbd5e1',
|
||||
};
|
||||
|
||||
function approvalLabel(entry: AuditEntryResponse): string {
|
||||
if (!entry.requiresApproval) return '—';
|
||||
if (entry.approved === true) return 'Approved';
|
||||
if (entry.approved === false) return 'Rejected';
|
||||
return 'Pending';
|
||||
}
|
||||
|
||||
function approvalColor(entry: AuditEntryResponse): string {
|
||||
if (!entry.requiresApproval) return '#6b7280';
|
||||
if (entry.approved === true) return '#10b981';
|
||||
if (entry.approved === false) return '#ef4444';
|
||||
return '#f59e0b';
|
||||
}
|
||||
|
||||
export function Audit({ token, teamSlug }: AuditProps) {
|
||||
const [entries, setEntries] = useState<AuditEntryResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !teamSlug) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await api.listAudit(token, teamSlug);
|
||||
if (!cancelled) setEntries(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError('Could not load audit log. You may need admin access, or the server is not running.');
|
||||
setEntries([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [token, teamSlug]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Audit Log</h1>
|
||||
|
||||
{error && (
|
||||
<div role="alert" style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(251, 191, 36, 0.1)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#fbbf24',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#9ca3af' }}>Loading audit log...</p>
|
||||
) : entries.length === 0 && !error ? (
|
||||
<div style={{
|
||||
padding: 32,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
textAlign: 'center',
|
||||
color: '#9ca3af',
|
||||
}}>
|
||||
<p style={{ fontSize: 16, marginBottom: 4 }}>No audit entries</p>
|
||||
<p style={{ fontSize: 13 }}>Agent actions will appear here as they are logged.</p>
|
||||
</div>
|
||||
) : entries.length > 0 ? (
|
||||
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Audit log table" tabIndex={0}>
|
||||
<table style={{ ...TABLE_STYLE, minWidth: 720 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>Agent</th>
|
||||
<th style={TH_STYLE}>Action</th>
|
||||
<th style={TH_STYLE}>Approval</th>
|
||||
<th style={TH_STYLE}>Details</th>
|
||||
<th style={TH_STYLE}>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} style={{ borderBottom: '1px solid #1a1d25' }}>
|
||||
<td style={TD_STYLE}>
|
||||
<span style={{ fontWeight: 600, color: '#f0f2f7' }}>{entry.agentName}</span>
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
background: 'rgba(229, 160, 0, 0.12)',
|
||||
color: '#f0b429',
|
||||
}}>
|
||||
{entry.actionType}
|
||||
</span>
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
<span style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: approvalColor(entry),
|
||||
}}>
|
||||
{approvalLabel(entry)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...TD_STYLE, maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{entry.description}
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
942
packages/admin-web/src/pages/Capabilities.tsx
Normal file
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* Capabilities page — team capability governance.
|
||||
*
|
||||
* Three tabs: Role Policies, Overrides, Requests.
|
||||
* Allows admins to manage capability policies, overrides, and review requests.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
api,
|
||||
getErrorMessage,
|
||||
type CapabilityPolicyResponse,
|
||||
type CapabilityOverrideResponse,
|
||||
type CapabilityRequestResponse,
|
||||
} from '../api.js';
|
||||
|
||||
interface CapabilitiesProps {
|
||||
token: string;
|
||||
teamSlug: string;
|
||||
}
|
||||
|
||||
type Tab = 'policies' | 'overrides' | 'requests';
|
||||
|
||||
const TABLE_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse' as const,
|
||||
marginTop: 16,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
};
|
||||
|
||||
const TH_STYLE: React.CSSProperties = {
|
||||
textAlign: 'left' as const,
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #2a2d36',
|
||||
fontSize: 13,
|
||||
color: '#9ca3af',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: 0.5,
|
||||
};
|
||||
|
||||
const TD_STYLE: React.CSSProperties = {
|
||||
padding: '10px 16px',
|
||||
fontSize: 14,
|
||||
color: '#cbd5e1',
|
||||
};
|
||||
|
||||
const WARNING_STYLE: React.CSSProperties = {
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(251, 191, 36, 0.1)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#fbbf24',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
owner: '#10b981',
|
||||
admin: '#3b82f6',
|
||||
member: '#eab308',
|
||||
};
|
||||
|
||||
const THRESHOLD_COLORS: Record<string, string> = {
|
||||
none: '#10b981',
|
||||
low: '#ef4444',
|
||||
medium: '#eab308',
|
||||
high: '#ef4444',
|
||||
};
|
||||
|
||||
const SOURCES = ['native', 'skill', 'plugin', 'mcp', 'subagent'] as const;
|
||||
const CAP_TYPES = ['native', 'skill', 'plugin', 'mcp'] as const;
|
||||
|
||||
function badge(label: string, bg: string, fg: string): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
key={label}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: bg,
|
||||
color: fg,
|
||||
marginRight: 4,
|
||||
display: 'inline-block',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffSec = Math.floor((now - then) / 1000);
|
||||
if (diffSec < 60) return 'just now';
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour}h ago`;
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
// ─── Role Policies Tab ─────────────────────────────────────────────
|
||||
|
||||
function PoliciesTab({ token, teamSlug }: CapabilitiesProps) {
|
||||
const [policies, setPolicies] = useState<CapabilityPolicyResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingRole, setEditingRole] = useState<string | null>(null);
|
||||
const [editSources, setEditSources] = useState<string[]>([]);
|
||||
const [editBlocked, setEditBlocked] = useState('');
|
||||
const [editThreshold, setEditThreshold] = useState('none');
|
||||
const [savingRole, setSavingRole] = useState<string | null>(null);
|
||||
|
||||
const fetchPolicies = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await api.listCapabilityPolicies(token, teamSlug);
|
||||
setPolicies(data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to load policies'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, teamSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && teamSlug) fetchPolicies();
|
||||
}, [token, teamSlug, fetchPolicies]);
|
||||
|
||||
const startEdit = (p: CapabilityPolicyResponse) => {
|
||||
setEditingRole(p.role);
|
||||
setEditSources([...p.allowedSources]);
|
||||
setEditBlocked(p.blockedTools.join(', '));
|
||||
setEditThreshold(p.approvalThreshold);
|
||||
};
|
||||
|
||||
const cancelEdit = () => setEditingRole(null);
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingRole) return;
|
||||
const role = editingRole;
|
||||
try {
|
||||
setError(null);
|
||||
setSavingRole(role);
|
||||
await api.updateCapabilityPolicy(token, teamSlug, role, {
|
||||
allowedSources: editSources,
|
||||
blockedTools: editBlocked
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
approvalThreshold: editThreshold,
|
||||
});
|
||||
setEditingRole(null);
|
||||
await fetchPolicies();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to update policy'));
|
||||
} finally {
|
||||
setSavingRole(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSource = (src: string) => {
|
||||
setEditSources((prev) =>
|
||||
prev.includes(src) ? prev.filter((s) => s !== src) : [...prev, src],
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) return <p style={{ color: '#9ca3af' }}>Loading...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div role="alert" style={WARNING_STYLE}>{error}</div>}
|
||||
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Capability role policies table" tabIndex={0}>
|
||||
<table style={{ ...TABLE_STYLE, minWidth: 760 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>Role</th>
|
||||
<th style={TH_STYLE}>Allowed Sources</th>
|
||||
<th style={TH_STYLE}>Blocked Tools</th>
|
||||
<th style={TH_STYLE}>Approval Threshold</th>
|
||||
<th style={{ ...TH_STYLE, width: 80 }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policies.map((p) => (
|
||||
<tr key={p.role} style={{ borderBottom: '1px solid #1a1d25' }}>
|
||||
<td style={TD_STYLE}>
|
||||
{badge(p.role, (ROLE_COLORS[p.role] ?? '#6b7280') + '20', ROLE_COLORS[p.role] ?? '#6b7280')}
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
{p.allowedSources.map((s) => badge(s, '#4f46e520', '#4f46e5'))}
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
{p.blockedTools.length > 0
|
||||
? p.blockedTools.map((t) => badge(t, '#ef444420', '#ef4444'))
|
||||
: <span style={{ color: '#9ca3af', fontSize: 13 }}>None</span>}
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
{badge(
|
||||
p.approvalThreshold,
|
||||
(THRESHOLD_COLORS[p.approvalThreshold] ?? '#6b7280') + '20',
|
||||
THRESHOLD_COLORS[p.approvalThreshold] ?? '#6b7280',
|
||||
)}
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
<button
|
||||
onClick={() => startEdit(p)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#e5a000',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editingRole && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, fontSize: 15, color: '#f0f2f7' }}>
|
||||
Edit Policy: {editingRole}
|
||||
</h3>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ fontSize: 13, color: '#9ca3af', display: 'block', marginBottom: 4 }}>
|
||||
Allowed Sources
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{SOURCES.map((src) => (
|
||||
<label key={src} style={{ fontSize: 13, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editSources.includes(src)}
|
||||
onChange={() => toggleSource(src)}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
{src}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label htmlFor="policy-blocked-tools" style={{ fontSize: 13, color: '#9ca3af', display: 'block', marginBottom: 4 }}>
|
||||
Blocked Tools (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
id="policy-blocked-tools"
|
||||
name="blockedTools"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={editBlocked}
|
||||
onChange={(e) => setEditBlocked(e.target.value)}
|
||||
placeholder="tool_name_1, tool_name_2"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
color: '#f0f2f7',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label htmlFor="policy-approval-threshold" style={{ fontSize: 13, color: '#9ca3af', display: 'block', marginBottom: 4 }}>
|
||||
Approval Threshold
|
||||
</label>
|
||||
<select
|
||||
id="policy-approval-threshold"
|
||||
name="approvalThreshold"
|
||||
value={editThreshold}
|
||||
onChange={(e) => setEditThreshold(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
color: '#f0f2f7',
|
||||
}}
|
||||
>
|
||||
<option value="none">None</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
disabled={savingRole === editingRole}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e5a000',
|
||||
color: '#08090c',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: savingRole === editingRole ? 'wait' : 'pointer',
|
||||
fontSize: 14,
|
||||
opacity: savingRole === editingRole ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{savingRole === editingRole ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#1a1d25',
|
||||
color: '#cbd5e1',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Overrides Tab ──────────────────────────────────────────────────
|
||||
|
||||
function OverridesTab({ token, teamSlug }: CapabilitiesProps) {
|
||||
const [overrides, setOverrides] = useState<CapabilityOverrideResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formType, setFormType] = useState('native');
|
||||
const [formDecision, setFormDecision] = useState('approved');
|
||||
const [formReason, setFormReason] = useState('');
|
||||
const [creatingOverride, setCreatingOverride] = useState(false);
|
||||
const [removingOverrideId, setRemovingOverrideId] = useState<string | null>(null);
|
||||
|
||||
const fetchOverrides = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await api.listCapabilityOverrides(token, teamSlug);
|
||||
setOverrides(data);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to load overrides'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, teamSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && teamSlug) fetchOverrides();
|
||||
}, [token, teamSlug, fetchOverrides]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formName.trim()) return;
|
||||
try {
|
||||
setError(null);
|
||||
setCreatingOverride(true);
|
||||
await api.createCapabilityOverride(token, teamSlug, {
|
||||
capabilityName: formName.trim(),
|
||||
capabilityType: formType,
|
||||
decision: formDecision,
|
||||
reason: formReason.trim(),
|
||||
});
|
||||
setFormName('');
|
||||
setFormType('native');
|
||||
setFormDecision('approved');
|
||||
setFormReason('');
|
||||
setShowForm(false);
|
||||
await fetchOverrides();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to create override'));
|
||||
} finally {
|
||||
setCreatingOverride(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setRemovingOverrideId(id);
|
||||
await api.deleteCapabilityOverride(token, teamSlug, id);
|
||||
await fetchOverrides();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to remove override'));
|
||||
} finally {
|
||||
setRemovingOverrideId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p style={{ color: '#9ca3af' }}>Loading...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div role="alert" style={WARNING_STYLE}>{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e5a000',
|
||||
color: '#08090c',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
+ Add Override
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<label htmlFor="override-capability-name" style={{ fontSize: 12, color: '#9ca3af', display: 'block', marginBottom: 4 }}>
|
||||
Capability Name
|
||||
</label>
|
||||
<input
|
||||
id="override-capability-name"
|
||||
name="capabilityName"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="e.g. shell_exec"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
color: '#f0f2f7',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="override-capability-type" style={{ fontSize: 12, color: '#9ca3af', display: 'block', marginBottom: 4 }}>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="override-capability-type"
|
||||
name="capabilityType"
|
||||
value={formType}
|
||||
onChange={(e) => setFormType(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
color: '#f0f2f7',
|
||||
}}
|
||||
>
|
||||
{CAP_TYPES.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="override-decision" style={{ fontSize: 12, color: '#9ca3af', display: 'block', marginBottom: 4 }}>
|
||||
Decision
|
||||
</label>
|
||||
<select
|
||||
id="override-decision"
|
||||
name="overrideDecision"
|
||||
value={formDecision}
|
||||
onChange={(e) => setFormDecision(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
color: '#f0f2f7',
|
||||
}}
|
||||
>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="override-reason" style={{ fontSize: 12, color: '#9ca3af', display: 'block', marginBottom: 4 }}>
|
||||
Reason
|
||||
</label>
|
||||
<input
|
||||
id="override-reason"
|
||||
name="overrideReason"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={formReason}
|
||||
onChange={(e) => setFormReason(e.target.value)}
|
||||
placeholder="Optional reason"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
color: '#f0f2f7',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creatingOverride || !formName.trim()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e5a000',
|
||||
color: '#08090c',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: creatingOverride ? 'wait' : 'pointer',
|
||||
fontSize: 14,
|
||||
opacity: creatingOverride || !formName.trim() ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{creatingOverride ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Capability overrides table" tabIndex={0}>
|
||||
<table style={{ ...TABLE_STYLE, minWidth: 680 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>Capability</th>
|
||||
<th style={TH_STYLE}>Type</th>
|
||||
<th style={TH_STYLE}>Decision</th>
|
||||
<th style={TH_STYLE}>Reason</th>
|
||||
<th style={{ ...TH_STYLE, width: 80 }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{overrides.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ ...TD_STYLE, color: '#9ca3af', textAlign: 'center' }}>
|
||||
No overrides configured.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
overrides.map((o) => (
|
||||
<tr key={o.id} style={{ borderBottom: '1px solid #1a1d25' }}>
|
||||
<td style={TD_STYLE}>{o.capabilityName}</td>
|
||||
<td style={TD_STYLE}>
|
||||
{badge(o.capabilityType, '#4f46e520', '#4f46e5')}
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
{o.decision === 'approved'
|
||||
? badge('approved', '#10b98120', '#10b981')
|
||||
: badge('blocked', '#ef444420', '#ef4444')}
|
||||
</td>
|
||||
<td style={{ ...TD_STYLE, color: '#9ca3af', fontSize: 13 }}>
|
||||
{o.reason || '--'}
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
<button
|
||||
onClick={() => handleRemove(o.id)}
|
||||
disabled={removingOverrideId === o.id}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#dc2626',
|
||||
cursor: removingOverrideId === o.id ? 'wait' : 'pointer',
|
||||
fontSize: 13,
|
||||
opacity: removingOverrideId === o.id ? 0.7 : 1,
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
{removingOverrideId === o.id ? 'Removing...' : 'Remove'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Requests Tab ───────────────────────────────────────────────────
|
||||
|
||||
function RequestsTab({
|
||||
token,
|
||||
teamSlug,
|
||||
onPendingCount,
|
||||
}: CapabilitiesProps & { onPendingCount: (n: number) => void }) {
|
||||
const [requests, setRequests] = useState<CapabilityRequestResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [decidingId, setDecidingId] = useState<string | null>(null);
|
||||
const [decisionReason, setDecisionReason] = useState('');
|
||||
const [savingDecision, setSavingDecision] = useState<{ id: string; status: string } | null>(null);
|
||||
|
||||
const fetchRequests = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await api.listCapabilityRequests(token, teamSlug);
|
||||
setRequests(data);
|
||||
onPendingCount(data.filter((r) => r.status === 'pending').length);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to load requests'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, teamSlug, onPendingCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && teamSlug) fetchRequests();
|
||||
}, [token, teamSlug, fetchRequests]);
|
||||
|
||||
const handleDecision = async (id: string, status: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setSavingDecision({ id, status });
|
||||
await api.decideCapabilityRequest(token, teamSlug, id, {
|
||||
status,
|
||||
reason: decisionReason.trim() || undefined,
|
||||
});
|
||||
setDecidingId(null);
|
||||
setDecisionReason('');
|
||||
await fetchRequests();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to process decision'));
|
||||
} finally {
|
||||
setSavingDecision(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p style={{ color: '#9ca3af' }}>Loading...</p>;
|
||||
|
||||
const pending = requests.filter((r) => r.status === 'pending');
|
||||
const decided = requests.filter((r) => r.status !== 'pending');
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div role="alert" style={WARNING_STYLE}>{error}</div>}
|
||||
|
||||
{pending.length === 0 && decided.length === 0 && (
|
||||
<p style={{ color: '#9ca3af' }}>No capability requests.</p>
|
||||
)}
|
||||
|
||||
{pending.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ fontSize: 15, color: '#cbd5e1', marginBottom: 12 }}>
|
||||
Pending Requests ({pending.length})
|
||||
</h3>
|
||||
{pending.map((r) => {
|
||||
const isSavingDecision = savingDecision?.id === r.id;
|
||||
const isSavingApprove = isSavingDecision && savingDecision?.status === 'approved';
|
||||
const isSavingReject = isSavingDecision && savingDecision?.status === 'rejected';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
style={{
|
||||
padding: 16,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: '#f0f2f7' }}>{r.capabilityName}</span>
|
||||
{badge(r.capabilityType, '#4f46e520', '#4f46e5')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#9ca3af', marginBottom: 4 }}>
|
||||
Requested by {r.requestedBy} {r.createdAt ? relativeTime(r.createdAt) : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#cbd5e1', marginBottom: 12 }}>
|
||||
{r.justification}
|
||||
</div>
|
||||
|
||||
{decidingId === r.id ? (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
name="decisionReason"
|
||||
aria-label="Decision reason"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={decisionReason}
|
||||
disabled={isSavingDecision}
|
||||
onChange={(e) => setDecisionReason(e.target.value)}
|
||||
placeholder="Reason (optional)"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
color: '#f0f2f7',
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleDecision(r.id, 'approved')}
|
||||
disabled={isSavingDecision}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: '#10b981',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: isSavingDecision ? 'wait' : 'pointer',
|
||||
fontSize: 13,
|
||||
opacity: isSavingDecision ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isSavingApprove ? 'Saving...' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDecision(r.id, 'rejected')}
|
||||
disabled={isSavingDecision}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: isSavingDecision ? 'wait' : 'pointer',
|
||||
fontSize: 13,
|
||||
opacity: isSavingDecision ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isSavingReject ? 'Saving...' : 'Reject'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDecidingId(null); setDecisionReason(''); }}
|
||||
disabled={isSavingDecision}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: '#1a1d25',
|
||||
color: '#cbd5e1',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: isSavingDecision ? 'wait' : 'pointer',
|
||||
fontSize: 13,
|
||||
opacity: isSavingDecision ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => setDecidingId(r.id)}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: '#10b981',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDecidingId(r.id)}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{decided.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ fontSize: 15, color: '#cbd5e1', marginTop: 24, marginBottom: 12 }}>
|
||||
Decided Requests ({decided.length})
|
||||
</h3>
|
||||
{decided.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
style={{
|
||||
padding: 16,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
marginBottom: 12,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: '#f0f2f7' }}>{r.capabilityName}</span>
|
||||
{badge(r.capabilityType, '#4f46e520', '#4f46e5')}
|
||||
{r.status === 'approved'
|
||||
? badge('approved', '#10b98120', '#10b981')
|
||||
: badge('rejected', '#ef444420', '#ef4444')}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#9ca3af', marginBottom: 4 }}>
|
||||
Requested by {r.requestedBy} {r.createdAt ? relativeTime(r.createdAt) : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#cbd5e1', marginBottom: 4 }}>
|
||||
{r.justification}
|
||||
</div>
|
||||
{r.decisionReason && (
|
||||
<div style={{ fontSize: 13, color: '#9ca3af', fontStyle: 'italic' }}>
|
||||
Decision reason: {r.decisionReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ─────────────────────────────────────────────────
|
||||
|
||||
export function Capabilities({ token, teamSlug }: CapabilitiesProps) {
|
||||
const [tab, setTab] = useState<Tab>('policies');
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'policies', label: 'Role Policies' },
|
||||
{ key: 'overrides', label: 'Overrides' },
|
||||
{ key: 'requests', label: 'Requests' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Capabilities</h1>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid #2a2d36', marginBottom: 16 }}>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: tab === t.key ? '2px solid #e5a000' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: tab === t.key ? 600 : 400,
|
||||
color: tab === t.key ? '#f0f2f7' : '#9ca3af',
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{t.key === 'requests' && pendingCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
padding: '2px 7px',
|
||||
borderRadius: 10,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === 'policies' && <PoliciesTab token={token} teamSlug={teamSlug} />}
|
||||
{tab === 'overrides' && <OverridesTab token={token} teamSlug={teamSlug} />}
|
||||
{tab === 'requests' && (
|
||||
<RequestsTab token={token} teamSlug={teamSlug} onPendingCount={setPendingCount} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
packages/admin-web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, getErrorMessage, type TeamResponse, type TaskResponse } from '../api.js';
|
||||
|
||||
interface DashboardProps {
|
||||
token: string;
|
||||
teamSlug: string;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function StatCard({ title, value }: StatCardProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 20,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: 14, color: '#9ca3af' }}>{title}</h3>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 32, fontWeight: 'bold', color: '#e5a000' }}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
open: '#3b82f6',
|
||||
'in-progress': '#f59e0b',
|
||||
completed: '#10b981',
|
||||
cancelled: '#6b7280',
|
||||
};
|
||||
|
||||
function isAuthError(error: unknown) {
|
||||
return getErrorMessage(error).startsWith('API error: 401');
|
||||
}
|
||||
|
||||
export function Dashboard({ token, teamSlug }: DashboardProps) {
|
||||
const [team, setTeam] = useState<TeamResponse | null>(null);
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !teamSlug) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [teamData, taskData] = await Promise.allSettled([
|
||||
api.getTeam(token, teamSlug),
|
||||
api.listTasks(token, teamSlug),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (teamData.status === 'fulfilled') {
|
||||
setTeam(teamData.value);
|
||||
}
|
||||
if (taskData.status === 'fulfilled') {
|
||||
setTasks(taskData.value);
|
||||
}
|
||||
|
||||
if (teamData.status === 'rejected' && taskData.status === 'rejected') {
|
||||
setError(
|
||||
isAuthError(teamData.reason) || isAuthError(taskData.reason)
|
||||
? 'Authentication failed. Check the admin auth token.'
|
||||
: 'Could not connect to team server. Is it running?',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(getErrorMessage(err, 'Failed to load dashboard data'));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [token, teamSlug]);
|
||||
|
||||
const memberCount = team?.members?.length ?? 0;
|
||||
const activeTasks = tasks.filter((t) => t.status === 'open' || t.status === 'in-progress');
|
||||
const recentTasks = tasks.slice(0, 5);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Dashboard</h1>
|
||||
<p style={{ color: '#9ca3af' }}>Loading dashboard...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>
|
||||
{team ? `${team.name} Dashboard` : 'Dashboard'}
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<div role="alert" style={{
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#f87171',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: 16,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<StatCard title="Members" value={String(memberCount)} />
|
||||
<StatCard title="Active Tasks" value={String(activeTasks.length)} />
|
||||
<StatCard title="Total Tasks" value={String(tasks.length)} />
|
||||
<StatCard title="Team Slug" value={teamSlug} />
|
||||
</div>
|
||||
|
||||
{/* Active Members */}
|
||||
{team?.members && team.members.length > 0 && (
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<h2 style={{ color: '#f0f2f7', borderBottom: '1px solid #2a2d36', paddingBottom: 8 }}>Team Members</h2>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{team.members.map((m) => (
|
||||
<div
|
||||
key={m.userId}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
minWidth: 150,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#f0f2f7' }}>
|
||||
{m.displayName ?? m.userId.slice(0, 8)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#9ca3af',
|
||||
marginTop: 4,
|
||||
}}>
|
||||
{m.role}
|
||||
{m.email ? ` - ${m.email}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Tasks */}
|
||||
<div style={{ marginTop: 32 }}>
|
||||
<h2 style={{ color: '#f0f2f7', borderBottom: '1px solid #2a2d36', paddingBottom: 8 }}>Recent Tasks</h2>
|
||||
{recentTasks.length === 0 ? (
|
||||
<p style={{ color: '#9ca3af' }}>No tasks yet.</p>
|
||||
) : (
|
||||
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Recent tasks table" tabIndex={0}>
|
||||
<table style={{
|
||||
width: '100%',
|
||||
minWidth: 520,
|
||||
borderCollapse: 'collapse',
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #2a2d36' }}>
|
||||
<th style={thStyle}>Title</th>
|
||||
<th style={thStyle}>Status</th>
|
||||
<th style={thStyle}>Priority</th>
|
||||
<th style={thStyle}>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentTasks.map((task) => (
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid #1a1d25' }}>
|
||||
<td style={tdStyle}>{task.title}</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: (STATUS_COLORS[task.status] ?? '#6b7280') + '20',
|
||||
color: STATUS_COLORS[task.status] ?? '#6b7280',
|
||||
}}>
|
||||
{task.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={tdStyle}>{task.priority}</td>
|
||||
<td style={tdStyle}>
|
||||
{new Date(task.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
color: '#9ca3af',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '10px 12px',
|
||||
fontSize: 14,
|
||||
color: '#cbd5e1',
|
||||
};
|
||||
149
packages/admin-web/src/pages/Jobs.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, type JobResponse } from '../api.js';
|
||||
|
||||
interface JobsProps {
|
||||
token: string;
|
||||
teamSlug: string;
|
||||
}
|
||||
|
||||
const TABLE_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
marginTop: 16,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
};
|
||||
|
||||
const TH_STYLE: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #2a2d36',
|
||||
fontSize: 13,
|
||||
color: '#9ca3af',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
};
|
||||
|
||||
const TD_STYLE: React.CSSProperties = {
|
||||
padding: '10px 16px',
|
||||
fontSize: 14,
|
||||
color: '#cbd5e1',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
queued: '#6b7280',
|
||||
running: '#3b82f6',
|
||||
completed: '#10b981',
|
||||
failed: '#ef4444',
|
||||
};
|
||||
|
||||
export function Jobs({ token, teamSlug }: JobsProps) {
|
||||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !teamSlug) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await api.listJobs(token, teamSlug);
|
||||
if (!cancelled) setJobs(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError('Job queue not available. The team server may not support job listing, or is not running.');
|
||||
setJobs([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [token, teamSlug]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Agent Jobs</h1>
|
||||
|
||||
{error && (
|
||||
<div role="alert" style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(251, 191, 36, 0.1)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#fbbf24',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#9ca3af' }}>Loading jobs...</p>
|
||||
) : jobs.length === 0 && !error ? (
|
||||
<div style={{
|
||||
padding: 32,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
textAlign: 'center',
|
||||
color: '#9ca3af',
|
||||
}}>
|
||||
<p style={{ fontSize: 16, marginBottom: 4 }}>No jobs found</p>
|
||||
<p style={{ fontSize: 13 }}>Jobs appear here when agents execute tasks in the team workspace.</p>
|
||||
</div>
|
||||
) : jobs.length > 0 ? (
|
||||
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Agent jobs table" tabIndex={0}>
|
||||
<table style={{ ...TABLE_STYLE, minWidth: 640 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={TH_STYLE}>ID</th>
|
||||
<th style={TH_STYLE}>Type</th>
|
||||
<th style={TH_STYLE}>Status</th>
|
||||
<th style={TH_STYLE}>Created</th>
|
||||
<th style={TH_STYLE}>Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} style={{ borderBottom: '1px solid #1a1d25' }}>
|
||||
<td style={{ ...TD_STYLE, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{job.id.slice(0, 8)}
|
||||
</td>
|
||||
<td style={TD_STYLE}>{job.jobType}</td>
|
||||
<td style={TD_STYLE}>
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: (STATUS_COLORS[job.status] ?? '#6b7280') + '20',
|
||||
color: STATUS_COLORS[job.status] ?? '#6b7280',
|
||||
}}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td style={TD_STYLE}>
|
||||
{job.completedAt ? new Date(job.completedAt).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
packages/admin-web/src/pages/Members.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Members page — team member management.
|
||||
*
|
||||
* View members, invite new ones, change roles, remove.
|
||||
* Talks to team server via api.ts.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { api, getErrorMessage, type TeamMemberResponse } from '../api.js';
|
||||
|
||||
interface MembersProps {
|
||||
token: string;
|
||||
teamSlug: string;
|
||||
}
|
||||
|
||||
const ROLES = ['owner', 'admin', 'member', 'viewer'] as const;
|
||||
|
||||
export function Members({ token, teamSlug }: MembersProps) {
|
||||
const [members, setMembers] = useState<TeamMemberResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Invite form
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState('member');
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [updatingRoleId, setUpdatingRoleId] = useState<string | null>(null);
|
||||
const [pendingRemoval, setPendingRemoval] = useState<{ userId: string; name: string } | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const team = await api.getTeam(token, teamSlug);
|
||||
setMembers(team.members ?? []);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to load members'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, teamSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token && teamSlug) fetchMembers();
|
||||
}, [token, teamSlug, fetchMembers]);
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!inviteEmail.trim()) return;
|
||||
try {
|
||||
setInviting(true);
|
||||
setError(null);
|
||||
await api.inviteMember(token, teamSlug, inviteEmail.trim(), inviteRole);
|
||||
setInviteEmail('');
|
||||
await fetchMembers();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Invite failed'));
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setUpdatingRoleId(userId);
|
||||
await api.updateMemberRole(token, teamSlug, userId, newRole);
|
||||
await fetchMembers();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Role change failed'));
|
||||
} finally {
|
||||
setUpdatingRoleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (userId: string, displayName?: string) => {
|
||||
const name = displayName ?? userId;
|
||||
setPendingRemoval({ userId, name });
|
||||
};
|
||||
|
||||
const confirmRemove = async () => {
|
||||
if (!pendingRemoval) return;
|
||||
try {
|
||||
setRemoving(true);
|
||||
setError(null);
|
||||
await api.removeMember(token, teamSlug, pendingRemoval.userId);
|
||||
setPendingRemoval(null);
|
||||
await fetchMembers();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Remove failed'));
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Team Members</h1>
|
||||
|
||||
{error && (
|
||||
<div role="alert" style={{
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#f87171',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingRemoval && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="member-remove-title"
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
background: 'rgba(248, 113, 113, 0.08)',
|
||||
border: '1px solid rgba(248, 113, 113, 0.32)',
|
||||
borderRadius: 8,
|
||||
color: '#f0f2f7',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div id="member-remove-title" style={{ fontSize: 14, fontWeight: 700, marginBottom: 4 }}>
|
||||
Remove {pendingRemoval.name} from the team?
|
||||
</div>
|
||||
<p style={{ margin: 0, color: '#cbd5e1', fontSize: 13 }}>
|
||||
This revokes their team access. Existing audit records and completed work stay in the team history.
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 10 }}>
|
||||
<button
|
||||
onClick={() => setPendingRemoval(null)}
|
||||
disabled={removing}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
color: '#cbd5e1',
|
||||
cursor: removing ? 'wait' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmRemove}
|
||||
disabled={removing}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: '#f87171',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
color: '#08090c',
|
||||
cursor: removing ? 'wait' : 'pointer',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{removing ? 'Removing...' : 'Remove member'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite form */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginBottom: 24,
|
||||
padding: 16,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<label style={{ ...labelStyle, flex: 1, marginBottom: 0 }}>
|
||||
Invite email
|
||||
<input
|
||||
name="inviteEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="user@example.com"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
marginTop: 4,
|
||||
padding: '8px 12px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
color: '#f0f2f7',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleInvite()}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ ...labelStyle, marginBottom: 0 }}>
|
||||
Invite role
|
||||
<select
|
||||
name="inviteRole"
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value)}
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: 4,
|
||||
padding: '8px 12px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
color: '#f0f2f7',
|
||||
}}
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="member">Member</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleInvite}
|
||||
disabled={inviting || !inviteEmail.trim()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e5a000',
|
||||
color: '#08090c',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: inviting ? 'wait' : 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
opacity: inviting || !inviteEmail.trim() ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{inviting ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Member list */}
|
||||
{loading ? (
|
||||
<p style={{ color: '#9ca3af' }}>Loading members...</p>
|
||||
) : members.length === 0 ? (
|
||||
<p style={{ color: '#9ca3af' }}>No members found. Invite someone to get started.</p>
|
||||
) : (
|
||||
<div className="admin-table-scroll" data-admin-scroll-region="true" role="region" aria-label="Team members table" tabIndex={0}>
|
||||
<table style={{
|
||||
width: '100%',
|
||||
minWidth: 640,
|
||||
borderCollapse: 'collapse',
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #2a2d36' }}>
|
||||
<th style={thStyle}>Name</th>
|
||||
<th style={thStyle}>Email</th>
|
||||
<th style={thStyle}>Role</th>
|
||||
<th style={thStyle}>Joined</th>
|
||||
<th style={{ ...thStyle, width: 80 }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.userId} style={{ borderBottom: '1px solid #1a1d25' }}>
|
||||
<td style={tdStyle}>{m.displayName ?? m.userId}</td>
|
||||
<td style={tdStyle}>{m.email ?? '—'}</td>
|
||||
<td style={tdStyle}>
|
||||
{m.role === 'owner' ? (
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
background: 'rgba(229, 160, 0, 0.15)',
|
||||
color: '#e5a000',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
Owner
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
name={`role-${m.userId}`}
|
||||
aria-label={`Role for ${m.displayName ?? m.userId}`}
|
||||
value={m.role}
|
||||
disabled={updatingRoleId === m.userId}
|
||||
onChange={(e) => handleRoleChange(m.userId, e.target.value)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
color: m.role === 'admin' ? '#f0b429' : m.role === 'member' ? '#cbd5e1' : '#9ca3af',
|
||||
cursor: updatingRoleId === m.userId ? 'wait' : 'pointer',
|
||||
opacity: updatingRoleId === m.userId ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{ROLES.filter((r) => r !== 'owner').map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{updatingRoleId === m.userId && (
|
||||
<span role="status" style={{ display: 'block', marginTop: 4, fontSize: 12, color: '#9ca3af' }}>
|
||||
Updating role...
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
{m.joinedAt ? new Date(m.joinedAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
{m.role !== 'owner' && (
|
||||
<button
|
||||
onClick={() => handleRemove(m.userId, m.displayName)}
|
||||
aria-label={`Remove ${m.displayName ?? m.userId}`}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#f87171',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
color: '#9ca3af',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#9ca3af',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '10px 12px',
|
||||
fontSize: 14,
|
||||
color: '#cbd5e1',
|
||||
};
|
||||
155
packages/admin-web/src/pages/TeamSettings.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* TeamSettings page — edit team name and view team info.
|
||||
*
|
||||
* Minimal — just enough to manage a team without raw API calls.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, getErrorMessage, type TeamResponse } from '../api.js';
|
||||
|
||||
interface TeamSettingsProps {
|
||||
token: string;
|
||||
teamSlug: string;
|
||||
onTeamUpdated?: () => void;
|
||||
}
|
||||
|
||||
export function TeamSettings({ token, teamSlug, onTeamUpdated }: TeamSettingsProps) {
|
||||
const [team, setTeam] = useState<TeamResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !teamSlug) return;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const t = await api.getTeam(token, teamSlug);
|
||||
setTeam(t);
|
||||
setName(t.name);
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to load team'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, teamSlug]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim() || name === team?.name) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const updated = await api.updateTeam(token, teamSlug, { name: name.trim() });
|
||||
setTeam(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
onTeamUpdated?.();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Save failed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p style={{ color: '#9ca3af' }}>Loading team settings...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ marginTop: 0, color: '#f0f2f7' }}>Team Settings</h1>
|
||||
|
||||
{error && (
|
||||
<div role="alert" style={{
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
borderRadius: 4,
|
||||
color: '#f87171',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
padding: 24,
|
||||
background: '#12141a',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)',
|
||||
maxWidth: 480,
|
||||
}}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label htmlFor="team-name" style={labelStyle}>Team Name</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
id="team-name"
|
||||
name="teamName"
|
||||
type="text"
|
||||
autoComplete="organization"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: '#1a1d25',
|
||||
border: '1px solid #2a2d36',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
color: '#f0f2f7',
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim() || name === team?.name}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#e5a000',
|
||||
color: '#08090c',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: saving ? 'wait' : 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
opacity: saving || !name.trim() || name === team?.name ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : saved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Slug</label>
|
||||
<p style={{ margin: 0, fontSize: 14, color: '#cbd5e1' }}>{team?.slug ?? '—'}</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Team ID</label>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#9ca3af', fontFamily: 'monospace' }}>{team?.id ?? '—'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Created</label>
|
||||
<p style={{ margin: 0, fontSize: 14, color: '#cbd5e1' }}>
|
||||
{team?.createdAt ? new Date(team.createdAt).toLocaleDateString() : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#9ca3af',
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
};
|
||||
1
packages/admin-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
661
packages/admin-web/tests/admin-pages.test.ts
Normal file
@@ -0,0 +1,661 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Admin web — comprehensive page component and API module tests.
|
||||
*
|
||||
* Tests module exports, API client methods, URL construction,
|
||||
* and rendering of each page component in various states.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { App } from '../src/App.js';
|
||||
import { Dashboard } from '../src/pages/Dashboard.js';
|
||||
import { Members } from '../src/pages/Members.js';
|
||||
import { Capabilities } from '../src/pages/Capabilities.js';
|
||||
import { Jobs } from '../src/pages/Jobs.js';
|
||||
import { Audit } from '../src/pages/Audit.js';
|
||||
import { TeamSettings } from '../src/pages/TeamSettings.js';
|
||||
import { Analytics } from '../src/pages/Analytics.js';
|
||||
import { api } from '../src/api.js';
|
||||
|
||||
// ─── API Client Tests ──────────────────────────────────────────────────
|
||||
|
||||
describe('API client', () => {
|
||||
it('exports team management methods', () => {
|
||||
expect(typeof api.listTeams).toBe('function');
|
||||
expect(typeof api.getTeam).toBe('function');
|
||||
expect(typeof api.updateTeam).toBe('function');
|
||||
expect(typeof api.inviteMember).toBe('function');
|
||||
expect(typeof api.removeMember).toBe('function');
|
||||
expect(typeof api.updateMemberRole).toBe('function');
|
||||
});
|
||||
|
||||
it('exports job and audit methods', () => {
|
||||
expect(typeof api.listJobs).toBe('function');
|
||||
expect(typeof api.listCron).toBe('function');
|
||||
expect(typeof api.listAudit).toBe('function');
|
||||
expect(typeof api.getStats).toBe('function');
|
||||
});
|
||||
|
||||
it('exports analytics method', () => {
|
||||
expect(typeof api.getAnalytics).toBe('function');
|
||||
});
|
||||
|
||||
it('exports task management method', () => {
|
||||
expect(typeof api.listTasks).toBe('function');
|
||||
});
|
||||
|
||||
it('exports capability governance methods', () => {
|
||||
expect(typeof api.listCapabilityPolicies).toBe('function');
|
||||
expect(typeof api.updateCapabilityPolicy).toBe('function');
|
||||
expect(typeof api.listCapabilityOverrides).toBe('function');
|
||||
expect(typeof api.createCapabilityOverride).toBe('function');
|
||||
expect(typeof api.deleteCapabilityOverride).toBe('function');
|
||||
expect(typeof api.listCapabilityRequests).toBe('function');
|
||||
expect(typeof api.decideCapabilityRequest).toBe('function');
|
||||
});
|
||||
|
||||
it('exports scout and suggestions methods', () => {
|
||||
expect(typeof api.listScoutFindings).toBe('function');
|
||||
expect(typeof api.listSuggestions).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Page Module Export Tests ──────────────────────────────────────────
|
||||
|
||||
describe('Admin page module exports', () => {
|
||||
it('Dashboard is a function component', () => {
|
||||
expect(typeof Dashboard).toBe('function');
|
||||
});
|
||||
|
||||
it('Members is a function component', () => {
|
||||
expect(typeof Members).toBe('function');
|
||||
});
|
||||
|
||||
it('Capabilities is a function component', () => {
|
||||
expect(typeof Capabilities).toBe('function');
|
||||
});
|
||||
|
||||
it('Jobs is a function component', () => {
|
||||
expect(typeof Jobs).toBe('function');
|
||||
});
|
||||
|
||||
it('Audit is a function component', () => {
|
||||
expect(typeof Audit).toBe('function');
|
||||
});
|
||||
|
||||
it('TeamSettings is a function component', () => {
|
||||
expect(typeof TeamSettings).toBe('function');
|
||||
});
|
||||
|
||||
it('Analytics is a function component', () => {
|
||||
expect(typeof Analytics).toBe('function');
|
||||
});
|
||||
|
||||
it('App root is a function component', () => {
|
||||
expect(typeof App).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rendering Tests ───────────────────────────────────────────────────
|
||||
|
||||
// Mock fetch globally for all rendering tests
|
||||
function mockFetch(responses: Record<string, unknown>) {
|
||||
// Sort patterns longest-first so more specific URLs match before shorter ones
|
||||
const sortedPatterns = Object.entries(responses).sort(
|
||||
([a], [b]) => b.length - a.length,
|
||||
);
|
||||
const mock = vi.fn(async (url: string, _opts?: RequestInit) => {
|
||||
// Match URL patterns (longest match first)
|
||||
for (const [pattern, data] of sortedPatterns) {
|
||||
if (url.includes(pattern)) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => data,
|
||||
text: async () => JSON.stringify(data),
|
||||
};
|
||||
}
|
||||
}
|
||||
// Default: 404
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({}),
|
||||
text: async () => 'Not Found',
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
function mockPendingFetch() {
|
||||
const mock = vi.fn(() => new Promise(() => {}));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe('App root component', () => {
|
||||
beforeEach(() => {
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.location.hash = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders the Waggle Admin title', () => {
|
||||
const { container } = render(React.createElement(App));
|
||||
expect(container.textContent).toContain('Waggle Admin');
|
||||
});
|
||||
|
||||
it('shows connect prompt when no token/slug entered', () => {
|
||||
render(React.createElement(App));
|
||||
expect(screen.getByText('Connect to a Team')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders all navigation items', () => {
|
||||
render(React.createElement(App));
|
||||
expect(screen.getByText('Dashboard')).toBeDefined();
|
||||
expect(screen.getByText('Analytics')).toBeDefined();
|
||||
expect(screen.getByText('Members')).toBeDefined();
|
||||
expect(screen.getByText('Capabilities')).toBeDefined();
|
||||
expect(screen.getByText('Jobs')).toBeDefined();
|
||||
expect(screen.getByText('Audit Log')).toBeDefined();
|
||||
expect(screen.getByText('Team Settings')).toBeDefined();
|
||||
});
|
||||
|
||||
it('exposes connection inputs with accessible labels and metadata', () => {
|
||||
render(React.createElement(App));
|
||||
|
||||
const teamSlug = screen.getByLabelText(/team slug/i) as HTMLInputElement;
|
||||
const authToken = screen.getByLabelText(/auth token/i) as HTMLInputElement;
|
||||
|
||||
expect(teamSlug.name).toBe('teamSlug');
|
||||
expect(teamSlug.autocomplete).toBe('organization');
|
||||
expect(authToken.name).toBe('authToken');
|
||||
expect(authToken.autocomplete).toBe('off');
|
||||
});
|
||||
|
||||
it('uses hash deep links and aria-current for admin navigation', () => {
|
||||
window.location.hash = '#members';
|
||||
render(React.createElement(App));
|
||||
|
||||
const members = screen.getByRole('button', { name: 'Members' });
|
||||
const capabilities = screen.getByRole('button', { name: 'Capabilities' });
|
||||
|
||||
expect(members.getAttribute('aria-current')).toBe('page');
|
||||
fireEvent.click(capabilities);
|
||||
expect(window.location.hash).toBe('#capabilities');
|
||||
expect(capabilities.getAttribute('aria-current')).toBe('page');
|
||||
});
|
||||
|
||||
it('ships a responsive admin shell stylesheet for narrow viewports', () => {
|
||||
const cssPath = resolve(dirname(fileURLToPath(import.meta.url)), '../src/admin.css');
|
||||
const css = existsSync(cssPath) ? readFileSync(cssPath, 'utf8') : '';
|
||||
|
||||
expect(css).toContain('@media (max-width: 720px)');
|
||||
expect(css).toContain('.admin-shell');
|
||||
expect(css).toContain('.admin-sidebar');
|
||||
expect(css).toContain('.admin-main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dashboard page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state initially', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(Dashboard, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading dashboard...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders team name and stat cards after data loads', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [
|
||||
{ userId: 'u1', displayName: 'Alice', role: 'owner' },
|
||||
{ userId: 'u2', displayName: 'Bob', role: 'member' },
|
||||
],
|
||||
},
|
||||
'/api/teams/test/tasks': [
|
||||
{ id: 'task1', teamId: 't1', title: 'Fix bug', status: 'open', priority: 'high', createdBy: 'u1', createdAt: '2025-01-10', updatedAt: '2025-01-10' },
|
||||
{ id: 'task2', teamId: 't1', title: 'Write docs', status: 'completed', priority: 'medium', createdBy: 'u2', createdAt: '2025-01-11', updatedAt: '2025-01-11' },
|
||||
],
|
||||
});
|
||||
|
||||
render(React.createElement(Dashboard, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Team Dashboard')).toBeDefined();
|
||||
});
|
||||
|
||||
// Stat cards exist with correct labels
|
||||
expect(screen.getByText('Members')).toBeDefined();
|
||||
expect(screen.getByText('Active Tasks')).toBeDefined();
|
||||
expect(screen.getByText('Total Tasks')).toBeDefined();
|
||||
expect(screen.getByText('Team Slug')).toBeDefined();
|
||||
// Member count '2' appears in stat card (may also appear in task count area)
|
||||
expect(screen.getAllByText('2').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Team members section
|
||||
expect(screen.getByText('Alice')).toBeDefined();
|
||||
expect(screen.getByText('Bob')).toBeDefined();
|
||||
|
||||
// Recent tasks
|
||||
expect(screen.getByText('Fix bug')).toBeDefined();
|
||||
expect(screen.getByText('Write docs')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows error message when both API calls fail', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: async () => ({}),
|
||||
text: async () => 'Internal Server Error',
|
||||
})));
|
||||
|
||||
render(React.createElement(Dashboard, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Could not connect to team server/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows auth guidance when both API calls reject the token', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
json: async () => ({}),
|
||||
text: async () => 'Unauthorized',
|
||||
})));
|
||||
|
||||
render(React.createElement(Dashboard, { token: 'bad-token', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Authentication failed. Check the admin auth token./)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Members page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders heading and invite form', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [
|
||||
{ userId: 'u1', displayName: 'Alice', role: 'owner', email: 'alice@test.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Members, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
expect(screen.getByText('Team Members')).toBeDefined();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alice')).toBeDefined();
|
||||
});
|
||||
|
||||
// Owner should have a badge, not a select
|
||||
expect(screen.getByText('Owner')).toBeDefined();
|
||||
|
||||
// Invite form elements
|
||||
expect(screen.getByPlaceholderText('user@example.com')).toBeDefined();
|
||||
expect(screen.getByText('Invite')).toBeDefined();
|
||||
});
|
||||
|
||||
it('labels invite fields and member role controls', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [
|
||||
{ userId: 'u1', displayName: 'Alice', role: 'owner', email: 'alice@test.com' },
|
||||
{ userId: 'u2', displayName: 'Bob', role: 'member', email: 'bob@test.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Members, { token: 'tok', teamSlug: 'test' }));
|
||||
await screen.findByText('Bob');
|
||||
|
||||
expect(screen.getByLabelText(/invite email/i)).toBeDefined();
|
||||
expect(screen.getByLabelText(/invite role/i)).toBeDefined();
|
||||
expect(screen.getByLabelText(/role for bob/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows empty state when no members', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [],
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Members, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No members found/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('asks in-app before removing a member', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const fetchMock = mockFetch({
|
||||
'/api/teams/test/members/u2': {},
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
members: [
|
||||
{ userId: 'u1', displayName: 'Alice', role: 'owner', email: 'alice@test.com' },
|
||||
{ userId: 'u2', displayName: 'Bob', role: 'member', email: 'bob@test.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Members, { token: 'tok', teamSlug: 'test' }));
|
||||
await screen.findByText('Bob');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove bob/i }));
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(fetchMock.mock.calls.some(([url, opts]) => String(url).includes('/api/teams/test/members/u2') && opts?.method === 'DELETE')).toBe(false);
|
||||
expect(screen.getByRole('dialog').textContent).toMatch(/remove bob from the team/i);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove member/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.mock.calls.some(([url, opts]) => String(url).includes('/api/teams/test/members/u2') && opts?.method === 'DELETE')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Jobs page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(Jobs, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading jobs...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders job list after loading', async () => {
|
||||
mockFetch({
|
||||
'/api/jobs': [
|
||||
{
|
||||
id: 'job-abc12345-long-id',
|
||||
teamId: 't1', userId: 'u1', jobType: 'agent_task',
|
||||
status: 'completed', input: {}, output: {},
|
||||
createdAt: '2025-01-10T12:00:00Z', completedAt: '2025-01-10T12:05:00Z',
|
||||
},
|
||||
{
|
||||
id: 'job-def67890-long-id',
|
||||
teamId: 't1', userId: 'u1', jobType: 'research',
|
||||
status: 'running', input: {},
|
||||
createdAt: '2025-01-11T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(React.createElement(Jobs, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('agent_task')).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText('completed')).toBeDefined();
|
||||
expect(screen.getByText('running')).toBeDefined();
|
||||
expect(screen.getByText('research')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows empty state when no jobs', async () => {
|
||||
mockFetch({ '/api/jobs': [] });
|
||||
|
||||
render(React.createElement(Jobs, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No jobs found')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audit page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(Audit, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading audit log...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders audit entries after loading', async () => {
|
||||
mockFetch({
|
||||
'/api/admin/teams/test/audit': [
|
||||
{
|
||||
id: 'a1', userId: 'u1', agentName: 'waggle-1', actionType: 'tool_use',
|
||||
description: 'Executed shell command', requiresApproval: true, approved: true,
|
||||
createdAt: '2025-01-10T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'a2', userId: 'u1', agentName: 'waggle-1', actionType: 'memory_write',
|
||||
description: 'Stored project context', requiresApproval: false,
|
||||
createdAt: '2025-01-10T11:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(React.createElement(Audit, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('tool_use')).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText('memory_write')).toBeDefined();
|
||||
expect(screen.getByText('Approved')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows empty state when no audit entries', async () => {
|
||||
mockFetch({ '/api/admin/teams/test/audit': [] });
|
||||
|
||||
render(React.createElement(Audit, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No audit entries')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamSettings page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(TeamSettings, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading team settings...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders team info after loading', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 'team-uuid-123', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(TeamSettings, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Test Team')).toBeDefined();
|
||||
});
|
||||
|
||||
// Slug display
|
||||
expect(screen.getByText('test')).toBeDefined();
|
||||
// Team ID display
|
||||
expect(screen.getByText('team-uuid-123')).toBeDefined();
|
||||
// Labels
|
||||
expect(screen.getByText('Team Name')).toBeDefined();
|
||||
expect(screen.getByText('Slug')).toBeDefined();
|
||||
expect(screen.getByText('Team ID')).toBeDefined();
|
||||
expect(screen.getByText('Created')).toBeDefined();
|
||||
});
|
||||
|
||||
it('labels the editable team name field', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 'team-uuid-123', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(TeamSettings, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
const teamName = await screen.findByLabelText(/team name/i);
|
||||
expect((teamName as HTMLInputElement).name).toBe('teamName');
|
||||
});
|
||||
|
||||
it('save button is disabled when name unchanged', async () => {
|
||||
mockFetch({
|
||||
'/api/teams/test': {
|
||||
id: 't1', name: 'Test Team', slug: 'test', ownerId: 'u1', createdAt: '2025-01-01',
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(TeamSettings, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Test Team')).toBeDefined();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText('Save') as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Capabilities page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders heading and tab bar', () => {
|
||||
mockPendingFetch();
|
||||
|
||||
render(React.createElement(Capabilities, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
expect(screen.getByText('Capabilities')).toBeDefined();
|
||||
expect(screen.getByText('Role Policies')).toBeDefined();
|
||||
expect(screen.getByText('Overrides')).toBeDefined();
|
||||
expect(screen.getByText('Requests')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Analytics page', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
mockPendingFetch();
|
||||
render(React.createElement(Analytics, { token: 'tok', teamSlug: 'test' }));
|
||||
expect(screen.getByText('Loading analytics...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders analytics cards after data loads', async () => {
|
||||
mockFetch({
|
||||
'/api/admin/teams/test/analytics': {
|
||||
activeUsers: { daily: 7, weekly: 19, monthly: 42 },
|
||||
tokenUsage: {
|
||||
total: 1500000,
|
||||
byUser: [
|
||||
{ userId: 'u1', name: 'Alice', tokens: 800000, cost: 4.50 },
|
||||
{ userId: 'u2', name: 'Bob', tokens: 700000, cost: 3.20 },
|
||||
],
|
||||
},
|
||||
topTools: [
|
||||
{ name: 'memory_search', invocations: 150 },
|
||||
{ name: 'shell_exec', invocations: 80 },
|
||||
],
|
||||
topCommands: [
|
||||
{ name: '/research', count: 45 },
|
||||
{ name: '/draft', count: 30 },
|
||||
],
|
||||
capabilityGaps: [
|
||||
{ tool: 'browser_navigate', requestCount: 9, suggestion: 'Install browser skill' },
|
||||
],
|
||||
performanceTrends: {
|
||||
correctionRate: 0.15,
|
||||
correctionTrend: -0.03,
|
||||
avgResponseTime: 8.5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(React.createElement(Analytics, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Usage Analytics')).toBeDefined();
|
||||
});
|
||||
|
||||
// Active users section
|
||||
expect(screen.getByText('Active Users')).toBeDefined();
|
||||
expect(screen.getByText('7')).toBeDefined(); // daily
|
||||
expect(screen.getByText('19')).toBeDefined(); // weekly
|
||||
expect(screen.getByText('42')).toBeDefined(); // monthly
|
||||
|
||||
// Token usage
|
||||
expect(screen.getByText('Token Usage')).toBeDefined();
|
||||
expect(screen.getByText('1.5M tokens')).toBeDefined();
|
||||
expect(screen.getByText('Alice')).toBeDefined();
|
||||
expect(screen.getByText('Bob')).toBeDefined();
|
||||
|
||||
// Top tools
|
||||
expect(screen.getByText('Top Tools')).toBeDefined();
|
||||
expect(screen.getByText('memory_search')).toBeDefined();
|
||||
expect(screen.getByText('shell_exec')).toBeDefined();
|
||||
|
||||
// Top commands
|
||||
expect(screen.getByText('Top Commands')).toBeDefined();
|
||||
expect(screen.getByText('/research')).toBeDefined();
|
||||
expect(screen.getByText('/draft')).toBeDefined();
|
||||
|
||||
// Capability gaps
|
||||
expect(screen.getByText('Capability Gaps')).toBeDefined();
|
||||
expect(screen.getByText('browser_navigate')).toBeDefined();
|
||||
|
||||
// Performance trends
|
||||
expect(screen.getByText('Performance Trends')).toBeDefined();
|
||||
expect(screen.getByText('15.0%')).toBeDefined(); // correction rate
|
||||
expect(screen.getByText('8.5s')).toBeDefined(); // avg response time
|
||||
});
|
||||
|
||||
it('shows error when analytics API fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
json: async () => ({}),
|
||||
text: async () => 'Server Error',
|
||||
})));
|
||||
|
||||
render(React.createElement(Analytics, { token: 'tok', teamSlug: 'test' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/API error: 500/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
876
packages/admin-web/tests/admin-rendered.spec.ts
Normal file
@@ -0,0 +1,876 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { securityMiddleware } from '../../server/src/local/security-middleware.js';
|
||||
|
||||
const teamSlug = 'design-team';
|
||||
const authToken = 'test-token';
|
||||
const realAuthTeamSlug = 'admin-real-auth';
|
||||
const realAuthToken = 'admin-real-token';
|
||||
|
||||
const pages = [
|
||||
{ key: 'dashboard', label: 'Dashboard', heading: /Test Team Dashboard|Dashboard/ },
|
||||
{ key: 'analytics', label: 'Analytics', heading: /Usage Analytics/ },
|
||||
{ key: 'members', label: 'Members', heading: /Team Members/ },
|
||||
{ key: 'capabilities', label: 'Capabilities', heading: /Capabilities/ },
|
||||
{ key: 'jobs', label: 'Jobs', heading: /Agent Jobs/ },
|
||||
{ key: 'audit', label: 'Audit Log', heading: /Audit Log/ },
|
||||
{ key: 'settings', label: 'Team Settings', heading: /Team Settings/ },
|
||||
] as const;
|
||||
|
||||
const mockTeam = {
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
slug: teamSlug,
|
||||
ownerId: 'u1',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
members: [
|
||||
{ userId: 'u1', displayName: 'Alice Admin', email: 'alice@example.com', role: 'owner', joinedAt: '2026-01-02T00:00:00Z' },
|
||||
{ userId: 'u2', displayName: 'Bob Builder', email: 'bob@example.com', role: 'admin', joinedAt: '2026-01-03T00:00:00Z' },
|
||||
{ userId: 'u3', displayName: 'Cara Coordinator', email: 'cara@example.com', role: 'member', joinedAt: '2026-01-04T00:00:00Z' },
|
||||
],
|
||||
};
|
||||
|
||||
const mockTasks = [
|
||||
{ id: 'task-1', teamId: 'team-1', title: 'Review enterprise rollout', status: 'open', priority: 'high', createdBy: 'u1', createdAt: '2026-01-05T10:00:00Z', updatedAt: '2026-01-05T10:00:00Z' },
|
||||
{ id: 'task-2', teamId: 'team-1', title: 'Prepare audit packet', status: 'in-progress', priority: 'medium', createdBy: 'u2', createdAt: '2026-01-06T10:00:00Z', updatedAt: '2026-01-06T10:00:00Z' },
|
||||
];
|
||||
|
||||
const mockAnalytics = {
|
||||
activeUsers: { daily: 7, weekly: 19, monthly: 42 },
|
||||
tokenUsage: {
|
||||
total: 1500000,
|
||||
byUser: [
|
||||
{ userId: 'u1', name: 'Alice Admin', tokens: 800000, cost: 4.5 },
|
||||
{ userId: 'u2', name: 'Bob Builder', tokens: 700000, cost: 3.2 },
|
||||
],
|
||||
},
|
||||
topTools: [
|
||||
{ name: 'memory_search', invocations: 150 },
|
||||
{ name: 'shell_exec', invocations: 80 },
|
||||
],
|
||||
topCommands: [
|
||||
{ name: '/research', count: 45 },
|
||||
{ name: '/draft', count: 30 },
|
||||
],
|
||||
capabilityGaps: [
|
||||
{ tool: 'browser_navigate', requestCount: 9, suggestion: 'Install browser skill' },
|
||||
],
|
||||
performanceTrends: { correctionRate: 0.15, correctionTrend: -0.03, avgResponseTime: 8.5 },
|
||||
};
|
||||
|
||||
const mockPolicies = [
|
||||
{ id: 'p1', teamId: 'team-1', role: 'owner', allowedSources: ['native', 'skill', 'mcp'], blockedTools: [], approvalThreshold: 'none', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'p2', teamId: 'team-1', role: 'member', allowedSources: ['native', 'skill'], blockedTools: ['shell_exec'], approvalThreshold: 'medium', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
|
||||
const mockOverrides = [
|
||||
{ id: 'o1', teamId: 'team-1', capabilityName: 'browser_navigate', capabilityType: 'mcp', decision: 'approved', reason: 'Needed for research', decidedBy: 'u1', createdAt: '2026-01-01T00:00:00Z', decidedAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
|
||||
const mockRequests = [
|
||||
{ id: 'r1', teamId: 'team-1', requestedBy: 'u2', capabilityName: 'send_email', capabilityType: 'plugin', justification: 'Send customer follow-up', status: 'pending', createdAt: '2026-01-01T00:00:00Z' },
|
||||
];
|
||||
|
||||
const mockJobs = [
|
||||
{ id: 'job-abc12345-long-id', teamId: 'team-1', userId: 'u1', jobType: 'agent_task', status: 'completed', input: {}, output: {}, createdAt: '2026-01-07T12:00:00Z', completedAt: '2026-01-07T12:05:00Z' },
|
||||
{ id: 'job-def67890-long-id', teamId: 'team-1', userId: 'u2', jobType: 'research', status: 'running', input: {}, createdAt: '2026-01-08T10:00:00Z' },
|
||||
];
|
||||
|
||||
const mockAudit = [
|
||||
{ id: 'a1', userId: 'u1', teamId: 'team-1', agentName: 'waggle-1', actionType: 'tool_use', description: 'Executed shell command for deployment diagnostics', requiresApproval: true, approved: true, approvedBy: 'u1', createdAt: '2026-01-08T10:00:00Z' },
|
||||
{ id: 'a2', userId: 'u2', teamId: 'team-1', agentName: 'waggle-2', actionType: 'memory_write', description: 'Stored project context', requiresApproval: false, createdAt: '2026-01-08T11:00:00Z' },
|
||||
];
|
||||
|
||||
async function fulfillMockAdminApi(route: Route) {
|
||||
const url = new URL(route.request().url());
|
||||
const path = `${url.pathname}${url.search}`;
|
||||
let body: unknown;
|
||||
|
||||
if (path === `/api/teams/${teamSlug}`) body = mockTeam;
|
||||
else if (path === `/api/teams/${teamSlug}/tasks`) body = mockTasks;
|
||||
else if (path === `/api/admin/teams/${teamSlug}/analytics`) body = mockAnalytics;
|
||||
else if (path === `/api/teams/${teamSlug}/capability-policies`) body = mockPolicies;
|
||||
else if (path === `/api/teams/${teamSlug}/capability-overrides`) body = mockOverrides;
|
||||
else if (path === `/api/teams/${teamSlug}/capability-requests`) body = mockRequests;
|
||||
else if (path === `/api/jobs?teamSlug=${teamSlug}`) body = mockJobs;
|
||||
else if (path === `/api/admin/teams/${teamSlug}/audit`) body = mockAudit;
|
||||
else body = {};
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function mockAdminApi(page: Page) {
|
||||
await page.route('http://localhost:3100/**', fulfillMockAdminApi);
|
||||
}
|
||||
|
||||
type RealAuthServer = FastifyInstance;
|
||||
|
||||
async function buildRealAuthServer() {
|
||||
const server = Fastify({ logger: false });
|
||||
await server.register(securityMiddleware, { sessionToken: realAuthToken });
|
||||
server.get(`/api/teams/${realAuthTeamSlug}`, async () => ({
|
||||
id: 'real-auth-team-id',
|
||||
name: 'Real Auth Team',
|
||||
slug: realAuthTeamSlug,
|
||||
ownerId: 'real-auth-owner-id',
|
||||
createdAt: '2026-07-08T00:00:00Z',
|
||||
members: [
|
||||
{
|
||||
userId: 'real-auth-owner-id',
|
||||
displayName: 'Real Auth Owner',
|
||||
email: 'admin-real-auth-owner@test.com',
|
||||
role: 'owner',
|
||||
joinedAt: '2026-07-08T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}));
|
||||
server.get(`/api/teams/${realAuthTeamSlug}/tasks`, async () => ([
|
||||
{
|
||||
id: 'real-auth-task-id',
|
||||
teamId: 'real-auth-team-id',
|
||||
title: 'Review real-auth launch gate',
|
||||
status: 'open',
|
||||
priority: 'high',
|
||||
createdBy: 'real-auth-owner-id',
|
||||
createdAt: '2026-07-08T00:00:00Z',
|
||||
updatedAt: '2026-07-08T00:00:00Z',
|
||||
},
|
||||
]));
|
||||
await server.ready();
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function routeAdminApiThroughServer(page: Page, server: RealAuthServer) {
|
||||
await page.unroute('http://localhost:3100/**');
|
||||
await page.route('http://localhost:3100/**', async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const requestHeaders = request.headers();
|
||||
const response = await server.inject({
|
||||
method: request.method(),
|
||||
url: `${url.pathname}${url.search}`,
|
||||
headers: {
|
||||
...(requestHeaders.authorization ? { authorization: requestHeaders.authorization } : {}),
|
||||
...(requestHeaders['content-type'] ? { 'content-type': requestHeaders['content-type'] } : {}),
|
||||
},
|
||||
payload: request.postData() ?? undefined,
|
||||
});
|
||||
const contentTypeHeader = response.headers['content-type'];
|
||||
const contentType = Array.isArray(contentTypeHeader)
|
||||
? contentTypeHeader[0]
|
||||
: contentTypeHeader;
|
||||
|
||||
await route.fulfill({
|
||||
status: response.statusCode,
|
||||
contentType: typeof contentType === 'string' ? contentType : 'application/json',
|
||||
body: response.body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function connect(page: Page, hash: string) {
|
||||
await page.goto(`/#${hash}`);
|
||||
await page.getByLabel('Team Slug').fill(teamSlug);
|
||||
await page.getByLabel('Auth Token').fill(authToken);
|
||||
}
|
||||
|
||||
async function renderedMetrics(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const overflowers = Array.from(document.querySelectorAll('body *'))
|
||||
.map((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
inScrollRegion: Boolean(el.closest('[data-admin-scroll-region="true"]')),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
||||
width: Math.round(rect.width),
|
||||
left: Math.round(rect.left),
|
||||
right: Math.round(rect.right),
|
||||
};
|
||||
})
|
||||
.filter((item) => !item.inScrollRegion && item.width > 0 && (item.left < -1 || item.right > viewportWidth + 1))
|
||||
.slice(0, 10);
|
||||
|
||||
const tableProblems = Array.from(document.querySelectorAll('table'))
|
||||
.map((table) => {
|
||||
const wrapper = table.closest('[data-admin-scroll-region="true"]');
|
||||
return {
|
||||
hasWrapper: Boolean(wrapper),
|
||||
wrapperLabel: wrapper?.getAttribute('aria-label') ?? '',
|
||||
};
|
||||
})
|
||||
.filter((item) => !item.hasWrapper || !item.wrapperLabel);
|
||||
|
||||
const unlabeledControls = Array.from(document.querySelectorAll('input, select, textarea'))
|
||||
.filter((control) => {
|
||||
const id = control.getAttribute('id');
|
||||
const hasLabel = Boolean(id && document.querySelector(`label[for="${CSS.escape(id)}"]`));
|
||||
const hasAria = Boolean(control.getAttribute('aria-label') || control.getAttribute('aria-labelledby'));
|
||||
const nestedLabel = Boolean(control.closest('label'));
|
||||
return !hasLabel && !hasAria && !nestedLabel;
|
||||
})
|
||||
.map((control) => ({
|
||||
tag: control.tagName.toLowerCase(),
|
||||
name: control.getAttribute('name') ?? '',
|
||||
placeholder: control.getAttribute('placeholder') ?? '',
|
||||
}));
|
||||
|
||||
return {
|
||||
documentScrollWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth,
|
||||
overflowers,
|
||||
tableProblems,
|
||||
unlabeledControls,
|
||||
activeText: document.querySelector('[aria-current="page"]')?.textContent?.trim() ?? null,
|
||||
activeCurrent: document.querySelector('[aria-current="page"]')?.getAttribute('aria-current') ?? null,
|
||||
hasFrameworkOverlay: Boolean(document.querySelector('[data-nextjs-dialog-overlay], vite-error-overlay, .vite-error-overlay')),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function focusedControlName(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLElement)) return '';
|
||||
|
||||
const labelFor = active.id
|
||||
? document.querySelector(`label[for="${CSS.escape(active.id)}"]`)?.textContent?.trim()
|
||||
: '';
|
||||
const labelElement = active.closest('label');
|
||||
const nestedLabel = labelElement
|
||||
? Array.from(labelElement.childNodes)
|
||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: '';
|
||||
|
||||
return active.getAttribute('aria-label')
|
||||
|| active.getAttribute('aria-labelledby')
|
||||
|| labelFor
|
||||
|| nestedLabel
|
||||
|| active.textContent?.replace(/\s+/g, ' ').trim()
|
||||
|| active.getAttribute('placeholder')
|
||||
|| active.getAttribute('name')
|
||||
|| active.tagName.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
async function tabFocusNames(page: Page, count: number) {
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
names.push(await focusedControlName(page));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
async function blurActiveElement(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement) active.blur();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('admin web rendered UX', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockAdminApi(page);
|
||||
});
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1200, height: 800 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
]) {
|
||||
test(`renders all admin pages without overflow or unlabeled controls on ${viewport.name}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
const consoleMessages: string[] = [];
|
||||
page.on('console', (message) => {
|
||||
if (['error', 'warning'].includes(message.type())) consoleMessages.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => consoleMessages.push(error.message));
|
||||
|
||||
for (const item of pages) {
|
||||
await connect(page, item.key);
|
||||
await expect(page.getByRole('heading', { name: item.heading })).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`#${item.key}$`));
|
||||
await expect(page.getByRole('button', { name: item.label })).toHaveAttribute('aria-current', 'page');
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY), { message: `${item.key} scroll position` }).toBe(0);
|
||||
|
||||
const metrics = await renderedMetrics(page);
|
||||
expect(metrics.hasFrameworkOverlay, item.key).toBe(false);
|
||||
expect(metrics.documentScrollWidth, item.key).toBeLessThanOrEqual(metrics.viewportWidth);
|
||||
expect(metrics.overflowers, item.key).toEqual([]);
|
||||
expect(metrics.tableProblems, item.key).toEqual([]);
|
||||
expect(metrics.unlabeledControls, item.key).toEqual([]);
|
||||
}
|
||||
|
||||
expect(consoleMessages).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('keeps admin pages visually stable on desktop and mobile', async ({ page }) => {
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1200, height: 800 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
] as const) {
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
|
||||
for (const item of pages) {
|
||||
await connect(page, item.key);
|
||||
await expect(page.getByRole('heading', { name: item.heading })).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY), { message: `${item.key} scroll position` }).toBe(0);
|
||||
await blurActiveElement(page);
|
||||
await expect(page).toHaveScreenshot(`admin-${item.key}-${viewport.name}.png`, {
|
||||
animations: 'disabled',
|
||||
fullPage: true,
|
||||
maxDiffPixelRatio: 0.01,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('surfaces real server auth failures and renders real server data after a valid token', async ({ page }) => {
|
||||
const server = await buildRealAuthServer();
|
||||
try {
|
||||
await routeAdminApiThroughServer(page, server);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
await page.goto('/#dashboard');
|
||||
await page.getByLabel('Team Slug').fill(realAuthTeamSlug);
|
||||
await page.getByLabel('Auth Token').fill('wrong-token');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toContainText('Authentication failed');
|
||||
|
||||
await page.getByLabel('Auth Token').fill(realAuthToken);
|
||||
await expect(page.getByRole('heading', { name: 'Real Auth Team Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('Real Auth Owner')).toBeVisible();
|
||||
await expect(page.getByText('Review real-auth launch gate')).toBeVisible();
|
||||
|
||||
const metrics = await renderedMetrics(page);
|
||||
expect(metrics.hasFrameworkOverlay).toBe(false);
|
||||
expect(metrics.overflowers).toEqual([]);
|
||||
expect(metrics.unlabeledControls).toEqual([]);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('moves focus through the admin shell with keyboard alone', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await connect(page, 'dashboard');
|
||||
await expect(page.getByRole('heading', { name: /Test Team Dashboard|Dashboard/ })).toBeVisible();
|
||||
|
||||
const focusNames: string[] = [];
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await page.keyboard.press('Tab');
|
||||
focusNames.push(await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
return active?.getAttribute('aria-label')
|
||||
|| active?.textContent?.replace(/\s+/g, ' ').trim()
|
||||
|| active?.getAttribute('placeholder')
|
||||
|| active?.getAttribute('name')
|
||||
|| active?.tagName.toLowerCase()
|
||||
|| '';
|
||||
}));
|
||||
}
|
||||
|
||||
expect(focusNames).toContain('Dashboard');
|
||||
expect(focusNames).toContain('Analytics');
|
||||
expect(focusNames).toContain('Members');
|
||||
expect(focusNames).toContain('Capabilities');
|
||||
});
|
||||
|
||||
test('moves keyboard focus from connection fields into page-level admin controls', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
const keyboardTargets = [
|
||||
{ key: 'dashboard', expected: ['Recent tasks table'] },
|
||||
{ key: 'members', expected: ['Invite email', 'Invite role', 'Invite'] },
|
||||
{ key: 'capabilities', expected: ['Role Policies', 'Overrides', 'Requests', 'Capability role policies table'] },
|
||||
{ key: 'jobs', expected: ['Agent jobs table'] },
|
||||
{ key: 'audit', expected: ['Audit log table'] },
|
||||
{ key: 'settings', expected: ['Team Name'] },
|
||||
] as const;
|
||||
|
||||
for (const item of keyboardTargets) {
|
||||
await connect(page, item.key);
|
||||
const pageInfo = pages.find((candidate) => candidate.key === item.key);
|
||||
if (!pageInfo) throw new Error(`Unknown admin page ${item.key}`);
|
||||
await expect(page.getByRole('heading', { name: pageInfo.heading })).toBeVisible();
|
||||
if (item.key === 'members') {
|
||||
await page.getByLabel('Invite email').fill('keyboard@example.com');
|
||||
}
|
||||
await page.getByLabel('Auth Token').focus();
|
||||
const focusNames = await tabFocusNames(page, 8);
|
||||
|
||||
for (const expected of item.expected) {
|
||||
expect(focusNames, `${item.key} keyboard focus`).toContain(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps hash navigation aligned with browser back and forward', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await connect(page, 'dashboard');
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
|
||||
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole('button', { name: 'Members' }).click();
|
||||
await expect(page).toHaveURL(/#members$/);
|
||||
await expect(page.getByRole('heading', { name: /Team Members/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Members' })).toHaveAttribute('aria-current', 'page');
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
|
||||
|
||||
await page.getByRole('button', { name: 'Capabilities' }).click();
|
||||
await expect(page).toHaveURL(/#capabilities$/);
|
||||
await expect(page.getByRole('heading', { name: /Capabilities/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Capabilities' })).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#members$/);
|
||||
await expect(page.getByRole('heading', { name: /Team Members/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Members' })).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#dashboard$/);
|
||||
await expect(page.getByRole('heading', { name: /Test Team Dashboard|Dashboard/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/#members$/);
|
||||
await expect(page.getByRole('heading', { name: /Team Members/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Members' })).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/#capabilities$/);
|
||||
await expect(page.getByRole('heading', { name: /Capabilities/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Capabilities' })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('capability governance forms stay labelled and mobile-safe', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await connect(page, 'capabilities');
|
||||
await expect(page.getByRole('heading', { name: /Capabilities/ })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).first().click();
|
||||
let metrics = await renderedMetrics(page);
|
||||
expect(metrics.overflowers, 'policy edit').toEqual([]);
|
||||
expect(metrics.tableProblems, 'policy edit').toEqual([]);
|
||||
expect(metrics.unlabeledControls, 'policy edit').toEqual([]);
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
await page.getByRole('button', { name: 'Overrides' }).click();
|
||||
await expect(page.getByText('browser_navigate')).toBeVisible();
|
||||
await page.getByRole('button', { name: '+ Add Override' }).click();
|
||||
metrics = await renderedMetrics(page);
|
||||
expect(metrics.overflowers, 'override form').toEqual([]);
|
||||
expect(metrics.tableProblems, 'override form').toEqual([]);
|
||||
expect(metrics.unlabeledControls, 'override form').toEqual([]);
|
||||
|
||||
await page.getByRole('button', { name: 'Requests' }).click();
|
||||
await expect(page.getByText('send_email')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
metrics = await renderedMetrics(page);
|
||||
expect(metrics.overflowers, 'request decision').toEqual([]);
|
||||
expect(metrics.unlabeledControls, 'request decision').toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps capability policy save recoverable when the mutation fails', async ({ page }) => {
|
||||
await page.unroute('http://localhost:3100/**');
|
||||
|
||||
let policySaveRoute: Route | null = null;
|
||||
let resolvePolicySave: () => void = () => {};
|
||||
const policySaveStarted = new Promise<void>((resolve) => {
|
||||
resolvePolicySave = resolve;
|
||||
});
|
||||
|
||||
await page.route('http://localhost:3100/**', async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (request.method() === 'PUT' && url.pathname.includes('/capability-policies/')) {
|
||||
policySaveRoute = route;
|
||||
resolvePolicySave();
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillMockAdminApi(route);
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await connect(page, 'capabilities');
|
||||
await expect(page.getByRole('heading', { name: /Capabilities/ })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).first().click();
|
||||
await expect(page.getByRole('heading', { name: /Edit Policy:/ })).toBeVisible();
|
||||
|
||||
const saveButton = page.getByRole('button', { name: 'Save' });
|
||||
await saveButton.click();
|
||||
await policySaveStarted;
|
||||
|
||||
const savingButton = page.getByRole('button', { name: 'Saving...' });
|
||||
await expect(savingButton).toBeDisabled();
|
||||
|
||||
const pendingRoute = policySaveRoute;
|
||||
expect(pendingRoute, 'policy save request was sent').not.toBeNull();
|
||||
await pendingRoute!.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'policy denied',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('policy denied');
|
||||
await expect(page.getByRole('heading', { name: /Edit Policy:/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
|
||||
const metrics = await renderedMetrics(page);
|
||||
expect(metrics.hasFrameworkOverlay).toBe(false);
|
||||
expect(metrics.overflowers).toEqual([]);
|
||||
expect(metrics.unlabeledControls).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps capability override and request mutations recoverable when they fail', async ({ page }) => {
|
||||
await page.unroute('http://localhost:3100/**');
|
||||
|
||||
let overrideCreateRoute: Route | null = null;
|
||||
let requestDecisionRoute: Route | null = null;
|
||||
let resolveOverrideCreate: () => void = () => {};
|
||||
let resolveRequestDecision: () => void = () => {};
|
||||
const overrideCreateStarted = new Promise<void>((resolve) => {
|
||||
resolveOverrideCreate = resolve;
|
||||
});
|
||||
const requestDecisionStarted = new Promise<void>((resolve) => {
|
||||
resolveRequestDecision = resolve;
|
||||
});
|
||||
|
||||
await page.route('http://localhost:3100/**', async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (request.method() === 'POST' && url.pathname.endsWith('/capability-overrides')) {
|
||||
overrideCreateRoute = route;
|
||||
resolveOverrideCreate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'PATCH' && url.pathname.includes('/capability-requests/')) {
|
||||
requestDecisionRoute = route;
|
||||
resolveRequestDecision();
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillMockAdminApi(route);
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await connect(page, 'capabilities');
|
||||
await expect(page.getByRole('heading', { name: /Capabilities/ })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Overrides' }).click();
|
||||
await page.getByRole('button', { name: '+ Add Override' }).click();
|
||||
await page.getByLabel('Capability Name').fill('shell_exec');
|
||||
await page.getByLabel('Reason').fill('Production incident response');
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await overrideCreateStarted;
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Submitting...' })).toBeDisabled();
|
||||
|
||||
const pendingOverrideRoute = overrideCreateRoute;
|
||||
expect(pendingOverrideRoute, 'override create request was sent').not.toBeNull();
|
||||
await pendingOverrideRoute!.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'override denied',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('override denied');
|
||||
await expect(page.getByLabel('Capability Name')).toHaveValue('shell_exec');
|
||||
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
|
||||
|
||||
await page.getByRole('button', { name: 'Requests' }).click();
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
await page.getByLabel('Decision reason').fill('Reviewed by admin');
|
||||
await page.getByRole('button', { name: 'Approve' }).click();
|
||||
await requestDecisionStarted;
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Saving...' })).toBeDisabled();
|
||||
|
||||
const pendingDecisionRoute = requestDecisionRoute;
|
||||
expect(pendingDecisionRoute, 'request decision request was sent').not.toBeNull();
|
||||
await pendingDecisionRoute!.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'decision denied',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('decision denied');
|
||||
await expect(page.getByLabel('Decision reason')).toHaveValue('Reviewed by admin');
|
||||
await expect(page.getByRole('button', { name: 'Approve' })).toBeEnabled();
|
||||
|
||||
const metrics = await renderedMetrics(page);
|
||||
expect(metrics.hasFrameworkOverlay).toBe(false);
|
||||
expect(metrics.overflowers).toEqual([]);
|
||||
expect(metrics.unlabeledControls).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps member invite and team settings mutations recoverable when they fail', async ({ page }) => {
|
||||
await page.unroute('http://localhost:3100/**');
|
||||
|
||||
let inviteRoute: Route | null = null;
|
||||
let settingsRoute: Route | null = null;
|
||||
let resolveInvite: () => void = () => {};
|
||||
let resolveSettings: () => void = () => {};
|
||||
const inviteStarted = new Promise<void>((resolve) => {
|
||||
resolveInvite = resolve;
|
||||
});
|
||||
const settingsStarted = new Promise<void>((resolve) => {
|
||||
resolveSettings = resolve;
|
||||
});
|
||||
|
||||
await page.route('http://localhost:3100/**', async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (request.method() === 'POST' && url.pathname.endsWith('/members')) {
|
||||
inviteRoute = route;
|
||||
resolveInvite();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'PATCH' && url.pathname === `/api/teams/${teamSlug}`) {
|
||||
settingsRoute = route;
|
||||
resolveSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillMockAdminApi(route);
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await connect(page, 'members');
|
||||
await expect(page.getByRole('heading', { name: /Team Members/ })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Invite email').fill('new.admin@example.com');
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await inviteStarted;
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Inviting...' })).toBeDisabled();
|
||||
|
||||
const pendingInviteRoute = inviteRoute;
|
||||
expect(pendingInviteRoute, 'member invite request was sent').not.toBeNull();
|
||||
await pendingInviteRoute!.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'invite rejected',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('invite rejected');
|
||||
await expect(page.getByLabel('Invite email')).toHaveValue('new.admin@example.com');
|
||||
await expect(page.getByRole('button', { name: 'Invite' })).toBeEnabled();
|
||||
|
||||
await page.getByRole('button', { name: 'Team Settings' }).click();
|
||||
await expect(page.getByRole('heading', { name: /Team Settings/ })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Team Name').fill('Renamed Team');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await settingsStarted;
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Saving...' })).toBeDisabled();
|
||||
|
||||
const pendingSettingsRoute = settingsRoute;
|
||||
expect(pendingSettingsRoute, 'team settings save request was sent').not.toBeNull();
|
||||
await pendingSettingsRoute!.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'settings rejected',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('settings rejected');
|
||||
await expect(page.getByLabel('Team Name')).toHaveValue('Renamed Team');
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
|
||||
const metrics = await renderedMetrics(page);
|
||||
expect(metrics.hasFrameworkOverlay).toBe(false);
|
||||
expect(metrics.overflowers).toEqual([]);
|
||||
expect(metrics.unlabeledControls).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps member role, member removal, and override removal recoverable when they fail', async ({ page }) => {
|
||||
await page.unroute('http://localhost:3100/**');
|
||||
|
||||
let roleChangeRoute: Route | null = null;
|
||||
let memberRemovalRoute: Route | null = null;
|
||||
let overrideRemovalRoute: Route | null = null;
|
||||
let resolveRoleChange: () => void = () => {};
|
||||
let resolveMemberRemoval: () => void = () => {};
|
||||
let resolveOverrideRemoval: () => void = () => {};
|
||||
const roleChangeStarted = new Promise<void>((resolve) => {
|
||||
resolveRoleChange = resolve;
|
||||
});
|
||||
const memberRemovalStarted = new Promise<void>((resolve) => {
|
||||
resolveMemberRemoval = resolve;
|
||||
});
|
||||
const overrideRemovalStarted = new Promise<void>((resolve) => {
|
||||
resolveOverrideRemoval = resolve;
|
||||
});
|
||||
|
||||
await page.route('http://localhost:3100/**', async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
|
||||
if (request.method() === 'PATCH' && url.pathname.endsWith('/members/u2')) {
|
||||
roleChangeRoute = route;
|
||||
resolveRoleChange();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'DELETE' && url.pathname.endsWith('/members/u2')) {
|
||||
memberRemovalRoute = route;
|
||||
resolveMemberRemoval();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === 'DELETE' && url.pathname.endsWith('/capability-overrides/o1')) {
|
||||
overrideRemovalRoute = route;
|
||||
resolveOverrideRemoval();
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillMockAdminApi(route);
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await connect(page, 'members');
|
||||
await expect(page.getByRole('heading', { name: /Team Members/ })).toBeVisible();
|
||||
|
||||
const bobRole = page.getByLabel('Role for Bob Builder');
|
||||
await bobRole.selectOption('viewer');
|
||||
await roleChangeStarted;
|
||||
await expect(page.getByRole('status')).toContainText('Updating role');
|
||||
await expect(bobRole).toBeDisabled();
|
||||
|
||||
const pendingRoleRoute = roleChangeRoute;
|
||||
expect(pendingRoleRoute, 'member role-change request was sent').not.toBeNull();
|
||||
await pendingRoleRoute!.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'role rejected',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('role rejected');
|
||||
await expect(page.getByLabel('Role for Bob Builder')).toBeEnabled();
|
||||
await expect(page.getByLabel('Role for Bob Builder')).toHaveValue('admin');
|
||||
|
||||
await page.getByRole('button', { name: 'Remove Bob Builder' }).click();
|
||||
await expect(page.getByRole('dialog')).toContainText('Remove Bob Builder from the team?');
|
||||
await page.getByRole('button', { name: 'Remove member' }).click();
|
||||
await memberRemovalStarted;
|
||||
await expect(page.getByRole('button', { name: 'Removing...' })).toBeDisabled();
|
||||
|
||||
const pendingMemberRemovalRoute = memberRemovalRoute;
|
||||
expect(pendingMemberRemovalRoute, 'member removal request was sent').not.toBeNull();
|
||||
await pendingMemberRemovalRoute!.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'remove rejected',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('remove rejected');
|
||||
await expect(page.getByRole('dialog')).toContainText('Remove Bob Builder from the team?');
|
||||
await expect(page.getByRole('button', { name: 'Remove member' })).toBeEnabled();
|
||||
|
||||
await page.getByRole('button', { name: 'Capabilities' }).click();
|
||||
await page.getByRole('button', { name: 'Overrides' }).click();
|
||||
await expect(page.getByText('browser_navigate')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
await overrideRemovalStarted;
|
||||
await expect(page.getByRole('button', { name: 'Removing...' })).toBeDisabled();
|
||||
|
||||
const pendingOverrideRemovalRoute = overrideRemovalRoute;
|
||||
expect(pendingOverrideRemovalRoute, 'override removal request was sent').not.toBeNull();
|
||||
await pendingOverrideRemovalRoute!.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'override removal rejected',
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('override removal rejected');
|
||||
await expect(page.getByText('browser_navigate')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Remove' })).toBeEnabled();
|
||||
|
||||
const metrics = await renderedMetrics(page);
|
||||
expect(metrics.hasFrameworkOverlay).toBe(false);
|
||||
expect(metrics.overflowers).toEqual([]);
|
||||
expect(metrics.unlabeledControls).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps the shell usable when analytics returns malformed data', async ({ page }) => {
|
||||
await page.unroute('http://localhost:3100/**');
|
||||
await page.route('http://localhost:3100/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = `${url.pathname}${url.search}`;
|
||||
const body = path === `/api/admin/teams/${teamSlug}/analytics`
|
||||
? { tokenUsage: { total: 5, byUser: [] } }
|
||||
: {};
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await connect(page, 'analytics');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Analytics' })).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.getByRole('alert')).toContainText('Analytics data is incomplete');
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('shows accessible page errors without breaking the admin shell when APIs fail', async ({ page }) => {
|
||||
await page.unroute('http://localhost:3100/**');
|
||||
await page.route('http://localhost:3100/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'text/plain',
|
||||
body: 'server unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
for (const item of pages) {
|
||||
await connect(page, item.key);
|
||||
await expect(page.getByRole('button', { name: item.label })).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.getByRole('heading', { name: item.heading })).toBeVisible();
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
|
||||
const metrics = await renderedMetrics(page);
|
||||
expect(metrics.hasFrameworkOverlay, item.key).toBe(false);
|
||||
expect(metrics.documentScrollWidth, item.key).toBeLessThanOrEqual(metrics.viewportWidth);
|
||||
expect(metrics.overflowers, item.key).toEqual([]);
|
||||
expect(metrics.unlabeledControls, item.key).toEqual([]);
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
}
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
15
packages/admin-web/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
packages/admin-web/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5173 },
|
||||
});
|
||||