Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aac155ab6 | ||
|
|
a3b0770205 | ||
|
|
2a30c68b84 | ||
|
|
f8f8f97be7 |
@@ -13,8 +13,9 @@ import {
|
|||||||
TasksRouteErrorBoundary,
|
TasksRouteErrorBoundary,
|
||||||
} from '@/spa/pages/resource-route-error-boundaries';
|
} from '@/spa/pages/resource-route-error-boundaries';
|
||||||
import { TasksPage } from '@/spa/pages/tasks';
|
import { TasksPage } from '@/spa/pages/tasks';
|
||||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
import { SettingsPage } from '@/spa/pages/settings';
|
||||||
import { Placeholder } from '@/spa/placeholder';
|
import { AdminPage } from '@/spa/pages/admin';
|
||||||
|
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
|
|
||||||
function GuestLayout(): ReactElement {
|
function GuestLayout(): ReactElement {
|
||||||
return (
|
return (
|
||||||
@@ -56,8 +57,11 @@ export const routes: RouteObject[] = [
|
|||||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||||
},
|
},
|
||||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
{ path: '/settings', element: <SettingsPage /> },
|
||||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
{
|
||||||
|
element: <AdminGuard />,
|
||||||
|
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,3 +21,23 @@ export function AuthGuard(): ReactElement {
|
|||||||
|
|
||||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
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 />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|||||||
COPY apps/appservice/package.json ./apps/appservice/
|
COPY apps/appservice/package.json ./apps/appservice/
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY plugins/ ./plugins/
|
COPY plugins/ ./plugins/
|
||||||
|
# the root prepare script runs scripts/install-hooks.mjs on install
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# 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
|
||||||
|
1–3.
|
||||||
|
|
||||||
|
Revision 2 (terra review F1–F7): 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 1–3: 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 1–3 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?
|
||||||
Reference in New Issue
Block a user