Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f41bb5fb7 |
@@ -13,8 +13,9 @@ import {
|
||||
TasksRouteErrorBoundary,
|
||||
} from '@/spa/pages/resource-route-error-boundaries';
|
||||
import { TasksPage } from '@/spa/pages/tasks';
|
||||
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
import { Placeholder } from '@/spa/placeholder';
|
||||
import { SettingsPage } from '@/spa/pages/settings';
|
||||
import { AdminPage } from '@/spa/pages/admin';
|
||||
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
|
||||
|
||||
function GuestLayout(): ReactElement {
|
||||
return (
|
||||
@@ -56,8 +57,11 @@ export const routes: RouteObject[] = [
|
||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||
},
|
||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{
|
||||
element: <AdminGuard />,
|
||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -21,3 +21,23 @@ export function AuthGuard(): ReactElement {
|
||||
|
||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export function AdminGuard(): ReactElement {
|
||||
const { data: session, isPending } = useSession();
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-sm text-text-muted">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
const user = session.user as typeof session.user & { role?: string };
|
||||
|
||||
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
# RBAC Grant Model Contract
|
||||
|
||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 2 of 9).
|
||||
Authority: PRD Part I §4 ("Granular RBAC: admins restrict access per company,
|
||||
estate, and project; grants are evaluated down the chain") and the
|
||||
native-kanban SOT Amendment A1 (§8.1.3 RBAC evaluation, §8.3 acceptance 2).
|
||||
This document defines the grant vocabulary, evaluation semantics, and
|
||||
revocation propagation that the hierarchy schema contract
|
||||
(`docs/requirements/hierarchy-schema.md`, contract 1) attaches to. Contract 1
|
||||
pins the `hierarchy_grants` table shape and defers the `role` vocabulary and
|
||||
the meaning of "authority" here; the identity contract
|
||||
(`docs/requirements/identity-lifecycle.md` §1.4) pins that account creation
|
||||
grants nothing.
|
||||
|
||||
Revision 2 (independent review, GLM 5.3): §1.1 consequence analysis
|
||||
completed — the two existing platform-admin bypass code paths are named as
|
||||
non-conformant and §7.4 retires them; team grant subjects suspended pending
|
||||
a team contract (§1.4, §3.3–3.4, §7.5); no-self-escalation restated with
|
||||
its true rationale and a constructible observable (§4.2, §7.7);
|
||||
node-creation seeding scoped to the bootstrap path, resolving the §7.7/§4.3
|
||||
contradiction; A1 quotation corrected; audit-field provenance corrected;
|
||||
principal-position consequence named (§1.3); membership-row,
|
||||
fail-closed-fault, and existence-oracle observables added (§7);
|
||||
role-string namespacing rule added (§4.5); ruling request now names the
|
||||
interpretive resolution of PRD "admins".
|
||||
|
||||
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
|
||||
each hierarchy level confers, how grants evaluate down the chain, how
|
||||
revocation propagates, and who may manage grants. Out of scope: the hierarchy
|
||||
tables themselves (contract 1), workspace-internal membership and its
|
||||
role/capability vocabulary (native-kanban SOT REQ-ID-001 and its implementing
|
||||
schema), roll-up projection semantics (contract 8), wizard seeding
|
||||
(contract 3), the team model (suspended here; see §1.4).
|
||||
|
||||
## 1. Three authority layers, none substitutable
|
||||
|
||||
1. **Platform role** (`users.role`, better-auth: `member` | `admin`) governs
|
||||
instance administration — user management, system settings, provider
|
||||
configuration. It is not tenancy authority: holding platform `admin`
|
||||
confers **no implicit hierarchy grant and no workspace authorization**.
|
||||
An operator who should see tenant content holds an explicit, audited
|
||||
grant like anyone else. This is the deny-by-default consequence of A1
|
||||
§8.1.3 ("not a bypass of workspace authorization"). `AdminGuard`'s
|
||||
`role === 'admin'` check on admin endpoints stays the platform role's
|
||||
only meaning. **Two shipped code paths violate this rule today and are
|
||||
implementation defects this contract makes non-conformant:** (a) the
|
||||
command authorization service short-circuits every command scope to
|
||||
allowed for platform admins
|
||||
(`apps/gateway/src/commands/command-authorization.service.ts`,
|
||||
`hasScope` returning true when `role === 'admin'`), and (b) the MCP
|
||||
scope derivation maps platform `admin` to tenant-admin MCP scopes
|
||||
including task create/update
|
||||
(`apps/gateway/src/mcp/mcp.service.ts`,
|
||||
`deriveMcpToolScopesForUser`). Ratifying this contract revokes both;
|
||||
§7.4 names them as the surfaces the deny-by-default test retires.
|
||||
2. **Hierarchy grants** (`hierarchy_grants`, contract 1 §3) declare tenancy
|
||||
authority at company, estate, or platform-project scope and evaluate down
|
||||
the chain to workspace-scoped authorization (§3 below).
|
||||
3. **Workspace membership** (SOT REQ-ID-001) remains its own mechanism.
|
||||
A chain grant confers command authorization over descendant workspaces;
|
||||
it does not create membership rows, and row-level principal positions
|
||||
(task owner, proposer, decision actor) still require ACTIVE workspace
|
||||
membership exactly as REQ-TEN-001/REQ-ID-001 acceptance states.
|
||||
Consequence, stated so implementing PRs do not weaken REQ-TEN-001 to
|
||||
remove the friction: a chain-granted actor who is not a workspace member
|
||||
may issue the write commands their role implies but cannot occupy a
|
||||
principal position — any command taking a principal argument must name
|
||||
an ACTIVE member of the target workspace (§7.2 enumerates this cell).
|
||||
4. **Team grant subjects are suspended.** Contract 1 §3.1 reserves a
|
||||
`team_id` attachment point, but no ratified contract yet defines the
|
||||
team it would bind: the only existing `teams` table is the legacy global
|
||||
Brain table (own authority columns, no workspace binding, not
|
||||
repurposed per contract 1 §1.3), while the SOT's teams are
|
||||
workspace-bound (REQ-ID-001) — and a workspace-bound team holding a
|
||||
company-level grant would be a cross-workspace authority group nothing
|
||||
has ratified. Until a team contract defines the subject (which table,
|
||||
which membership rows, and its relation to D2/REQ-ID-001), creating a
|
||||
grant with a team subject MUST be refused at the command surface (the
|
||||
schema column remains, per contract 1). §3's evaluation semantics for
|
||||
team-conferred grants are specified now so the team contract activates
|
||||
them without amending this one.
|
||||
|
||||
## 2. Role vocabulary
|
||||
|
||||
One vocabulary at every hierarchy level, totally ordered — a higher role
|
||||
includes everything below it:
|
||||
|
||||
1. `viewer` — read: sees the node, its subtree structure, and the roll-up
|
||||
aggregates over descendant workspaces (within contract 8's carve-out
|
||||
bounds); read access to descendant workspace content per the SOT's read
|
||||
command families. No mutation of anything.
|
||||
2. `member` — work: everything `viewer` has, plus write authorization for
|
||||
business/orchestration command families in descendant workspaces (the
|
||||
concrete command-family mapping is implementation work under SOT
|
||||
REQ-ID-001; this contract pins that `member` maps to the workspace write
|
||||
families and nothing structural).
|
||||
3. `owner` — structure: everything `member` has, plus hierarchy mutations on
|
||||
the subtree (create/rename/delete child nodes, transfers per §5), and
|
||||
grant management on the node and its subtree (§4).
|
||||
|
||||
No other value is valid in `hierarchy_grants.role`; the column is
|
||||
constraint-checked against exactly these three. Extending the vocabulary is a
|
||||
contract amendment, not an implementation decision.
|
||||
|
||||
## 3. Evaluation semantics
|
||||
|
||||
1. **Deny by default.** No grant on any ancestor → no authority. There are
|
||||
no implicit grants: not from platform role (§1.1), not from creating a
|
||||
node (§4.3), not from workspace membership (membership without a chain
|
||||
grant confers exactly what the SOT's own membership rules confer inside
|
||||
that workspace, nothing up the chain).
|
||||
2. **Down-the-chain only.** A grant on a node applies to that node and its
|
||||
entire descendant subtree. Nothing evaluates upward or sideways: a grant
|
||||
on an estate says nothing about the parent company or sibling estates.
|
||||
3. **Effective role = maximum.** A subject's effective role at any node is
|
||||
the highest role among grants held directly by the subject's user on
|
||||
that node or any ancestor — and, once the team contract activates team
|
||||
subjects (§1.4), grants held by any team the user is a member of on that
|
||||
node or any ancestor. Roles never subtract — there is no negative/deny
|
||||
grant in this model; revocation is deletion (§6).
|
||||
4. **Team grants follow live membership** (specified now, active only per
|
||||
§1.4). A team grant confers its role on the team's current members,
|
||||
evaluated at decision time. Leaving the team is loss of the grant with
|
||||
§6's propagation bound.
|
||||
5. **Live evaluation, fail closed.** Authorization decisions derive from the
|
||||
live grant and team-membership rows (or from a cache that is invalidated
|
||||
in the same transaction as any grant/membership/hierarchy mutation). A
|
||||
decision path that cannot read grant state denies. No materialized ACL is
|
||||
ever authoritative.
|
||||
6. **Tenant context stays derived from authenticated authority**
|
||||
(REQ-TEN-001). The chain adds where grants can be declared; a workspace
|
||||
request is still authorized against that workspace, with the chain
|
||||
contributing the effective role — never letting the chain become what A1
|
||||
§8.1.3 forbids: "a bypass of workspace authorization".
|
||||
|
||||
## 4. Grant management
|
||||
|
||||
1. Creating, changing, or revoking a grant on a node requires effective
|
||||
`owner` on that node (directly or via any ancestor).
|
||||
2. **No self-escalation.** A grant manager cannot create a grant with a role
|
||||
higher than their own effective role on the target node. Under the §2
|
||||
vocabulary this rule is currently implied by §4.1 (managers are `owner`,
|
||||
the top role — no constructible grant exceeds it); it is stated
|
||||
explicitly so it survives any future amendment that decouples
|
||||
grant-management authority from role height. Its observable is the §7.7
|
||||
audit invariant, not a refusal test.
|
||||
3. **Bootstrap of authority is explicit; inheritance covers the rest.**
|
||||
Creating the first company (the wizard path, contract 3) and any
|
||||
top-level company creation MUST name the initial `owner` grant in the
|
||||
same audited operation — a top-level node has no ancestor to inherit
|
||||
from, so without this the node would be unownable. Creating a child node
|
||||
(estate, platform-project, workspace) requires effective `owner` on the
|
||||
parent (§2.3) and confers no automatic grant; the creator's authority
|
||||
over the new node already follows from §3.2 down-the-chain evaluation.
|
||||
The creating command MAY additionally name an explicit initial grant for
|
||||
a child node; it is not required to.
|
||||
4. Every grant mutation is a semantic audit event under contract 1 §5.2's
|
||||
guarantees, extended by this contract with two further fields: the event
|
||||
carries actor, verb, target, **subject, and role** (subject and role are
|
||||
this contract's addition; contract 1 §5.2 does not enumerate them).
|
||||
5. **Role strings are namespaced.** `viewer`/`member` exist at hierarchy
|
||||
level, `member`/`admin` on `users.role`, and the current command layer
|
||||
uses a third `viewer|member|admin` vocabulary — same strings, different
|
||||
meanings. Any serialized role string (audit events per §4.4, API
|
||||
responses, logs) MUST identify its layer (e.g. `hierarchy:owner`,
|
||||
`platform:admin`); a bare role string in a serialized artifact is
|
||||
non-conformant.
|
||||
|
||||
## 5. Transfer authority (completes contract 1 §4.2)
|
||||
|
||||
"Authority over BOTH the source and the destination parent" means: effective
|
||||
`owner` on the current parent node (or an ancestor) AND effective `owner` on
|
||||
the destination parent node (or an ancestor), evaluated at transfer time in
|
||||
the transfer's own transaction. One subject must hold both; two cooperating
|
||||
half-authorized subjects are not a transfer protocol this contract defines.
|
||||
|
||||
## 6. Revocation propagation
|
||||
|
||||
1. Revoking a grant (deleting the row), removing a user from a team that
|
||||
carries a grant (once team subjects activate, §1.4), or the cascade
|
||||
deletion of a node's grants during node deletion (contract 1 §3.3) all
|
||||
propagate identically: the authority derived from that grant is gone for
|
||||
every descendant workspace.
|
||||
2. **Bound:** the next authorization decision on any affected transport
|
||||
decides against the revoked grant. Concretely: no new HTTP/MCP command
|
||||
authorized by the revoked grant after the revoking transaction commits;
|
||||
an open Socket.IO connection whose subscriptions depend on the revoked
|
||||
grant is re-evaluated within 30 seconds or at its next inbound message,
|
||||
whichever comes first (same bound as the identity contract's §7.1
|
||||
deactivation rule; same mechanism may serve both).
|
||||
3. Revocation is subtractive only in effect, not in representation: the
|
||||
evaluator never needs tombstones; deletion of the row is the revocation.
|
||||
|
||||
## 7. Verification requirements
|
||||
|
||||
Binding on the implementing PRs (extends A1 §8.3 acceptance 2–3 and
|
||||
contract 1 §6):
|
||||
|
||||
1. Vocabulary: the role CHECK constraint rejects any value outside
|
||||
`viewer|member|owner` (real-PostgreSQL witness, `ci-postgres` service in
|
||||
the `test` CI step).
|
||||
2. Per-level conferral: for each of the three levels × three roles, a grant
|
||||
yields exactly the implied workspace authorization in a descendant
|
||||
workspace and nothing in a non-descendant workspace (the A1 §8.3
|
||||
"exactly the permissions the chain implies" matrix, enumerated). The
|
||||
matrix includes: a chain grant creates zero workspace-membership rows
|
||||
(assert row counts); a chain-granted non-member is refused as the
|
||||
principal argument of any principal-taking command while their
|
||||
non-principal writes succeed (§1.3); structure reads leak no existence
|
||||
of nodes the reader holds no grant on (no cross-tenant existence
|
||||
oracle, A1 §8.3 acceptance 3).
|
||||
3. Ordering: `owner` ⊇ `member` ⊇ `viewer` behaviorally — each higher role
|
||||
passes every lower role's positive cases.
|
||||
4. Deny-by-default: platform `admin` with no grant reaches no tenant
|
||||
content — asserted against the two §1.1 non-conformant surfaces after
|
||||
their retirement: the command-authorization admin short-circuit and the
|
||||
MCP tenant-admin scope derivation both gone (a platform admin with no
|
||||
grant is refused workspace commands and receives no tenant MCP scopes);
|
||||
workspace member with no chain grant gains nothing outside SOT
|
||||
membership semantics; fresh account reaches nothing (identity contract
|
||||
§1.4 cross-check).
|
||||
5. Team subjects: while suspended (§1.4), creating a team-subject grant is
|
||||
refused at the command surface. On activation by the team contract:
|
||||
user-direct and team-conferred grants combine to the maximum; team-leave
|
||||
drops authority within the §6.2 bound; decision-time evaluation
|
||||
witnessed (grant added → next decision allows; no restart or re-login
|
||||
required).
|
||||
6. Revocation: each revocation path in §6.1 denies the next command on
|
||||
every transport; the socket bound is measured; a cached-authorization
|
||||
implementation proves transactional invalidation (grant revoked and
|
||||
decision made on two distinct physical connections). Fail-closed fault
|
||||
witness for §3.5: with grant state unreadable (fault injection), the
|
||||
decision denies.
|
||||
7. Grant management: non-`owner` cannot mutate grants; top-level company
|
||||
creation without the named initial `owner` grant is refused, while child
|
||||
node creation under ancestor authority succeeds without one (§4.3 both
|
||||
directions); every mutation produces its audit event with the §4.4
|
||||
fields. Self-escalation observable: over the audit event stream, every
|
||||
grant-create/change event's role is ≤ the acting user's effective role
|
||||
on the target at event time (reconstructable invariant, not a refusal
|
||||
test — see §4.2).
|
||||
8. Transfer: both-sides `owner` accepted, each single-side case refused
|
||||
(completing contract 1 §6.5).
|
||||
|
||||
## Ruling request
|
||||
|
||||
Ratify sections 1–7 as written, with one decision embedded and one
|
||||
interpretive resolution named:
|
||||
|
||||
- Decision: platform `admin` confers no implicit tenant access — operators
|
||||
see tenant content only through explicit, audited grants (§1.1), which
|
||||
retires the two existing admin bypass paths named there. Say "agreed" or
|
||||
name the implicit access you want platform admins to keep.
|
||||
- Interpretive resolution (for visibility, not a separate question): PRD
|
||||
Part I §4 says "admins restrict access per company, estate, and project";
|
||||
this contract resolves "admins" as hierarchy `owner`s (§4.1), not
|
||||
platform admins. A1 §8.1.3 does not attribute grant declaration to
|
||||
platform admins, and the §1.1 decision above is what makes this reading
|
||||
binding.
|
||||
Reference in New Issue
Block a user