Compare commits

..
Author SHA1 Message Date
fred 339e147f77 style: prettier formatting in identity-lifecycle.md
ci/woodpecker/pr/ci Pipeline was successful
2026-08-26 18:21:39 -05:00
fred e595855adc docs: identity lifecycle contract revision 3
ci/woodpecker/pr/ci Pipeline is pending
Addresses the revision-2 re-review residuals and new findings:
- emailVerified joins the canonical set; defined reset rule on email change
- external-principal uniqueness moved to (issuer, subject)
- 'usable login method' defined for unlink refusal
- shipped delete affordances (web admin, mosaic CLI) must be removed or
  disabled with a defined visible state; deactivation exposed instead
- admin-creation switch dropped in favor of plain admin authorization
- verification matrix extended: IdP removal, forward-auth non-use,
  first-admin SSO, wizard-recorded JIT choice, step-up positive/expiry,
  issuer-aliasing witness, UI/CLI delete assertions, docs observable
2026-08-26 18:21:22 -05:00
fred f507b2bc79 docs: identity lifecycle contract rev 2 — address independent review findings 1-9
ci/woodpecker/pr/ci Pipeline was canceled
- §3: durable fail-closed epoch + writer-level refusal + atomic transition
  (adapter must be transaction-bound or bootstrap writes users itself);
  first-admin SSO defined as the bootstrap writer, not JIT
- §5: canonical account email split from per-principal provider claims;
  explicit email-change workflow only
- §6: external principal keyed by issuer+subject, DB-unique, immutable
  provider->issuer binding; explicit link requires step-up reauth
- §2/§4: JIT per-provider default off always, wizard-explicit enable;
  effective closed during epoch; allowlist requires verified email
- §7.1: measurable deactivation (atomic-or-fail-closed ban+revocation,
  all transports, 30s/next-message socket bound, admin tokens)
- §7.3: deletion gated here; hard-delete endpoint fails closed until a
  chartered deletion/retention contract ratifies (contract 7 is D14 custody)
- §1.4: account creates no workspace membership/authority (SOT REQ-TEN-001)
- §8: expanded verification matrix with bounded observables
- factual fixes: 21 FK constraints across 19 tables; ci-postgres is the
  postgres service of the test step
