moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

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

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

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

View 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 }}>&#x2B21;</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>
);
}

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

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

View 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>,
);

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

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

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

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

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

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

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

@@ -0,0 +1 @@
/// <reference types="vite/client" />

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

View 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([]);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View 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"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 5173 },
});