2026-08-26 18:05:31 -05:00
fred 4d3de6c58c docs: identity account-lifecycle contract (S2 contract 4, D10/Q1=O1; folds #1430 bootstrap invariant)
ci/woodpecker/pr/ci Pipeline was canceled
2026-08-26 17:49:24 -05:00
7 changed files with 4 additions and 2111 deletions
+4 -8
View File
@@ -13,9 +13,8 @@ import {
TasksRouteErrorBoundary,
} from '@/spa/pages/resource-route-error-boundaries';
import { TasksPage } from '@/spa/pages/tasks';
import { SettingsPage } from '@/spa/pages/settings';
import { AdminPage } from '@/spa/pages/admin';
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
import { AuthGuard, GuestGuard } from '@/spa/guards';
import { Placeholder } from '@/spa/placeholder';
function GuestLayout(): ReactElement {
return (
@@ -57,11 +56,8 @@ export const routes: RouteObject[] = [
errorElement: <ProjectDetailRouteErrorBoundary />,
},
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
{ path: '/settings', element: <SettingsPage /> },
{
element: <AdminGuard />,
children: [{ path: '/admin', element: <AdminPage /> }],
},
{ path: '/settings', element: <Placeholder title="Settings" /> },
{ path: '/admin', element: <Placeholder title="Admin" /> },
],
},
];
-20
View File
@@ -21,23 +21,3 @@ export function AuthGuard(): ReactElement {
return session ? <Outlet /> : <Navigate to="/login" replace />;
}
export function AdminGuard(): ReactElement {
const { data: session, isPending } = useSession();
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (!session) {
return <Navigate to="/login" replace />;
}
const user = session.user as typeof session.user & { role?: string };
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
}
-206
View File
@@ -1,206 +0,0 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
const { apiMock, useSessionMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
useSessionMock: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
api: apiMock,
}));
vi.mock('@/lib/auth-client', () => ({
useSession: useSessionMock,
authClient: {},
}));
import { AdminPage } from './admin';
import { AdminGuard } from '@/spa/guards';
const userFixtures = {
users: [
{
id: 'u-admin',
name: 'Ada Admin',
email: '[email protected]',
role: 'admin',
banned: false,
banReason: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
{
id: 'u-member',
name: 'Mel Member',
email: '[email protected]',
role: 'member',
banned: true,
banReason: 'spam',
createdAt: '2026-08-02T00:00:00.000Z',
updatedAt: '2026-08-02T00:00:00.000Z',
},
],
total: 2,
};
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
apiMock.mockReset();
useSessionMock.mockReset();
});
async function renderAdminRoute(): Promise<void> {
const routes: RouteObject[] = [
{
element: <AdminGuard />,
children: [{ path: '/admin', element: <AdminPage /> }],
},
{ path: '/', element: <div>home page</div> },
{ path: '/login', element: <div>login page</div> },
];
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
}
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
return {
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
isPending: false,
};
}
describe('AdminGuard', () => {
it('redirects unauthenticated visitors to /login', async () => {
useSessionMock.mockReturnValue({ data: null, isPending: false });
await renderAdminRoute();
expect(container.textContent).toContain('login page');
expect(apiMock).not.toHaveBeenCalled();
});
it('redirects non-admin users to /', async () => {
useSessionMock.mockReturnValue(sessionWithRole('member'));
await renderAdminRoute();
expect(container.textContent).toContain('home page');
expect(apiMock).not.toHaveBeenCalled();
});
it('renders the admin page for admin users', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValueOnce(userFixtures);
await renderAdminRoute();
expect(container.textContent).toContain('Admin Panel');
});
});
describe('AdminPage users tab', () => {
it('lists users with role and ban status after load', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValueOnce(userFixtures);
await renderAdminRoute();
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
expect(container.textContent).toContain('Ada Admin');
expect(container.textContent).toContain('Mel Member');
expect(container.textContent).toContain('Banned');
expect(container.textContent).toContain('2 user(s)');
});
it('shows the load error with a retry control', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
await renderAdminRoute();
expect(container.textContent).toContain('gateway unavailable');
apiMock.mockResolvedValueOnce(userFixtures);
const retry = [...container.querySelectorAll('button')].find((b) =>
b.textContent?.includes('Retry'),
);
expect(retry).toBeTruthy();
await act(async () => {
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.textContent).toContain('Ada Admin');
});
it('posts to the ban endpoint and reloads on Ban', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValue(userFixtures);
await renderAdminRoute();
const banButton = [...container.querySelectorAll('button')].find(
(b) => b.textContent === 'Ban',
);
expect(banButton).toBeTruthy();
await act(async () => {
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
});
});
describe('AdminPage health tab', () => {
it('loads health status when the tab is opened', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
status: 'ok',
database: { status: 'ok', latencyMs: 3 },
cache: { status: 'ok', latencyMs: 1 },
agentPool: { activeSessions: 2 },
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
checkedAt: '2026-08-26T00:00:00.000Z',
});
await renderAdminRoute();
const healthTab = [...container.querySelectorAll('button')].find((b) =>
b.textContent?.includes('System Health'),
);
await act(async () => {
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
expect(container.textContent).toContain('Database (PostgreSQL)');
expect(container.textContent).toContain('Active sessions: 2');
expect(container.textContent).toContain('4 models');
});
});
-522
View File
@@ -1,522 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
// ── Types ──────────────────────────────────────────────────────────────────────
interface UserDto {
id: string;
name: string;
email: string;
role: string;
banned: boolean;
banReason: string | null;
createdAt: string;
updatedAt: string;
}
interface UserListDto {
users: UserDto[];
total: number;
}
interface ServiceStatusDto {
status: 'ok' | 'error';
latencyMs?: number;
error?: string;
}
interface ProviderStatusDto {
id: string;
name: string;
available: boolean;
modelCount: number;
}
interface HealthStatusDto {
status: 'ok' | 'degraded' | 'error';
database: ServiceStatusDto;
cache: ServiceStatusDto;
agentPool: { activeSessions: number };
providers: ProviderStatusDto[];
checkedAt: string;
}
// ── Admin Page ─────────────────────────────────────────────────────────────────
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
// assumes an authenticated admin session.
export function AdminPage(): React.ReactElement {
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
</div>
<div className="flex gap-1 border-b border-surface-border">
{(['users', 'health'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
'px-4 py-2 text-sm font-medium capitalize transition-colors',
activeTab === tab
? 'border-b-2 border-blue-500 text-blue-400'
: 'text-text-secondary hover:text-text-primary',
)}
>
{tab === 'users' ? 'User Management' : 'System Health'}
</button>
))}
</div>
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
</div>
);
}
// ── Users Tab ──────────────────────────────────────────────────────────────────
function UsersTab(): React.ReactElement {
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const loadUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api<UserListDto>('/api/admin/users');
setUsers(data.users);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
async function handleRoleToggle(user: UserDto): Promise<void> {
const newRole = user.role === 'admin' ? 'member' : 'admin';
try {
await api(`/api/admin/users/${user.id}/role`, {
method: 'PATCH',
body: { role: newRole },
});
await loadUsers();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update role');
}
}
async function handleBanToggle(user: UserDto): Promise<void> {
const endpoint = user.banned ? 'unban' : 'ban';
try {
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
await loadUsers();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update ban status');
}
}
async function handleDelete(user: UserDto): Promise<void> {
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
try {
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
await loadUsers();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete user');
}
}
if (loading) {
return <p className="text-sm text-text-muted">Loading users...</p>;
}
if (error) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
<p className="text-sm text-red-400">{error}</p>
<button
type="button"
onClick={() => void loadUsers()}
className="mt-2 text-xs text-red-300 underline hover:no-underline"
>
Retry
</button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-text-muted">{users.length} user(s)</p>
<button
type="button"
onClick={() => setShowCreate(true)}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
>
+ New User
</button>
</div>
{showCreate && (
<CreateUserForm
onCancel={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false);
void loadUsers();
}}
/>
)}
{users.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">No users found</p>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-surface-border">
<table className="w-full">
<thead>
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Name / Email</th>
<th className="px-4 py-2 font-medium">Role</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
<th className="px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
<td className="px-4 py-3">
<div className="text-sm font-medium text-text-primary">{user.name}</div>
<div className="text-xs text-text-muted">{user.email}</div>
</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
user.role === 'admin'
? 'bg-purple-500/20 text-purple-400'
: 'bg-surface-elevated text-text-secondary',
)}
>
{user.role}
</span>
</td>
<td className="hidden px-4 py-3 md:table-cell">
{user.banned ? (
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
Banned
</span>
) : (
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
Active
</span>
)}
</td>
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleRoleToggle(user)}
className="text-xs text-blue-400 hover:text-blue-300"
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
>
{user.role === 'admin' ? 'Demote' : 'Promote'}
</button>
<button
type="button"
onClick={() => void handleBanToggle(user)}
className={cn(
'text-xs',
user.banned
? 'text-green-400 hover:text-green-300'
: 'text-yellow-400 hover:text-yellow-300',
)}
>
{user.banned ? 'Unban' : 'Ban'}
</button>
<button
type="button"
onClick={() => void handleDelete(user)}
className="text-xs text-red-400 hover:text-red-300"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ── Create User Form ──────────────────────────────────────────────────────────
interface CreateUserFormProps {
onCancel: () => void;
onCreated: () => void;
}
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('member');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await api('/api/admin/users', {
method: 'POST',
body: { name, email, password, role },
});
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create user');
} finally {
setSubmitting(false);
}
}
return (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs text-text-muted">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Password</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="member">member</option>
<option value="admin">admin</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
);
}
// ── Health Tab ────────────────────────────────────────────────────────────────
function HealthTab(): React.ReactElement {
const [health, setHealth] = useState<HealthStatusDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadHealth = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api<HealthStatusDto>('/api/admin/health');
setHealth(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load health');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadHealth();
}, [loadHealth]);
if (loading) {
return <p className="text-sm text-text-muted">Loading health status...</p>;
}
if (error) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
<p className="text-sm text-red-400">{error}</p>
<button
type="button"
onClick={() => void loadHealth()}
className="mt-2 text-xs text-red-300 underline hover:no-underline"
>
Retry
</button>
</div>
);
}
if (!health) return <></>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusBadge status={health.status} />
<span className="text-sm text-text-muted">
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
</span>
</div>
<button
type="button"
onClick={() => void loadHealth()}
className="text-xs text-blue-400 hover:text-blue-300"
>
Refresh
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Database */}
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
{health.database.latencyMs !== undefined && (
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
)}
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
</HealthCard>
{/* Cache */}
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
{health.cache.latencyMs !== undefined && (
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
)}
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
</HealthCard>
{/* Agent Pool */}
<HealthCard title="Agent Pool" status="ok">
<p className="text-xs text-text-muted">
Active sessions: {health.agentPool.activeSessions}
</p>
</HealthCard>
{/* Providers */}
<HealthCard
title="LLM Providers"
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
>
{health.providers.length === 0 ? (
<p className="text-xs text-text-muted">No providers configured</p>
) : (
<ul className="space-y-1">
{health.providers.map((p) => (
<li key={p.id} className="flex items-center justify-between text-xs">
<span className="text-text-secondary">{p.name}</span>
<span
className={cn(
'rounded-full px-1.5 py-0.5',
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
)}
>
{p.available ? `${p.modelCount} models` : 'unavailable'}
</span>
</li>
))}
</ul>
)}
</HealthCard>
</div>
</div>
);
}
// ── Helper Components ─────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
const map = {
ok: 'bg-green-500/20 text-green-400',
degraded: 'bg-yellow-500/20 text-yellow-400',
error: 'bg-red-500/20 text-red-400',
};
return (
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
{status}
</span>
);
}
interface HealthCardProps {
title: string;
status: 'ok' | 'error';
children?: React.ReactNode;
}
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
return (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
<span
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
/>
</div>
{children}
</div>
);
}
-178
View File
@@ -1,178 +0,0 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
useSessionMock: vi.fn(),
updateUserMock: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
api: apiMock,
}));
vi.mock('@/lib/auth-client', () => ({
useSession: useSessionMock,
authClient: { updateUser: updateUserMock },
}));
import { SettingsPage } from './settings';
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
apiMock.mockReset();
useSessionMock.mockReset();
updateUserMock.mockReset();
});
async function renderSettingsPage(): Promise<void> {
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
}
function clickButtonByText(text: string): Promise<void> {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
return act(async () => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
const session = {
user: { id: 'u-1', name: 'Test User', email: '[email protected]', image: null },
};
describe('SettingsPage profile tab', () => {
it('renders the profile form from the session and saves via authClient', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
updateUserMock.mockResolvedValue({});
await renderSettingsPage();
const nameInput = container.querySelector<HTMLInputElement>('#profile-name');
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
expect(nameInput?.value).toBe('Test User');
expect(emailInput?.value).toBe('[email protected]');
expect(emailInput?.disabled).toBe(true);
await clickButtonByText('Save changes');
expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null });
expect(container.textContent).toContain('Saved!');
});
it('surfaces an update failure without clearing the form', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } });
await renderSettingsPage();
await clickButtonByText('Save changes');
expect(container.textContent).toContain('name rejected');
expect(container.querySelector<HTMLInputElement>('#profile-name')?.value).toBe('Test User');
});
});
describe('SettingsPage appearance tab', () => {
it('loads preferences and posts each changed preference on save', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
apiMock.mockImplementation((path: string) =>
path.startsWith('/api/memory/preferences?')
? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }])
: Promise.resolve({}),
);
await renderSettingsPage();
await clickButtonByText('Appearance');
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance');
await clickButtonByText('Save changes');
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', {
method: 'POST',
body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' },
});
});
});
describe('SettingsPage providers tab', () => {
it('loads LLM and SSO providers and runs a connection test', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
apiMock.mockImplementation((path: string, opts?: { method?: string }) => {
if (path === '/api/providers' && opts === undefined) {
return Promise.resolve([
{
id: 'ollama',
name: 'Ollama',
available: true,
models: [
{
id: 'llama3.2',
provider: 'ollama',
name: 'Llama 3.2',
reasoning: false,
contextWindow: 128_000,
maxTokens: 4096,
inputTypes: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
]);
}
if (path === '/api/sso/providers') {
return Promise.resolve([]);
}
if (path === '/api/providers/test') {
return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 });
}
return Promise.resolve([]);
});
await renderSettingsPage();
await clickButtonByText('Providers');
expect(container.textContent).toContain('Ollama');
expect(container.textContent).toContain('1 model');
await clickButtonByText('Test');
expect(apiMock).toHaveBeenCalledWith('/api/providers/test', {
method: 'POST',
body: { providerId: 'ollama' },
});
expect(container.textContent).toContain('Reachable');
});
});
-826
View File
@@ -1,826 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { authClient, useSession } from '@/lib/auth-client';
import type { SsoProviderDiscovery } from '@/lib/sso';
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ModelInfo {
id: string;
provider: string;
name: string;
reasoning: boolean;
contextWindow: number;
maxTokens: number;
inputTypes: ('text' | 'image')[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}
interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
interface TestConnectionResult {
providerId: string;
reachable: boolean;
latencyMs?: number;
error?: string;
discoveredModels?: string[];
}
type TestState = 'idle' | 'testing' | 'success' | 'error';
interface ProviderTestStatus {
state: TestState;
result?: TestConnectionResult;
}
interface Preference {
key: string;
value: unknown;
category: string;
}
type Theme = 'light' | 'dark' | 'system';
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
const p = prefs.find((x) => x.key === key);
if (p === undefined) return fallback;
return p.value as T;
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function SettingsPage(): React.ReactElement {
const { data: session } = useSession();
const [activeTab, setActiveTab] = useState<Tab>('profile');
const tabs: { id: Tab; label: string }[] = [
{ id: 'profile', label: 'Profile' },
{ id: 'appearance', label: 'Appearance' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'providers', label: 'Providers' },
];
return (
<div className="mx-auto max-w-3xl space-y-6">
<h1 className="text-2xl font-semibold">Settings</h1>
{/* Tab bar */}
<div className="flex gap-1 border-b border-surface-border">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'border-b-2 border-accent text-accent'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'profile' && <ProfileTab session={session} />}
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'notifications' && <NotificationsTab />}
{activeTab === 'providers' && <ProvidersTab />}
</div>
);
}
// ─── Profile Tab ──────────────────────────────────────────────────────────────
function ProfileTab({
session,
}: {
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
}): React.ReactElement {
const [name, setName] = useState(session?.user.name ?? '');
const [image, setImage] = useState(session?.user.image ?? '');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
// Sync from session when it loads
useEffect(() => {
if (session?.user) {
setName(session.user.name ?? '');
setImage(session.user.image ?? '');
}
}, [session]);
const handleSave = async (): Promise<void> => {
setSaveState('saving');
setErrorMsg('');
try {
const result = await authClient.updateUser({ name, image: image || null });
if (result.error) {
setErrorMsg(result.error.message ?? 'Failed to update profile');
setSaveState('error');
return;
}
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update profile';
setErrorMsg(message);
setSaveState('error');
}
};
return (
<section className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
<FormField label="Display Name" id="profile-name">
<input
id="profile-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</FormField>
<FormField label="Email" id="profile-email">
<input
id="profile-email"
type="email"
value={session?.user.email ?? ''}
disabled
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
</FormField>
<FormField label="Avatar URL" id="profile-image">
<input
id="profile-image"
type="url"
value={image}
onChange={(e) => setImage(e.target.value)}
placeholder="https://example.com/avatar.png"
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</FormField>
<div className="flex items-center gap-3 pt-2">
<SaveButton state={saveState} onClick={handleSave} />
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
</div>
</div>
</section>
);
}
// ─── Appearance Tab ───────────────────────────────────────────────────────────
function AppearanceTab(): React.ReactElement {
const [loading, setLoading] = useState(true);
const [theme, setTheme] = useState<Theme>('system');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [defaultModel, setDefaultModel] = useState('');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=appearance')
.catch(() => [] as Preference[])
.then((p) => {
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
})
.finally(() => setLoading(false));
}, []);
const handleSave = async (): Promise<void> => {
setSaveState('saving');
setErrorMsg('');
try {
await Promise.all([
api('/api/memory/preferences', {
method: 'POST',
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
}),
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'ui.sidebar_collapsed',
value: sidebarCollapsed,
category: 'appearance',
source: 'user',
},
}),
...(defaultModel
? [
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'ui.default_model',
value: defaultModel,
category: 'appearance',
source: 'user',
},
}),
]
: []),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
setSaveState('error');
}
};
if (loading) {
return (
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
<p className="text-sm text-text-muted">Loading preferences...</p>
</section>
);
}
return (
<section className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
{/* Theme */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
<div className="flex gap-3">
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTheme(t)}
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
theme === t
? 'border-accent bg-accent/10 text-accent'
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
}`}
>
{t}
</button>
))}
</div>
</div>
{/* Sidebar collapsed default */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
</div>
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
</div>
{/* Default model */}
<FormField label="Default Model" id="default-model">
<input
id="default-model"
type="text"
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
placeholder="e.g. ollama/llama3.2"
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
<p className="mt-1 text-xs text-text-muted">
Model ID to pre-select for new conversations.
</p>
</FormField>
<div className="flex items-center gap-3 pt-2">
<SaveButton state={saveState} onClick={handleSave} />
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
</div>
</div>
</section>
);
}
// ─── Notifications Tab ────────────────────────────────────────────────────────
function NotificationsTab(): React.ReactElement {
const [loading, setLoading] = useState(true);
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
const [emailMentions, setEmailMentions] = useState(true);
const [emailDigest, setEmailDigest] = useState(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=communication')
.catch(() => [] as Preference[])
.then((p) => {
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
})
.finally(() => setLoading(false));
}, []);
const handleSave = async (): Promise<void> => {
setSaveState('saving');
setErrorMsg('');
try {
await Promise.all([
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'notify.email_agent_complete',
value: emailAgentComplete,
category: 'communication',
source: 'user',
},
}),
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'notify.email_mentions',
value: emailMentions,
category: 'communication',
source: 'user',
},
}),
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'notify.email_digest',
value: emailDigest,
category: 'communication',
source: 'user',
},
}),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
setSaveState('error');
}
};
if (loading) {
return (
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
<p className="text-sm text-text-muted">Loading preferences...</p>
</section>
);
}
return (
<section className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
<NotifyRow
label="Agent task completed"
description="Email when an agent finishes a task"
checked={emailAgentComplete}
onChange={setEmailAgentComplete}
/>
<NotifyRow
label="Mentions"
description="Email when you are mentioned in a conversation"
checked={emailMentions}
onChange={setEmailMentions}
/>
<NotifyRow
label="Weekly digest"
description="Weekly summary of activity"
checked={emailDigest}
onChange={setEmailDigest}
/>
<div className="flex items-center gap-3 pt-2">
<SaveButton state={saveState} onClick={handleSave} />
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
</div>
</div>
</section>
);
}
// ─── Providers Tab ────────────────────────────────────────────────────────────
function ProvidersTab(): React.ReactElement {
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
const [loading, setLoading] = useState(true);
const [ssoLoading, setSsoLoading] = useState(true);
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
useEffect(() => {
api<ProviderInfo[]>('/api/providers')
.catch(() => [] as ProviderInfo[])
.then((p) => setProviders(p))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
api<SsoProviderDiscovery[]>('/api/sso/providers')
.catch(() => [] as SsoProviderDiscovery[])
.then((providers) => setSsoProviders(providers))
.finally(() => setSsoLoading(false));
}, []);
const testConnection = useCallback(async (providerId: string): Promise<void> => {
setTestStatuses((prev) => ({
...prev,
[providerId]: { state: 'testing' },
}));
try {
const result = await api<TestConnectionResult>('/api/providers/test', {
method: 'POST',
body: { providerId },
});
setTestStatuses((prev) => ({
...prev,
[providerId]: { state: result.reachable ? 'success' : 'error', result },
}));
} catch {
setTestStatuses((prev) => ({
...prev,
[providerId]: {
state: 'error',
result: { providerId, reachable: false, error: 'Request failed' },
},
}));
}
}, []);
const defaultModel: ModelInfo | undefined = providers
.flatMap((p) => p.models)
.find((m) => providers.find((p) => p.id === m.provider)?.available);
return (
<section className="space-y-6">
<div className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
</div>
<div className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading providers...</p>
) : providers.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<p className="text-sm text-text-muted">
No providers configured. Set{' '}
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
OLLAMA_BASE_URL
</code>{' '}
or{' '}
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
MOSAIC_CUSTOM_PROVIDERS
</code>{' '}
to add providers.
</p>
</div>
) : (
<div className="space-y-4">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
defaultModel={defaultModel}
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
onTest={() => void testConnection(provider.id)}
/>
))}
</div>
)}
</div>
</section>
);
}
// ─── Shared UI Components ─────────────────────────────────────────────────────
function FormField({
label,
id,
children,
}: {
label: string;
id: string;
children: React.ReactNode;
}): React.ReactElement {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
{label}
</label>
{children}
</div>
);
}
function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}): React.ReactElement {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
checked ? 'bg-accent' : 'bg-surface-border'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
checked ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
);
}
function NotifyRow({
label,
description,
checked,
onChange,
}: {
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}): React.ReactElement {
return (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-primary">{label}</p>
<p className="text-xs text-text-muted">{description}</p>
</div>
<Toggle checked={checked} onChange={onChange} />
</div>
);
}
function SaveButton({
state,
onClick,
}: {
state: SaveState;
onClick: () => void;
}): React.ReactElement {
return (
<button
type="button"
onClick={onClick}
disabled={state === 'saving'}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
</button>
);
}
// ─── Provider Card (from original page) ──────────────────────────────────────
interface ProviderCardProps {
provider: ProviderInfo;
defaultModel: ModelInfo | undefined;
testStatus: ProviderTestStatus;
onTest: () => void;
}
function ProviderCard({
provider,
defaultModel,
testStatus,
onTest,
}: ProviderCardProps): React.ReactElement {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-surface-border bg-surface-card">
{/* Header row */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<ProviderAvatar id={provider.id} />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
<ProviderStatusBadge available={provider.available} />
</div>
<p className="text-xs text-text-muted">
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<TestConnectionButton status={testStatus} onTest={onTest} />
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
aria-expanded={expanded}
aria-label={expanded ? 'Collapse models' : 'Expand models'}
>
{expanded ? '▲ Hide' : '▼ Models'}
</button>
</div>
</div>
{/* Test result banner */}
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
<TestResultBanner result={testStatus.result} />
)}
{/* Model list */}
{expanded && (
<div className="border-t border-surface-border">
<table className="w-full">
<thead>
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Model</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
<th className="px-4 py-2 font-medium">Default</th>
</tr>
</thead>
<tbody>
{provider.models.map((model) => (
<ModelRow
key={model.id}
model={model}
isDefault={
defaultModel?.id === model.id && defaultModel?.provider === model.provider
}
/>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
interface ModelRowProps {
model: ModelInfo;
isDefault: boolean;
}
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
return (
<tr className="border-t border-surface-border">
<td className="px-4 py-2">
<span className="text-sm text-text-primary">{model.name}</span>
</td>
<td className="hidden px-4 py-2 md:table-cell">
<div className="flex flex-wrap gap-1">
<CapabilityBadge label="chat" />
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
</div>
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{formatContext(model.contextWindow)}
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{model.cost.input === 0 && model.cost.output === 0
? 'free'
: `$${model.cost.input} / $${model.cost.output}`}
</td>
<td className="px-4 py-2 text-center">
{isDefault && (
<span
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
title="Default model used for new sessions"
>
default
</span>
)}
</td>
</tr>
);
}
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
const letter = id.charAt(0).toUpperCase();
return (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
{letter}
</div>
);
}
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
}`}
>
{available ? 'Active' : 'Inactive'}
</span>
);
}
interface TestConnectionButtonProps {
status: ProviderTestStatus;
onTest: () => void;
}
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
const isTesting = status.state === 'testing';
return (
<button
type="button"
onClick={onTest}
disabled={isTesting}
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
title="Test connection"
>
{isTesting ? (
<span className="text-text-muted">Testing</span>
) : status.state === 'success' ? (
<span className="text-success"> Reachable</span>
) : status.state === 'error' ? (
<span className="text-error"> Unreachable</span>
) : (
<span className="text-text-muted">Test</span>
)}
</button>
);
}
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
return (
<div
className={`px-4 py-2 text-xs ${
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
}`}
>
{result.reachable ? (
<>
Connected
{result.latencyMs !== undefined && (
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
)}
{result.discoveredModels && result.discoveredModels.length > 0 && (
<span className="ml-2 opacity-70">
{result.discoveredModels.length} model
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
</span>
)}
</>
) : (
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
)}
</div>
);
}
function CapabilityBadge({
label,
color = 'default',
}: {
label: string;
color?: 'default' | 'purple' | 'blue';
}): React.ReactElement {
const colorClass =
color === 'purple'
? 'bg-purple-500/20 text-purple-400'
: color === 'blue'
? 'bg-blue-500/20 text-blue-400'
: 'bg-surface-elevated text-text-muted';
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
}
function formatContext(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
return String(tokens);
}
-351
View File
@@ -1,351 +0,0 @@
# Roll-up Projection Contract (S2 contract 8)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 8 of 9).
Authority: `native-kanban-sot.md` §8 (A1 amendment) — "task and status
visualization bubbles up the hierarchy as aggregation over workspaces
the reader is authorized on" (§8.1.3); roll-up is never a write and
bubble-up views are generated projections, non-authoritative and never
import sources (§8.2.2); the express, narrow carve-out from the
portfolio-analytics non-goal covers per-workspace task counts and
statuses aggregated up the parent chain over readable workspaces, and
nothing beyond that boundary (§8.2.4); acceptance requires that roll-up
endpoints cannot mutate state and that a reader sees aggregates only
over workspaces they are authorized on, with no cross-tenant existence
oracles (§8.3). A5 rank 5 names the deliverable: an authorized
read-only roll-up query over only readable workspaces, at every
hierarchy level, as its own non-mutating query tool, dependent on ranks
13.
Revision 2 (terra review F1F7): membership-only readability is now
workspace-local — it contributes at the workspace node only and never
promotes ancestor visibility; upward aggregation requires an effective
chain role, and §1 defines direct vs effective grants in contract 2's
terms (F1). The no-oracle rule gains a defined equivalence predicate
(normalized byte equality with an enumerated volatile-field set) and a
partial-scope hidden-sibling witness (F2). §2.5 enumerates the closed
semantic result and denial schemas field-by-field, including the
explicit-zero representation (F3). Cache invalidation, when a cache
exists, is witnessed per invalidator class (F4). Non-authoritative and
never-gate rules gain an import-graph/data-flow witness, and the
mutation check is aligned to contract 1 §6.7's both-table zero-write
assertion (F5). The fixture gains a second estate with distinct counts
and explicit company-, estate-, project-grant, and membership cases
(F6). The §5.3 legacy-row exclusion and pre-rank no-obligation rules
are disclosed as drafting additions (F7).
Revision 3 (terra re-review residuals): the partial-scope witnesses are
reconstructed at levels where chain grants can actually differ —
platform-project siblings under one estate and estate siblings under
one company — because contract 1 §3.1/§3.4 defines no workspace-level
grant target, so no reader can hold a chain grant on two of three
sibling workspaces (F2). §2.5 now defines one field-exact recursive
record — every node, including the queried node and every leaf, is the
same five-field shape with a required, deterministically ordered
`children` array that is empty at workspaces — and the whole-result
rules (no optional fields, denial envelope, wire faithfulness) are
their own §2.6 at section scope (F3). The fixture assigns workspaces
to named platform-projects, and §6.1's grant-level cases are the three
levels contract 1 defines, with workspace-level access covered by the
membership case and stated as having no direct chain grant (F6).
This contract binds the projection semantics (§2), reader authorization
semantics (§3), read-only enforcement (§4), dependencies and phase
timing (§5), witnesses (§6), and disclosed drafting additions (§7). It
defines the roll-up only: hierarchy shape stays with contract 1
(`hierarchy-schema.md`), grant vocabulary and evaluation with contract 2
(`rbac-grant-model.md`), the task lifecycle and status taxonomy with
`native-kanban-sot.md`'s typed surface, and the tool↔Gateway mapping
row with contract 5 (`tool-gateway-mapping.md`).
## 1. Definitions
1. **Roll-up**: the read-only projection of per-workspace task counts
by status, aggregated up the contract 1 parent chain (workspace →
platform-project → estate → company).
2. **Effective chain role** (at a node, for a reader): the role
contract 2 §3 evaluation yields at that node — from a grant on the
node itself (a **direct grant**) or from a grant on an ancestor
whose domain covers it (an **inherited grant**, contract 2 §3.2).
The role vocabulary is contract 2 §2's; this contract adds no role
and no new authority source.
3. **Chain-readable workspace** (for a reader): a workspace where the
reader's effective chain role permits reading task state.
4. **Member-readable workspace** (for a reader): a workspace readable
only through workspace membership under the SOT's own membership
rules (REQ-ID-001), with no effective chain role. Membership
confers workspace-local semantics only (contract 2 §3.1, §7.4): it
never contributes authority, visibility, or aggregation upward.
5. **Aggregation scope** (of a hierarchy node, for a reader): the set
of chain-readable workspaces in that node's descendant subtree;
plus, when the node is itself a workspace, that workspace if it is
chain-readable or member-readable. A member-readable workspace
therefore contributes to exactly one node's aggregation scope: its
own.
6. **Projection**: a generated, non-authoritative view in the sense of
`native-kanban-sot.md` §3 invariant 5 — derived from SOT rows,
never an import source, never authoritative.
## 2. Projection semantics
1. **Aggregate content.** The roll-up for a node reports, per
workspace in the reader's aggregation scope and as subtree totals:
task counts keyed by the typed lifecycle's status values (owned by
`native-kanban-sot.md`; this contract introduces no status), and
nothing else. Direct count/status aggregation is the entire
surface.
2. **Every level.** The roll-up is queryable at workspace,
platform-project, estate, and company level. A node's totals equal
the sum over its aggregation scope; chain resolution is contract 1
§2.5's (every workspace resolves to exactly one chain), so no
workspace is counted twice and none is orphaned.
3. **Carve-out boundary.** Everything beyond direct count/status
aggregation — metrics, trends, forecasting, scoring, velocity,
cross-workspace derived analytics, dashboards computed across
workspaces — remains a `native-kanban-sot.md` §6 non-goal
(§8.2.4). The response schema is closed (§2.5; §6.7 witness):
adding any field is an amendment to this contract.
4. **Non-authoritative.** No consumer may treat roll-up output as a
source of record; it is recomputable at any time from SOT rows and
is never imported, persisted as authoritative state, or used to
gate or deny work (witness §6.8 — both the write-path and the
decision-path prohibitions are witnessed).
5. **Closed semantic schema.** The successful result is exactly one
**roll-up node record**, a single recursive shape used at every
depth. A roll-up node record consists of exactly these five
fields, and no others:
- `id`: the node's identifier.
- `type`: one of the four contract 1 levels.
- `name`: the node's name.
- `totals`: one entry per status value of the typed lifecycle —
every status key present, a count of zero represented explicitly
as `0`, never by key absence. At a workspace node, `totals` is
that workspace's own counts; at any other node, `totals` is the
sum over the node's aggregation scope (§2.2). This is how §2.1's
"per workspace and as subtree totals" content is carried:
per-workspace counts are the leaf records' `totals`, subtree
totals are the interior records' `totals`.
- `children`: a required array, present on EVERY node record. Its
elements are the reader-visible (§3.2) child nodes of this node,
each itself a complete roll-up node record, recursing down to
the workspaces in the reader's aggregation scope. At a workspace
node the array is exactly `[]` — a workspace record never has
children. The array is ordered deterministically, ascending by
`id`; the implementing PR asserts that ordering. A node outside
§3.2 visibility never appears at any depth.
The queried node's record IS the whole result — there is no
wrapper field around it.
6. **Whole-result rules.** There are no optional result fields at any
depth. The denial/nonexistent response is the contract 5 §4.2
not-found-class error envelope with no fields beyond that
envelope. The wire DTO is expressed under contract 5 §4.1, and
MUST be a faithful serialization of exactly the §2.5 recursive
record: a wire field with no corresponding semantic field is a
conformance defect.
## 3. Reader authorization semantics
1. **Scope rule.** A reader's roll-up over any node aggregates ONLY
the reader's aggregation scope (§1.5). An unreadable workspace
contributes nothing to any total — not a count, not a row, not a
presence marker. A member-readable workspace contributes only at
the workspace node itself (§1.4–§1.5): querying it directly
succeeds; it never appears in, and never adds to, any ancestor's
response for that reader.
2. **Node visibility.** A node appears in a roll-up response iff the
reader's aggregation scope at that node is non-empty, or the
reader holds an effective chain role at the node (§1.2 — direct or
inherited; contract 2 §3.2 makes a grant's domain the node and its
subtree, so an ancestor grant makes empty descendants visible per
the ruling). Per the ruling below, a node with an effective chain
role but an empty aggregation scope appears with zero counts.
Workspace membership alone never makes any non-workspace node
visible. A node where the reader has neither an effective chain
role nor a non-empty aggregation scope does not appear at all.
3. **No existence oracle.** The response MUST NOT disclose the
existence, count, name, or any property of unreadable workspaces
or of nodes outside §3.2 visibility — no "N workspaces hidden"
fields, no total-vs-visible discrepancy fields. A query naming a
node outside §3.2 visibility MUST satisfy the §3.4 response
equivalence with a query naming a nonexistent node (fail closed,
`rbac-grant-model.md` §3.5 pattern: a decision path that cannot
read grant state denies).
4. **Response equivalence predicate.** Two responses are equivalent
when they carry the identical HTTP status, the identical contract
5 §4.2 error code, and byte-identical bodies after normalizing
exactly the declared volatile envelope fields — correlation id and
response timestamp, and nothing else. The implementing PR declares
that volatile-field list in the witness; any additional
normalization is a conformance defect. This is contract 5 §4.2's
same code/status/shape rule made executable.
5. **Live evaluation.** Readability is evaluated per contract 2 §3.5
(live rows or transactionally-invalidated cache). Revocation
propagates per contract 2 §6: the next roll-up query decided after
the revoking transaction commits excludes the revoked scope.
## 4. Read-only enforcement
1. **Never a write.** No roll-up path may mutate, claim, order, or
gate work in any workspace (§8.2.2). The roll-up ships as a
non-mutating query tool (A5 rank 5) — a query surface with no
command counterpart.
2. **Mechanical enforcement.** The implementing PR executes roll-up
database work inside read-only transactions (or an equivalently
privilege-restricted path), so a mutation attempt fails at the
database boundary, not only by convention.
3. **Freshness.** v1 computes the roll-up live from SOT rows at query
time. A cache is an implementation option only if it is
invalidated in the same transaction as any task, hierarchy, grant,
or membership mutation that affects it (each invalidator class
witnessed, §6.5), and it is never authoritative (§1.6).
## 5. Dependencies and phase timing
1. The roll-up depends on A5 ranks 13: contract 1's hierarchy tables
(the parent chain), contract 2's evaluator (readability), and the
typed Kanban lifecycle (the task state being counted). It ships
after them and reads their surfaces; it defines none of them.
2. The roll-up query is one tool with one Gateway mapping row under
contract 5's regime (request/result/error/audit contracts there);
this contract binds its semantics (§2.5 defines the semantic
fields the contract 5 §4.1 DTO serializes), not its wire encoding.
3. Legacy task rows outside the typed lifecycle are not aggregated;
the roll-up begins counting a workspace's tasks when they exist in
the typed surface. No roll-up obligation attaches to v1 before
ranks 13 exist. Both rules are drafting additions disclosed in §7
(they trace to no §8 sentence).
## 6. Verification requirements
Binding on the implementing PRs. Every witness names, in its
implementation, the exact endpoints/tools, tables, and fixtures it
exercises. The base fixture seeds two companies; under company A **two
estates with distinct, non-identical count profiles**: estate A1 with
two platform-projects — P1 holding workspaces W1 and W2, P2 holding
workspace W3 — and estate A2 with one platform-project P3 holding one
workspace W4, all with known task counts across at least three
statuses; under company B one workspace.
1. **Correctness witnesses:** for a reader holding a direct company-A
grant, roll-up totals at every level equal the seeded sums — each
workspace, each platform-project, estate A1 and estate A2
separately (their distinct profiles asserted distinct), and the
company total equal to A1+A2 — keyed by the typed status values,
with no double count across the chain. For a reader holding a
direct estate-A1 grant, the estate-A1 result equals the A1 sum and
a company-A query returns company A with exactly A1's contribution
(estate A2 invisible). Each of the three chain grant levels
contract 1 §3.1 defines — company, estate, platform-project
(below, §6.2) — has an explicit direct-grant case, none simulated
by unioning lower access. Workspace-level access has NO direct
chain grant (contract 1 §3.1/§3.4 define no workspace grant
target) and is covered by the §6.2 membership case.
2. **Scope witnesses:** a reader with a direct grant on
platform-project P1 only sees exactly P1's subtree counts
(W1+W2): a P1 query returns W1+W2; an estate-A1 query returns the
estate node with exactly P1's contribution, sibling project P2 and
its workspace W3 absent at every depth; a company-A query likewise
carries only P1's contribution. An estate-sibling case: a reader
with a direct grant on estate A1 only queries company A and
receives exactly A1's contribution, estate A2 absent. (Chain
grants exist only at company, estate, and platform-project —
contract 1 §3.1 — so partial scope among SIBLING WORKSPACES of
one project is not constructible by grants and is not witnessed;
the constructible partial-scope cases are the project- and
estate-sibling ones above.) **Membership locality (§1.4):** a member-only reader queries
the workspace directly and receives its counts; the same reader
querying the workspace's parent (or any ancestor) receives the
§3.4-equivalent nonexistent-node response, and no ancestor
response for any other reader changes because of that membership.
3. **No-oracle witnesses:** the P1-only reader's estate-A1 response
above contains no field disclosing P2's or W3's existence
(closed-schema comparison against an estate-A1-granted reader's
response: identical field set, differing only in counts and
visible nodes). **Partial-scope hidden node:** the P1-only reader
— who sees estate A1 and the P1 subtree — queries hidden sibling
project P2 by its real id, and separately hidden workspace W3 by
its real id; each response satisfies the §3.4 equivalence
predicate against the same query naming a nonexistent id, under
one fixed request context with the declared volatile-field
normalization. **Cross-tenant:** an unauthorized reader naming company B receives
a response §3.4-equivalent to naming a nonexistent id. Each
equivalence check is executable byte comparison after the declared
normalization, not a shape judgment.
4. **Empty-vs-hidden witness (ruling):** a reader granted (direct
chain grant) on an empty platform-project receives it with zero
counts — every status key present at `0` (§2.5); with the grant
deleted, the same query returns the §3.4-equivalent
nonexistent-node response. An inherited-grant case: a company
grant makes an empty descendant platform-project visible with zero
counts.
5. **Cache-invalidation witnesses (conditional):** bound only if the
implementation caches — for EACH invalidator class, prime the
cache, commit one mutation of that class, and assert the next
query reflects it: a task status change, a task creation, a
membership removal (the member-readable workspace disappears from
its own node's next query), a workspace reparenting (both old and
new parent totals correct), and a grant revocation. A live
(cacheless) v1 implementation records that fact and the witnesses
bind at the PR that introduces a cache.
6. **Mutation witnesses:** the roll-up surface rejects every mutating
verb/command; a crafted attempt to issue a write through the
roll-up's database path fails at the read-only boundary (§4.2);
after any roll-up query, the row diff is empty across BOTH the
workspace tables and the hierarchy tables (contract 1 §6.7's
both-table zero-write assertion).
7. **Closed-schema witness:** the response is asserted field-exact
against the §2.5 recursive record at every depth — exactly
`id`/`type`/`name`/`totals`/`children` on every node, every typed
status present with explicit zeros, `children: []` at every
workspace record, the declared ascending-`id` ordering, no
wrapper field — and a response carrying any field outside the
record at any depth fails the assertion (carve-out boundary,
§2.3). The denial envelope is asserted field-exact against
contract 5 §4.2's envelope (§2.6).
8. **Non-authoritative and never-gate witnesses:** (a) a static
production import-graph inventory (hierarchy contract §6.3 style,
production code over `apps/` and `packages/`, tests excluded)
shows no production module imports the roll-up query module or its
result DTO into any SOT write path, any authorization/gating
decision path, or any persistence beyond the response lifetime —
asserted in both directions (the roll-up module's consumers are
enumerated and each is a presentation surface); (b) a behavioral
probe: with roll-up output artificially perturbed (test double),
no authorization outcome and no work-gating decision anywhere in
the fixture suite changes — proving no gate consumes it.
9. **Revocation witness:** after revoking the grant that made a
subtree readable, the next roll-up query excludes it (contract 2
§6.2 bound).
## 7. Drafting additions (PRD §12.1 disclosure)
Proposed drafting additions, visible here for ratification, each
severable; the aggregation itself, its authorization scope, its
read-only nature, and the no-oracle acceptance are traced to
`native-kanban-sot.md` §8 and are not additions:
1. The §3.2 node-visibility rule and the granted-but-empty behavior
(the ruling below).
2. The §3.3–§3.4 nonexistent-node response equivalence, with its
normalized-byte-equality predicate, as the concrete no-oracle
mechanism.
3. The §4.2 read-only-transaction mechanical enforcement.
4. The §4.3 cache option with transactional invalidation and the
§6.5 per-invalidator witnesses.
5. The §2.5 closed response schema as an amendment boundary.
6. The §1.4 membership-locality rule — membership-only readability
contributes at the workspace node only (this contract's
reconciliation of `native-kanban-sot.md` §8.1.3 "authorized on"
with contract 2 §3.1/§7.4's workspace-local membership).
7. The §5.3 legacy-row exclusion and the §5.3 pre-rank no-obligation
rule.
## Ruling request
Ruling requested (one decision): shall a node the reader holds an
effective chain role on (direct or inherited, §1.2) but whose
aggregation scope is empty appear in the roll-up with zero counts
(recommended — it lets the UI show a granted-but-empty subtree
honestly) — or, as the alternative, be indistinguishable from a
nonexistent node until it contains a readable workspace?