Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9597be9010 | ||
|
|
b3d24d2fb5 |
@@ -13,9 +13,8 @@ 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 { SettingsPage } from '@/spa/pages/settings';
|
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
import { AdminPage } from '@/spa/pages/admin';
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
|
|
||||||
|
|
||||||
function GuestLayout(): ReactElement {
|
function GuestLayout(): ReactElement {
|
||||||
return (
|
return (
|
||||||
@@ -57,11 +56,8 @@ export const routes: RouteObject[] = [
|
|||||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||||
},
|
},
|
||||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||||
{ path: '/settings', element: <SettingsPage /> },
|
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||||
{
|
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||||
element: <AdminGuard />,
|
|
||||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,23 +21,3 @@ 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 />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { apiMock, useSessionMock } = vi.hoisted(() => ({
|
|
||||||
apiMock: vi.fn(),
|
|
||||||
useSessionMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
useSession: useSessionMock,
|
|
||||||
authClient: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { AdminPage } from './admin';
|
|
||||||
import { AdminGuard } from '@/spa/guards';
|
|
||||||
|
|
||||||
const userFixtures = {
|
|
||||||
users: [
|
|
||||||
{
|
|
||||||
id: 'u-admin',
|
|
||||||
name: 'Ada Admin',
|
|
||||||
email: '[email protected]',
|
|
||||||
role: 'admin',
|
|
||||||
banned: false,
|
|
||||||
banReason: null,
|
|
||||||
createdAt: '2026-08-01T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'u-member',
|
|
||||||
name: 'Mel Member',
|
|
||||||
email: '[email protected]',
|
|
||||||
role: 'member',
|
|
||||||
banned: true,
|
|
||||||
banReason: 'spam',
|
|
||||||
createdAt: '2026-08-02T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-08-02T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLDivElement;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
apiMock.mockReset();
|
|
||||||
useSessionMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function renderAdminRoute(): Promise<void> {
|
|
||||||
const routes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
element: <AdminGuard />,
|
|
||||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
|
||||||
},
|
|
||||||
{ path: '/', element: <div>home page</div> },
|
|
||||||
{ path: '/login', element: <div>login page</div> },
|
|
||||||
];
|
|
||||||
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<RouterProvider router={router} />);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
|
|
||||||
return {
|
|
||||||
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
|
|
||||||
isPending: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AdminGuard', () => {
|
|
||||||
it('redirects unauthenticated visitors to /login', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('login page');
|
|
||||||
expect(apiMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects non-admin users to /', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('member'));
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('home page');
|
|
||||||
expect(apiMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the admin page for admin users', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockResolvedValueOnce(userFixtures);
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Admin Panel');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AdminPage users tab', () => {
|
|
||||||
it('lists users with role and ban status after load', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockResolvedValueOnce(userFixtures);
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
|
|
||||||
expect(container.textContent).toContain('Ada Admin');
|
|
||||||
expect(container.textContent).toContain('Mel Member');
|
|
||||||
expect(container.textContent).toContain('Banned');
|
|
||||||
expect(container.textContent).toContain('2 user(s)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the load error with a retry control', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('gateway unavailable');
|
|
||||||
|
|
||||||
apiMock.mockResolvedValueOnce(userFixtures);
|
|
||||||
const retry = [...container.querySelectorAll('button')].find((b) =>
|
|
||||||
b.textContent?.includes('Retry'),
|
|
||||||
);
|
|
||||||
expect(retry).toBeTruthy();
|
|
||||||
await act(async () => {
|
|
||||||
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Ada Admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('posts to the ban endpoint and reloads on Ban', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockResolvedValue(userFixtures);
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
const banButton = [...container.querySelectorAll('button')].find(
|
|
||||||
(b) => b.textContent === 'Ban',
|
|
||||||
);
|
|
||||||
expect(banButton).toBeTruthy();
|
|
||||||
await act(async () => {
|
|
||||||
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AdminPage health tab', () => {
|
|
||||||
it('loads health status when the tab is opened', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
|
|
||||||
status: 'ok',
|
|
||||||
database: { status: 'ok', latencyMs: 3 },
|
|
||||||
cache: { status: 'ok', latencyMs: 1 },
|
|
||||||
agentPool: { activeSessions: 2 },
|
|
||||||
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
|
|
||||||
checkedAt: '2026-08-26T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
const healthTab = [...container.querySelectorAll('button')].find((b) =>
|
|
||||||
b.textContent?.includes('System Health'),
|
|
||||||
);
|
|
||||||
await act(async () => {
|
|
||||||
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
|
|
||||||
expect(container.textContent).toContain('Database (PostgreSQL)');
|
|
||||||
expect(container.textContent).toContain('Active sessions: 2');
|
|
||||||
expect(container.textContent).toContain('4 models');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface UserDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
banned: boolean;
|
|
||||||
banReason: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserListDto {
|
|
||||||
users: UserDto[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceStatusDto {
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderStatusDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
modelCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HealthStatusDto {
|
|
||||||
status: 'ok' | 'degraded' | 'error';
|
|
||||||
database: ServiceStatusDto;
|
|
||||||
cache: ServiceStatusDto;
|
|
||||||
agentPool: { activeSessions: number };
|
|
||||||
providers: ProviderStatusDto[];
|
|
||||||
checkedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
|
|
||||||
// assumes an authenticated admin session.
|
|
||||||
export function AdminPage(): React.ReactElement {
|
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-1 border-b border-surface-border">
|
|
||||||
{(['users', 'health'] as const).map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2 text-sm font-medium capitalize transition-colors',
|
|
||||||
activeTab === tab
|
|
||||||
? 'border-b-2 border-blue-500 text-blue-400'
|
|
||||||
: 'text-text-secondary hover:text-text-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab === 'users' ? 'User Management' : 'System Health'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Users Tab ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function UsersTab(): React.ReactElement {
|
|
||||||
const [users, setUsers] = useState<UserDto[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await api<UserListDto>('/api/admin/users');
|
|
||||||
setUsers(data.users);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadUsers();
|
|
||||||
}, [loadUsers]);
|
|
||||||
|
|
||||||
async function handleRoleToggle(user: UserDto): Promise<void> {
|
|
||||||
const newRole = user.role === 'admin' ? 'member' : 'admin';
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/users/${user.id}/role`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { role: newRole },
|
|
||||||
});
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to update role');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBanToggle(user: UserDto): Promise<void> {
|
|
||||||
const endpoint = user.banned ? 'unban' : 'ban';
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to update ban status');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(user: UserDto): Promise<void> {
|
|
||||||
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to delete user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <p className="text-sm text-text-muted">Loading users...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadUsers()}
|
|
||||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-text-muted">{users.length} user(s)</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCreate(true)}
|
|
||||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
+ New User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCreate && (
|
|
||||||
<CreateUserForm
|
|
||||||
onCancel={() => setShowCreate(false)}
|
|
||||||
onCreated={() => {
|
|
||||||
setShowCreate(false);
|
|
||||||
void loadUsers();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{users.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No users found</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
|
||||||
<th className="px-4 py-2 font-medium">Name / Email</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Role</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="text-sm font-medium text-text-primary">{user.name}</div>
|
|
||||||
<div className="text-xs text-text-muted">{user.email}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
|
||||||
user.role === 'admin'
|
|
||||||
? 'bg-purple-500/20 text-purple-400'
|
|
||||||
: 'bg-surface-elevated text-text-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{user.role}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-3 md:table-cell">
|
|
||||||
{user.banned ? (
|
|
||||||
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
|
||||||
Banned
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
|
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleRoleToggle(user)}
|
|
||||||
className="text-xs text-blue-400 hover:text-blue-300"
|
|
||||||
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
|
|
||||||
>
|
|
||||||
{user.role === 'admin' ? 'Demote' : 'Promote'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleBanToggle(user)}
|
|
||||||
className={cn(
|
|
||||||
'text-xs',
|
|
||||||
user.banned
|
|
||||||
? 'text-green-400 hover:text-green-300'
|
|
||||||
: 'text-yellow-400 hover:text-yellow-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{user.banned ? 'Unban' : 'Ban'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleDelete(user)}
|
|
||||||
className="text-xs text-red-400 hover:text-red-300"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create User Form ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface CreateUserFormProps {
|
|
||||||
onCancel: () => void;
|
|
||||||
onCreated: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [role, setRole] = useState('member');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await api('/api/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { name, email, password, role },
|
|
||||||
});
|
|
||||||
onCreated();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
|
|
||||||
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
|
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Role</label>
|
|
||||||
<select
|
|
||||||
value={role}
|
|
||||||
onChange={(e) => setRole(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="member">member</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{submitting ? 'Creating...' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Health Tab ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function HealthTab(): React.ReactElement {
|
|
||||||
const [health, setHealth] = useState<HealthStatusDto | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadHealth = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await api<HealthStatusDto>('/api/admin/health');
|
|
||||||
setHealth(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load health');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadHealth();
|
|
||||||
}, [loadHealth]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <p className="text-sm text-text-muted">Loading health status...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadHealth()}
|
|
||||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!health) return <></>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status={health.status} />
|
|
||||||
<span className="text-sm text-text-muted">
|
|
||||||
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadHealth()}
|
|
||||||
className="text-xs text-blue-400 hover:text-blue-300"
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
{/* Database */}
|
|
||||||
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
|
|
||||||
{health.database.latencyMs !== undefined && (
|
|
||||||
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
|
|
||||||
)}
|
|
||||||
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
|
|
||||||
</HealthCard>
|
|
||||||
|
|
||||||
{/* Cache */}
|
|
||||||
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
|
|
||||||
{health.cache.latencyMs !== undefined && (
|
|
||||||
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
|
|
||||||
)}
|
|
||||||
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
|
|
||||||
</HealthCard>
|
|
||||||
|
|
||||||
{/* Agent Pool */}
|
|
||||||
<HealthCard title="Agent Pool" status="ok">
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
Active sessions: {health.agentPool.activeSessions}
|
|
||||||
</p>
|
|
||||||
</HealthCard>
|
|
||||||
|
|
||||||
{/* Providers */}
|
|
||||||
<HealthCard
|
|
||||||
title="LLM Providers"
|
|
||||||
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
|
|
||||||
>
|
|
||||||
{health.providers.length === 0 ? (
|
|
||||||
<p className="text-xs text-text-muted">No providers configured</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{health.providers.map((p) => (
|
|
||||||
<li key={p.id} className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-text-secondary">{p.name}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-1.5 py-0.5',
|
|
||||||
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{p.available ? `${p.modelCount} models` : 'unavailable'}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</HealthCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helper Components ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
|
|
||||||
const map = {
|
|
||||||
ok: 'bg-green-500/20 text-green-400',
|
|
||||||
degraded: 'bg-yellow-500/20 text-yellow-400',
|
|
||||||
error: 'bg-red-500/20 text-red-400',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HealthCardProps {
|
|
||||||
title: string;
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
|
||||||
<span
|
|
||||||
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
|
|
||||||
apiMock: vi.fn(),
|
|
||||||
useSessionMock: vi.fn(),
|
|
||||||
updateUserMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
useSession: useSessionMock,
|
|
||||||
authClient: { updateUser: updateUserMock },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { SettingsPage } from './settings';
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLDivElement;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
apiMock.mockReset();
|
|
||||||
useSessionMock.mockReset();
|
|
||||||
updateUserMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function renderSettingsPage(): Promise<void> {
|
|
||||||
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
|
|
||||||
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<RouterProvider router={router} />);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clickButtonByText(text: string): Promise<void> {
|
|
||||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
|
||||||
candidate.textContent?.includes(text),
|
|
||||||
);
|
|
||||||
if (!button) {
|
|
||||||
throw new Error(`Button containing "${text}" not found`);
|
|
||||||
}
|
|
||||||
return act(async () => {
|
|
||||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = {
|
|
||||||
user: { id: 'u-1', name: 'Test User', email: '[email protected]', image: null },
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('SettingsPage profile tab', () => {
|
|
||||||
it('renders the profile form from the session and saves via authClient', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
|
||||||
updateUserMock.mockResolvedValue({});
|
|
||||||
|
|
||||||
await renderSettingsPage();
|
|
||||||
|
|
||||||
const nameInput = container.querySelector<HTMLInputElement>('#profile-name');
|
|
||||||
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
|
|
||||||
expect(nameInput?.value).toBe('Test User');
|
|
||||||
expect(emailInput?.value).toBe('[email protected]');
|
|
||||||
expect(emailInput?.disabled).toBe(true);
|
|
||||||
|
|
||||||
await clickButtonByText('Save changes');
|
|
||||||
|
|
||||||
expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null });
|
|
||||||
expect(container.textContent).toContain('Saved!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces an update failure without clearing the form', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
|
||||||
updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } });
|
|
||||||
|
|
||||||
await renderSettingsPage();
|
|
||||||
await clickButtonByText('Save changes');
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('name rejected');
|
|
||||||
expect(container.querySelector<HTMLInputElement>('#profile-name')?.value).toBe('Test User');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SettingsPage appearance tab', () => {
|
|
||||||
it('loads preferences and posts each changed preference on save', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
|
||||||
apiMock.mockImplementation((path: string) =>
|
|
||||||
path.startsWith('/api/memory/preferences?')
|
|
||||||
? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }])
|
|
||||||
: Promise.resolve({}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await renderSettingsPage();
|
|
||||||
await clickButtonByText('Appearance');
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance');
|
|
||||||
|
|
||||||
await clickButtonByText('Save changes');
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SettingsPage providers tab', () => {
|
|
||||||
it('loads LLM and SSO providers and runs a connection test', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
|
||||||
apiMock.mockImplementation((path: string, opts?: { method?: string }) => {
|
|
||||||
if (path === '/api/providers' && opts === undefined) {
|
|
||||||
return Promise.resolve([
|
|
||||||
{
|
|
||||||
id: 'ollama',
|
|
||||||
name: 'Ollama',
|
|
||||||
available: true,
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: 'llama3.2',
|
|
||||||
provider: 'ollama',
|
|
||||||
name: 'Llama 3.2',
|
|
||||||
reasoning: false,
|
|
||||||
contextWindow: 128_000,
|
|
||||||
maxTokens: 4096,
|
|
||||||
inputTypes: ['text'],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (path === '/api/sso/providers') {
|
|
||||||
return Promise.resolve([]);
|
|
||||||
}
|
|
||||||
if (path === '/api/providers/test') {
|
|
||||||
return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 });
|
|
||||||
}
|
|
||||||
return Promise.resolve([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await renderSettingsPage();
|
|
||||||
await clickButtonByText('Providers');
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Ollama');
|
|
||||||
expect(container.textContent).toContain('1 model');
|
|
||||||
|
|
||||||
await clickButtonByText('Test');
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/providers/test', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { providerId: 'ollama' },
|
|
||||||
});
|
|
||||||
expect(container.textContent).toContain('Reachable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,826 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { authClient, useSession } from '@/lib/auth-client';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface ModelInfo {
|
|
||||||
id: string;
|
|
||||||
provider: string;
|
|
||||||
name: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
contextWindow: number;
|
|
||||||
maxTokens: number;
|
|
||||||
inputTypes: ('text' | 'image')[];
|
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
models: ModelInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestConnectionResult {
|
|
||||||
providerId: string;
|
|
||||||
reachable: boolean;
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string;
|
|
||||||
discoveredModels?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestState = 'idle' | 'testing' | 'success' | 'error';
|
|
||||||
|
|
||||||
interface ProviderTestStatus {
|
|
||||||
state: TestState;
|
|
||||||
result?: TestConnectionResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Preference {
|
|
||||||
key: string;
|
|
||||||
value: unknown;
|
|
||||||
category: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|
||||||
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
|
||||||
const p = prefs.find((x) => x.key === key);
|
|
||||||
if (p === undefined) return fallback;
|
|
||||||
return p.value as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function SettingsPage(): React.ReactElement {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string }[] = [
|
|
||||||
{ id: 'profile', label: 'Profile' },
|
|
||||||
{ id: 'appearance', label: 'Appearance' },
|
|
||||||
{ id: 'notifications', label: 'Notifications' },
|
|
||||||
{ id: 'providers', label: 'Providers' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
|
||||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
|
||||||
|
|
||||||
{/* Tab bar */}
|
|
||||||
<div className="flex gap-1 border-b border-surface-border">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-b-2 border-accent text-accent'
|
|
||||||
: 'text-text-secondary hover:text-text-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'profile' && <ProfileTab session={session} />}
|
|
||||||
{activeTab === 'appearance' && <AppearanceTab />}
|
|
||||||
{activeTab === 'notifications' && <NotificationsTab />}
|
|
||||||
{activeTab === 'providers' && <ProvidersTab />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Profile Tab ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ProfileTab({
|
|
||||||
session,
|
|
||||||
}: {
|
|
||||||
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const [name, setName] = useState(session?.user.name ?? '');
|
|
||||||
const [image, setImage] = useState(session?.user.image ?? '');
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
|
|
||||||
// Sync from session when it loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.user) {
|
|
||||||
setName(session.user.name ?? '');
|
|
||||||
setImage(session.user.image ?? '');
|
|
||||||
}
|
|
||||||
}, [session]);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
const result = await authClient.updateUser({ name, image: image || null });
|
|
||||||
if (result.error) {
|
|
||||||
setErrorMsg(result.error.message ?? 'Failed to update profile');
|
|
||||||
setSaveState('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaveState('saved');
|
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
|
||||||
setErrorMsg(message);
|
|
||||||
setSaveState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
|
|
||||||
<FormField label="Display Name" id="profile-name">
|
|
||||||
<input
|
|
||||||
id="profile-name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Your name"
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Email" id="profile-email">
|
|
||||||
<input
|
|
||||||
id="profile-email"
|
|
||||||
type="email"
|
|
||||||
value={session?.user.email ?? ''}
|
|
||||||
disabled
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Avatar URL" id="profile-image">
|
|
||||||
<input
|
|
||||||
id="profile-image"
|
|
||||||
type="url"
|
|
||||||
value={image}
|
|
||||||
onChange={(e) => setImage(e.target.value)}
|
|
||||||
placeholder="https://example.com/avatar.png"
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Appearance Tab ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function AppearanceTab(): React.ReactElement {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [theme, setTheme] = useState<Theme>('system');
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
||||||
const [defaultModel, setDefaultModel] = useState('');
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
|
||||||
.catch(() => [] as Preference[])
|
|
||||||
.then((p) => {
|
|
||||||
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
|
|
||||||
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
|
|
||||||
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
|
|
||||||
}),
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'ui.sidebar_collapsed',
|
|
||||||
value: sidebarCollapsed,
|
|
||||||
category: 'appearance',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...(defaultModel
|
|
||||||
? [
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'ui.default_model',
|
|
||||||
value: defaultModel,
|
|
||||||
category: 'appearance',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]);
|
|
||||||
setSaveState('saved');
|
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
|
||||||
setErrorMsg(message);
|
|
||||||
setSaveState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
|
|
||||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
|
||||||
{/* Theme */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTheme(t)}
|
|
||||||
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
|
|
||||||
theme === t
|
|
||||||
? 'border-accent bg-accent/10 text-accent'
|
|
||||||
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar collapsed default */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
|
|
||||||
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
|
|
||||||
</div>
|
|
||||||
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Default model */}
|
|
||||||
<FormField label="Default Model" id="default-model">
|
|
||||||
<input
|
|
||||||
id="default-model"
|
|
||||||
type="text"
|
|
||||||
value={defaultModel}
|
|
||||||
onChange={(e) => setDefaultModel(e.target.value)}
|
|
||||||
placeholder="e.g. ollama/llama3.2"
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">
|
|
||||||
Model ID to pre-select for new conversations.
|
|
||||||
</p>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Notifications Tab ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function NotificationsTab(): React.ReactElement {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
|
|
||||||
const [emailMentions, setEmailMentions] = useState(true);
|
|
||||||
const [emailDigest, setEmailDigest] = useState(false);
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Preference[]>('/api/memory/preferences?category=communication')
|
|
||||||
.catch(() => [] as Preference[])
|
|
||||||
.then((p) => {
|
|
||||||
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
|
|
||||||
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
|
|
||||||
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'notify.email_agent_complete',
|
|
||||||
value: emailAgentComplete,
|
|
||||||
category: 'communication',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'notify.email_mentions',
|
|
||||||
value: emailMentions,
|
|
||||||
category: 'communication',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'notify.email_digest',
|
|
||||||
value: emailDigest,
|
|
||||||
category: 'communication',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
setSaveState('saved');
|
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
|
||||||
setErrorMsg(message);
|
|
||||||
setSaveState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
|
|
||||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
|
||||||
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
|
|
||||||
|
|
||||||
<NotifyRow
|
|
||||||
label="Agent task completed"
|
|
||||||
description="Email when an agent finishes a task"
|
|
||||||
checked={emailAgentComplete}
|
|
||||||
onChange={setEmailAgentComplete}
|
|
||||||
/>
|
|
||||||
<NotifyRow
|
|
||||||
label="Mentions"
|
|
||||||
description="Email when you are mentioned in a conversation"
|
|
||||||
checked={emailMentions}
|
|
||||||
onChange={setEmailMentions}
|
|
||||||
/>
|
|
||||||
<NotifyRow
|
|
||||||
label="Weekly digest"
|
|
||||||
description="Weekly summary of activity"
|
|
||||||
checked={emailDigest}
|
|
||||||
onChange={setEmailDigest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Providers Tab ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ProvidersTab(): React.ReactElement {
|
|
||||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
||||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [ssoLoading, setSsoLoading] = useState(true);
|
|
||||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<ProviderInfo[]>('/api/providers')
|
|
||||||
.catch(() => [] as ProviderInfo[])
|
|
||||||
.then((p) => setProviders(p))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
|
||||||
.catch(() => [] as SsoProviderDiscovery[])
|
|
||||||
.then((providers) => setSsoProviders(providers))
|
|
||||||
.finally(() => setSsoLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
|
||||||
setTestStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[providerId]: { state: 'testing' },
|
|
||||||
}));
|
|
||||||
try {
|
|
||||||
const result = await api<TestConnectionResult>('/api/providers/test', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { providerId },
|
|
||||||
});
|
|
||||||
setTestStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[providerId]: { state: result.reachable ? 'success' : 'error', result },
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
setTestStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[providerId]: {
|
|
||||||
state: 'error',
|
|
||||||
result: { providerId, reachable: false, error: 'Request failed' },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const defaultModel: ModelInfo | undefined = providers
|
|
||||||
.flatMap((p) => p.models)
|
|
||||||
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
|
|
||||||
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
|
||||||
) : providers.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
No providers configured. Set{' '}
|
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
|
||||||
OLLAMA_BASE_URL
|
|
||||||
</code>{' '}
|
|
||||||
or{' '}
|
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
|
||||||
MOSAIC_CUSTOM_PROVIDERS
|
|
||||||
</code>{' '}
|
|
||||||
to add providers.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{providers.map((provider) => (
|
|
||||||
<ProviderCard
|
|
||||||
key={provider.id}
|
|
||||||
provider={provider}
|
|
||||||
defaultModel={defaultModel}
|
|
||||||
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
|
||||||
onTest={() => void testConnection(provider.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shared UI Components ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function FormField({
|
|
||||||
label,
|
|
||||||
id,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
id: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
|
|
||||||
checked ? 'bg-accent' : 'bg-surface-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
checked ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotifyRow({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-primary">{label}</p>
|
|
||||||
<p className="text-xs text-text-muted">{description}</p>
|
|
||||||
</div>
|
|
||||||
<Toggle checked={checked} onChange={onChange} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SaveButton({
|
|
||||||
state,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
state: SaveState;
|
|
||||||
onClick: () => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={state === 'saving'}
|
|
||||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Provider Card (from original page) ──────────────────────────────────────
|
|
||||||
|
|
||||||
interface ProviderCardProps {
|
|
||||||
provider: ProviderInfo;
|
|
||||||
defaultModel: ModelInfo | undefined;
|
|
||||||
testStatus: ProviderTestStatus;
|
|
||||||
onTest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderCard({
|
|
||||||
provider,
|
|
||||||
defaultModel,
|
|
||||||
testStatus,
|
|
||||||
onTest,
|
|
||||||
}: ProviderCardProps): React.ReactElement {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card">
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ProviderAvatar id={provider.id} />
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
|
|
||||||
<ProviderStatusBadge available={provider.available} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TestConnectionButton status={testStatus} onTest={onTest} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
aria-label={expanded ? 'Collapse models' : 'Expand models'}
|
|
||||||
>
|
|
||||||
{expanded ? '▲ Hide' : '▼ Models'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test result banner */}
|
|
||||||
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
|
|
||||||
<TestResultBanner result={testStatus.result} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Model list */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t border-surface-border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
|
|
||||||
<th className="px-4 py-2 font-medium">Model</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Default</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{provider.models.map((model) => (
|
|
||||||
<ModelRow
|
|
||||||
key={model.id}
|
|
||||||
model={model}
|
|
||||||
isDefault={
|
|
||||||
defaultModel?.id === model.id && defaultModel?.provider === model.provider
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelRowProps {
|
|
||||||
model: ModelInfo;
|
|
||||||
isDefault: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<tr className="border-t border-surface-border">
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className="text-sm text-text-primary">{model.name}</span>
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 md:table-cell">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<CapabilityBadge label="chat" />
|
|
||||||
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
|
|
||||||
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{formatContext(model.contextWindow)}
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{model.cost.input === 0 && model.cost.output === 0
|
|
||||||
? 'free'
|
|
||||||
: `$${model.cost.input} / $${model.cost.output}`}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-center">
|
|
||||||
{isDefault && (
|
|
||||||
<span
|
|
||||||
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
|
|
||||||
title="Default model used for new sessions"
|
|
||||||
>
|
|
||||||
default
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
|
|
||||||
const letter = id.charAt(0).toUpperCase();
|
|
||||||
return (
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
|
|
||||||
{letter}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
||||||
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{available ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestConnectionButtonProps {
|
|
||||||
status: ProviderTestStatus;
|
|
||||||
onTest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
|
|
||||||
const isTesting = status.state === 'testing';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onTest}
|
|
||||||
disabled={isTesting}
|
|
||||||
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
title="Test connection"
|
|
||||||
>
|
|
||||||
{isTesting ? (
|
|
||||||
<span className="text-text-muted">Testing…</span>
|
|
||||||
) : status.state === 'success' ? (
|
|
||||||
<span className="text-success">✓ Reachable</span>
|
|
||||||
) : status.state === 'error' ? (
|
|
||||||
<span className="text-error">✗ Unreachable</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-text-muted">Test</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`px-4 py-2 text-xs ${
|
|
||||||
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{result.reachable ? (
|
|
||||||
<>
|
|
||||||
Connected
|
|
||||||
{result.latencyMs !== undefined && (
|
|
||||||
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
|
|
||||||
)}
|
|
||||||
{result.discoveredModels && result.discoveredModels.length > 0 && (
|
|
||||||
<span className="ml-2 opacity-70">
|
|
||||||
— {result.discoveredModels.length} model
|
|
||||||
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CapabilityBadge({
|
|
||||||
label,
|
|
||||||
color = 'default',
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
color?: 'default' | 'purple' | 'blue';
|
|
||||||
}): React.ReactElement {
|
|
||||||
const colorClass =
|
|
||||||
color === 'purple'
|
|
||||||
? 'bg-purple-500/20 text-purple-400'
|
|
||||||
: color === 'blue'
|
|
||||||
? 'bg-blue-500/20 text-blue-400'
|
|
||||||
: 'bg-surface-elevated text-text-muted';
|
|
||||||
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatContext(tokens: number): string {
|
|
||||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
||||||
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
|
||||||
return String(tokens);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,328 +0,0 @@
|
|||||||
# Identity Account-Lifecycle Contract
|
|
||||||
|
|
||||||
Status: DRAFT — awaiting ratification (webui-audit S2, contract 4 of 9).
|
|
||||||
Authority: PRD D10 (better-auth is the account system of record), Q1 ruled O1
|
|
||||||
by Jason 2026-08-26 (webui-audit T10). This document turns that ruling into
|
|
||||||
enforceable policy. It also carries the bootstrap/first-admin invariant from
|
|
||||||
issue #1430, folded in here after PR #1431's independent review showed the
|
|
||||||
quick-fix approach was insufficient.
|
|
||||||
|
|
||||||
Revision 2: addresses the 9 findings of the independent review
|
|
||||||
(`fleet/lanes/webui-audit/findings/pr1433-review.md`) — epoch enforcement
|
|
||||||
tightened (§3), canonical email split from provider claims (§5), external
|
|
||||||
principal keyed by issuer+subject with DB uniqueness and link step-up (§6),
|
|
||||||
JIT default precedence and first-admin SSO path defined (§2, §4),
|
|
||||||
deactivation made measurable (§7.1), deletion kept in scope and the existing
|
|
||||||
hard-delete endpoint required to fail closed (§7.3), workspace identity
|
|
||||||
reconciled with the native-kanban SOT (§1.4), verification matrix expanded
|
|
||||||
(§8), factual labels corrected (§7.3, §8.1).
|
|
||||||
|
|
||||||
Revision 3: addresses the residuals and new findings of the revision-2
|
|
||||||
re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) —
|
|
||||||
`users.emailVerified` added to the canonical set with a defined reset rule on
|
|
||||||
email change (§5.1–5.2), external-principal uniqueness moved to
|
|
||||||
(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped
|
|
||||||
delete affordances (web admin page, `mosaic auth users delete`) required to
|
|
||||||
be removed or disabled with a defined user-visible state (§7.3), the
|
|
||||||
admin-creation switch removed in favor of plain admin authorization (§2.3),
|
|
||||||
and §8 extended with observables for IdP removal, forward-auth non-use,
|
|
||||||
first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and
|
|
||||||
positive/expiry-bound step-up cases.
|
|
||||||
|
|
||||||
Scope: account creation, bootstrap, federated login, account linking, claim
|
|
||||||
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
|
|
||||||
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
|
|
||||||
(contract 1), sensitive-data custody (contract 7 / D14).
|
|
||||||
|
|
||||||
## 1. System of record
|
|
||||||
|
|
||||||
1. better-auth's tables (`users`, `accounts`, `sessions`, `verifications`) are
|
|
||||||
the only account system of record. All foreign keys reference `users.id`.
|
|
||||||
2. External IdPs (Authentik or any OIDC provider) are login methods, attached
|
|
||||||
through better-auth's generic-OAuth plugin (`packages/auth/src/sso.ts`).
|
|
||||||
They never own accounts. Removing an IdP removes a login method, not users.
|
|
||||||
3. The forward-auth perimeter shim is a deployment measure. Once in-app OIDC
|
|
||||||
is configured for a deployment, the shim is demoted: it may stay as network
|
|
||||||
perimeter, but no application code may read identity from its headers.
|
|
||||||
4. **Account ≠ workspace membership.** Creating an account (by any path:
|
|
||||||
bootstrap, sign-up, invite, JIT, admin creation) creates no workspace, no
|
|
||||||
hierarchy grant, and no workspace-scoped authority (native-kanban SOT
|
|
||||||
REQ-TEN-001 / REQ-ID-001). The better-auth `role` field is a platform/auth
|
|
||||||
role (`member` | `admin`), not workspace membership. Workspace grants are
|
|
||||||
defined by contract 2; until then a fresh account can authenticate and
|
|
||||||
holds no workspace authority.
|
|
||||||
|
|
||||||
## 2. Registration gating
|
|
||||||
|
|
||||||
Measured current state on `next`: `emailAndPassword.enabled: true` with no
|
|
||||||
gating — anyone who can reach the Gateway can create an account via
|
|
||||||
`POST /api/auth/sign-up/email` and receives role `member`.
|
|
||||||
|
|
||||||
Contract:
|
|
||||||
|
|
||||||
1. A single server-side setting `registration_mode` with values
|
|
||||||
`open | invite | closed`. It lives in the database (admin-mutable at
|
|
||||||
runtime), not in env config.
|
|
||||||
2. Default after bootstrap: `closed`. The wizard (contract 3) may set a
|
|
||||||
different mode during setup, recorded as an explicit operator choice.
|
|
||||||
While the bootstrap epoch is open (§3), the effective mode is `closed`
|
|
||||||
regardless of any stored value: the setting takes effect only after the
|
|
||||||
epoch completes.
|
|
||||||
3. `closed` blocks self-service email/password sign-up. It does not block
|
|
||||||
admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is
|
|
||||||
gated by admin authorization alone — there is no separate switch for it.
|
|
||||||
JIT is gated by its per-provider flag (§4.1). All user-creating paths are
|
|
||||||
closed while the bootstrap epoch is open (§3).
|
|
||||||
4. `invite` requires a single-use, expiring invite token bound to an email
|
|
||||||
address. Invite issuance is an admin operation and is audit-logged.
|
|
||||||
5. Enforcement point: a better-auth hook (or equivalent middleware executed
|
|
||||||
inside the auth handler path), not a Gateway route guard in front of it —
|
|
||||||
the raw `/api/auth/*` handler must be incapable of bypassing the gate.
|
|
||||||
|
|
||||||
## 3. Bootstrap / first-admin invariant (from #1430)
|
|
||||||
|
|
||||||
Invariant: **the system transitions from zero users to one admin user exactly
|
|
||||||
once per bootstrap epoch, atomically, regardless of concurrency or which code
|
|
||||||
path writes users.**
|
|
||||||
|
|
||||||
Constraints any implementation MUST satisfy (each traces to a verified defect
|
|
||||||
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):
|
|
||||||
|
|
||||||
1. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
|
|
||||||
lives in a constraint-backed one-row `bootstrap_state` table. While the
|
|
||||||
epoch is open, every non-bootstrap user-creating writer — better-auth
|
|
||||||
sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
|
|
||||||
the writer's own path (better-auth hook for the raw handler; guard for
|
|
||||||
admin routes). A partial unique index or a winning epoch-transition row is
|
|
||||||
necessary but not sufficient on its own: neither stops an untagged insert
|
|
||||||
from a writer that never consulted the epoch. Both layers are required:
|
|
||||||
database-level transition safety (the epoch-completing write races safely
|
|
||||||
and at most one wins) and writer-level refusal (no path can create a user
|
|
||||||
without reading epoch state).
|
|
||||||
2. **Atomic first-admin transition.** The admin user, its credential account,
|
|
||||||
the initial admin token, and the epoch-completed transition commit in one
|
|
||||||
database transaction or not at all. A better-auth call through
|
|
||||||
`drizzleAdapter(db)` runs on the root pool and is NOT part of any caller
|
|
||||||
transaction; it may be used inside the bootstrap transition only if the
|
|
||||||
adapter is explicitly bound to the transaction handle. Otherwise the
|
|
||||||
bootstrap writer must create the user rows itself within the transaction.
|
|
||||||
3. **Pool safety.** No design may hold a pooled connection inside a
|
|
||||||
transaction while awaiting a write that acquires a second connection from
|
|
||||||
the same pool (`DB_POOL_MAX=1` is a supported configuration).
|
|
||||||
4. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin
|
|
||||||
epoch completes, re-running the wizard reconfigures the system but never
|
|
||||||
re-opens the zero-user transition. "Setup already completed" is a stable,
|
|
||||||
testable state, and factory-reset (a future, explicitly destructive
|
|
||||||
operation) is the only way to open a new epoch.
|
|
||||||
5. **No stranded partial outcome.** A failure at any point in the transition
|
|
||||||
leaves nothing observable (no admin user without its token, no completed
|
|
||||||
epoch without an admin) and setup remains retryable — this follows from
|
|
||||||
§3.2 and is stated separately because it is the pre-existing failure mode
|
|
||||||
the #1431 review verified.
|
|
||||||
6. **First-admin via SSO (D4).** When the operator chooses SSO for the
|
|
||||||
initial user, the wizard executes the OIDC login as part of the bootstrap
|
|
||||||
transition itself: the bootstrap writer creates the account from the
|
|
||||||
asserted identity inside the §3.2 transaction. This path is the bootstrap
|
|
||||||
writer, not JIT — §4's JIT gate stays closed during the epoch and is not
|
|
||||||
an obstacle to D4.
|
|
||||||
|
|
||||||
## 4. JIT provisioning (OIDC first login)
|
|
||||||
|
|
||||||
1. A successful OIDC login with no matching account creates a user
|
|
||||||
just-in-time only when `jit_provisioning` is enabled for that provider.
|
|
||||||
The flag is per-provider and defaults off, always. There is no
|
|
||||||
mode-implied default: Enterprise setup enables JIT only when the wizard
|
|
||||||
records it as an explicit operator choice for a named provider (this
|
|
||||||
replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
|
|
||||||
enabled", which contradicted the per-provider default).
|
|
||||||
2. JIT users receive platform role `member`, never an elevated role,
|
|
||||||
regardless of IdP claims (§5), and no workspace authority (§1.4).
|
|
||||||
3. An optional per-provider email-domain allowlist constrains JIT. The
|
|
||||||
allowlist matches only when the IdP asserts the email with
|
|
||||||
`email_verified: true`; an unverified address never satisfies the
|
|
||||||
allowlist. Empty allowlist with JIT on means any authenticated subject at
|
|
||||||
that IdP gets an account — permitted, but the wizard must present it as an
|
|
||||||
explicit choice.
|
|
||||||
4. JIT is disabled while the bootstrap epoch is open (§3.1). The first-admin
|
|
||||||
SSO path is §3.6, not JIT.
|
|
||||||
|
|
||||||
## 5. Claim mapping
|
|
||||||
|
|
||||||
1. **Two stores, not one.** Provider-observed claims (`email`,
|
|
||||||
`email_verified`, display name, avatar) are recorded per external
|
|
||||||
principal — keyed by issuer + subject (§6.1) — at first login and
|
|
||||||
refreshed at each login. The canonical account fields (`users.email`,
|
|
||||||
`users.emailVerified`, `users.name`, `users.image`) are set exactly once
|
|
||||||
at account creation and are never silently overwritten by a later login.
|
|
||||||
For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified`
|
|
||||||
is set from the provider's `email_verified` claim at creation; for
|
|
||||||
password-created accounts it is false until the address completes
|
|
||||||
verification.
|
|
||||||
2. **Canonical email changes only through an explicit workflow.** Either the
|
|
||||||
user-initiated email change (with verification of the new address) or an
|
|
||||||
admin edit. Any canonical email change — user- or admin-initiated — sets
|
|
||||||
`users.emailVerified` to false until the new address completes
|
|
||||||
verification; an admin may instead explicitly attest the address as
|
|
||||||
verified in the same operation, and that attestation is audit-logged. A
|
|
||||||
provider-claim refresh never rebinds `users.email` or
|
|
||||||
`users.emailVerified`; a divergence between canonical email and the latest
|
|
||||||
provider-observed email is surfaced per §6.4.
|
|
||||||
3. Never mapped from IdP claims: `role` and any future authorization
|
|
||||||
attribute. Authorization lives in the system of record and in the RBAC
|
|
||||||
layer (contract 2). An IdP group/role claim may at most be recorded for
|
|
||||||
audit; it grants nothing.
|
|
||||||
|
|
||||||
## 6. Account linking trust
|
|
||||||
|
|
||||||
1. **External principal identity is issuer + subject.** A linked identity is
|
|
||||||
keyed by the OIDC issuer and subject claims, not by an unqualified
|
|
||||||
provider subject id and not by email. The linked-identity row stores the
|
|
||||||
issuer, and the database enforces at most one local account per
|
|
||||||
**(issuer, subject)** with a unique constraint on those stored columns —
|
|
||||||
uniqueness on (provider, subject) is insufficient because provider →
|
|
||||||
issuer is not one-to-one: two provider configurations can point at the
|
|
||||||
same issuer, and the identity must not alias across them. The current
|
|
||||||
non-unique `(provider_id, account_id)` index satisfies neither;
|
|
||||||
application-level checks without a uniqueness witness lose
|
|
||||||
concurrent-callback races. Each configured provider additionally binds to
|
|
||||||
exactly one issuer, immutable after creation (changing the issuer means
|
|
||||||
creating a new provider).
|
|
||||||
2. Linking an OIDC identity to an existing account happens only in one of two
|
|
||||||
ways: (a) explicit link initiated by the logged-in user from settings,
|
|
||||||
which requires step-up: a fresh reauthentication (password or existing
|
|
||||||
linked method) no older than a short bound the implementation defines
|
|
||||||
(≤ 10 minutes) — a session cookie alone is insufficient, so a stolen
|
|
||||||
session cannot quietly attach a durable login method; or (b) automatic
|
|
||||||
link when the IdP asserts a verified email exactly matching an existing
|
|
||||||
account **and** the provider is marked `trusted_for_linking`
|
|
||||||
(per-provider flag, default off).
|
|
||||||
3. Untrusted-provider email collision produces a login error naming the
|
|
||||||
conflict, not an auto-link and not a duplicate account.
|
|
||||||
4. A linked identity whose IdP-observed email later diverges from the
|
|
||||||
canonical account email keeps working (the link is by issuer + subject,
|
|
||||||
§6.1) but the divergence is surfaced in the user's settings and audit log
|
|
||||||
(the per-principal claim store in §5.1 is what makes the divergence
|
|
||||||
representable).
|
|
||||||
5. Unlinking a login method is refused when it would leave the account with
|
|
||||||
no **usable** login method. Usable means: a set password, or a linked
|
|
||||||
identity whose provider is currently configured and enabled on this
|
|
||||||
deployment. A linked identity whose provider has been removed or disabled
|
|
||||||
(§1.2) is not usable and does not count; setting a password first lifts
|
|
||||||
the refusal.
|
|
||||||
|
|
||||||
## 7. Deactivation propagation
|
|
||||||
|
|
||||||
1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
|
|
||||||
Concretely:
|
|
||||||
- Ban and session revocation are one operation: the ban commit revokes all
|
|
||||||
better-auth sessions for the user. If revocation partially fails, the
|
|
||||||
ban itself must already be committed and every guard denies from that
|
|
||||||
point (fail closed); the operation is retryable.
|
|
||||||
- Every authenticated entry path checks banned state: HTTP session guards,
|
|
||||||
the admin bearer-token path (which today does not test `banned` — an
|
|
||||||
implementation defect this contract makes non-conformant), MCP, and
|
|
||||||
Socket.IO.
|
|
||||||
- Active socket connections are terminated or denied within 30 seconds of
|
|
||||||
the ban commit, or at the next inbound message on that socket, whichever
|
|
||||||
comes first (socket auth at connect-time only, as today, does not
|
|
||||||
satisfy this).
|
|
||||||
- The current admin ban route updates only the user row; it does not
|
|
||||||
conform to this section until revocation and guard coverage land.
|
|
||||||
- Admin tokens owned by the banned user are revoked in the same operation.
|
|
||||||
2. Deactivation at an external IdP does not propagate automatically in this
|
|
||||||
contract's scope (no SCIM). Operational rule: removing a user from the IdP
|
|
||||||
without banning them in Mosaic leaves any password or other linked login
|
|
||||||
method usable — the admin guide must state this. SCIM/webhook-driven
|
|
||||||
propagation is future work and out of scope here.
|
|
||||||
3. **Deletion is not deactivation, and deletion is gated here.** Account
|
|
||||||
deletion semantics (FK fan-out across the 21 foreign-key constraints to
|
|
||||||
`users.id`, spread over 19 referencing tables) require their own
|
|
||||||
deletion-and-retention contract, chartered as an addition to the S2 list —
|
|
||||||
contract 7 is the D14 sensitive-data custody contract and does not cover
|
|
||||||
account deletion. Until that deletion contract is ratified: the existing
|
|
||||||
hard-delete endpoint (`DELETE /api/admin/users/:id`) is disabled and fails
|
|
||||||
closed, and deactivation is the only supported removal operation. A
|
|
||||||
contract that merely declared deactivation "the only supported removal"
|
|
||||||
while the endpoint stayed live would be false on its face.
|
|
||||||
Disabling the endpoint alone is insufficient — its shipped callers must
|
|
||||||
not be left as advertised operations that now fail generically:
|
|
||||||
- The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx`
|
|
||||||
and any SPA port of it) is removed, or replaced by a disabled control
|
|
||||||
whose visible text states that deletion is unavailable pending the
|
|
||||||
deletion-and-retention contract and points at deactivation.
|
|
||||||
- The CLI command `mosaic auth users delete`
|
|
||||||
(`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero
|
|
||||||
with a message stating the same and naming the deactivation command.
|
|
||||||
- Both surfaces expose deactivation as the supported operation.
|
|
||||||
|
|
||||||
## 8. Verification requirements
|
|
||||||
|
|
||||||
Every MUST above needs a bounded observable. The matrix:
|
|
||||||
|
|
||||||
1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
|
|
||||||
distinct physical connections (pattern:
|
|
||||||
`apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
|
|
||||||
which runs in the `test` CI step against the `ci-postgres` PostgreSQL
|
|
||||||
service — note that pattern multiplexes one pooled handle, so the tests
|
|
||||||
here must explicitly open separate connections). Races to cover:
|
|
||||||
setup-vs-setup, setup-vs-raw-sign-up, setup-vs-JIT, setup-vs-admin-create.
|
|
||||||
Plus: liveness under `DB_POOL_MAX=1`; fault injection after each write in
|
|
||||||
the transition (user, credential, token, epoch) proving nothing observable
|
|
||||||
leaks and setup retries; wizard re-run after completion proving the
|
|
||||||
zero-user transition never re-opens. Mocked-transaction specs are
|
|
||||||
supplementary; they cannot prove serialization.
|
|
||||||
2. **Registration gating (§2).** Spec coverage of all three modes against the
|
|
||||||
raw `/api/auth/` handler path, not only Gateway controllers; invite
|
|
||||||
lifecycle (single-use, expiry, email binding); effective-`closed` while
|
|
||||||
the epoch is open regardless of stored mode.
|
|
||||||
3. **JIT (§4).** Provider flag off → no account on first OIDC login; on →
|
|
||||||
account with platform role `member` and no workspace grant; domain
|
|
||||||
allowlist rejects an unverified email claim even when the domain matches;
|
|
||||||
JIT refused while the epoch is open.
|
|
||||||
4. **Claim mapping (§5).** Login refresh updates the per-principal claim
|
|
||||||
store and touches none of the canonical fields (`users.email`,
|
|
||||||
`users.emailVerified`, name, image); explicit email-change workflow is the
|
|
||||||
only path that rebinds canonical email; every canonical email change
|
|
||||||
resets `users.emailVerified` to false unless the admin attestation path
|
|
||||||
is taken, and that attestation appears in the audit log.
|
|
||||||
5. **Linking (§6).** Unique-constraint witness: concurrent first-login
|
|
||||||
callbacks for the same (issuer, subject) yield exactly one account, and
|
|
||||||
two provider configurations sharing one issuer cannot create two accounts
|
|
||||||
for the same subject; trusted auto-link; untrusted collision error;
|
|
||||||
step-up both ways: an explicit link succeeds immediately after a fresh
|
|
||||||
reauthentication and is refused once the implementation's chosen bound
|
|
||||||
(≤ 10 minutes) has elapsed, and refused with no reauthentication at all;
|
|
||||||
unlink refusal when no remaining method is usable per §6.5, including the
|
|
||||||
removed-provider case, and acceptance after a password is set; divergence
|
|
||||||
surfaced after IdP email change.
|
|
||||||
6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed
|
|
||||||
(partial-failure injection); guard denial post-ban on each transport:
|
|
||||||
HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated
|
|
||||||
within the 30-second/next-message bound; banned user's admin tokens
|
|
||||||
unusable; hard-delete endpoint returns a fail-closed error while the
|
|
||||||
deletion contract is unratified; the admin web UI renders no live delete
|
|
||||||
action (absent, or disabled with the §7.3 text) and `mosaic auth users
|
|
||||||
delete` exits non-zero with the §7.3 message — both asserted by spec.
|
|
||||||
7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal:
|
|
||||||
deleting a provider configuration leaves every user row intact and every
|
|
||||||
other login method working (spec over the provider-config removal path).
|
|
||||||
Forward-auth non-use: with in-app OIDC configured, a request carrying
|
|
||||||
forward-auth identity headers and no session is treated as anonymous —
|
|
||||||
no code path derives identity from those headers (negative spec at the
|
|
||||||
Gateway entry). First-admin SSO: the §3.6 transition commits account,
|
|
||||||
token, and epoch atomically from the asserted identity, and fault
|
|
||||||
injection mid-transition leaves nothing observable (same harness as §8.1).
|
|
||||||
Wizard-recorded JIT choice: enabling JIT for a provider writes an
|
|
||||||
explicit per-provider operator-choice record, and no mode selection
|
|
||||||
enables it implicitly (assert the stored record, not UI behavior).
|
|
||||||
8. **Documentation observable (§7.2).** The admin guide contains the
|
|
||||||
IdP-removal-does-not-deactivate statement; verified by a docs assertion
|
|
||||||
(content check in CI or an enumerated review-checklist item on the
|
|
||||||
implementing PR) — a MUST about documentation needs a checkable artifact,
|
|
||||||
not intent.
|
|
||||||
|
|
||||||
## Ruling request
|
|
||||||
|
|
||||||
Ratify sections 1–8 as written, with one decision embedded: registration
|
|
||||||
defaults to `closed` after bootstrap (§2.2) — say "agreed" or name the mode
|
|
||||||
you want as the default.
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Tool↔Gateway Mapping Contract (D8)
|
||||||
|
|
||||||
|
Status: DRAFT — awaiting ratification (webui-audit S2, contract 5 of 9).
|
||||||
|
Authority: PRD D8/D12 (Part I §8) — the webUI sits OVER official tooling:
|
||||||
|
every webUI operation goes through the Gateway API backed by the same
|
||||||
|
official framework tooling the CLI uses, and a webUI operation with no
|
||||||
|
backing tool is scored **blocked on tooling** and the tool is built
|
||||||
|
first. Measured input: the webui-audit A5 tooling baseline
|
||||||
|
(operation-by-operation inventory of the current Gateway surface and the
|
||||||
|
P1 gaps, cross-reviewed; `fleet/lanes/webui-audit/findings/
|
||||||
|
A5-tooling-baseline.md` in the estate brain). The T10 ruling adopted the
|
||||||
|
targeted-update plan including building the D8 tools in A5's rank order.
|
||||||
|
|
||||||
|
Revision 2 (GLM review F1–F5): the §2 table completed against an
|
||||||
|
independent re-measurement of the live `apps/web` surface (mission
|
||||||
|
reads, coordination status, capability-gated `turn:send` added); rank-6
|
||||||
|
composition corrected to ranks 1 and 4; SOT citations corrected to §3
|
||||||
|
invariant 11 / REQ-TASK-001 / §5+A1; the §3.2 retirement clause
|
||||||
|
softened to match what the owning contracts actually schedule; §6.1
|
||||||
|
scoped to outbound calls with an extractability lint, and §6.3 given
|
||||||
|
static companions for §4.1 and §4.3.
|
||||||
|
|
||||||
|
This contract binds three things: the operation→tool mapping itself
|
||||||
|
(§2–§3), the command envelope every mapped operation satisfies
|
||||||
|
(§4), and the process rule that keeps the mapping closed (§5). Domain
|
||||||
|
semantics stay with their owning contracts — hierarchy (contract 1,
|
||||||
|
`hierarchy-schema.md`), grants (contract 2, `rbac-grant-model.md`),
|
||||||
|
wizard (contract 3, `onboarding-wizard.md`), identity
|
||||||
|
(`identity-lifecycle.md`), kanban lifecycle (`native-kanban-sot.md`
|
||||||
|
§5 and Amendment A1), roll-up (contract 8), API artifact format
|
||||||
|
(contract 9).
|
||||||
|
|
||||||
|
## 1. Definitions
|
||||||
|
|
||||||
|
1. **Official tool**: a command implemented in the framework packages and
|
||||||
|
exposed through the Gateway API; the CLI remains the primary execution
|
||||||
|
method for the same command (D8). The webUI is a Gateway client only.
|
||||||
|
2. **Mapped operation**: a webUI operation with a named official path in
|
||||||
|
§2 or §3. Anything else the webUI wants to do is unmapped and follows
|
||||||
|
§5.
|
||||||
|
3. **Legacy non-substitute**: an existing endpoint that resembles a P1
|
||||||
|
need but is contractually barred from backing it (§3.2).
|
||||||
|
|
||||||
|
## 2. P0 mapping (current operations, ratified as-is)
|
||||||
|
|
||||||
|
This table is the complete measured P0 surface: every Gateway call the
|
||||||
|
web app's production sources make at this revision's head appears as a
|
||||||
|
row (independently re-measured at review; the three calls the first
|
||||||
|
measurement missed — mission reads, coordination status, and the
|
||||||
|
capability-gated `turn:send` emit — are rows below). The surface stays
|
||||||
|
bound to these paths:
|
||||||
|
|
||||||
|
| WebUI operation | Official path |
|
||||||
|
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Register / log in / log out / OIDC callback | better-auth mount `/api/auth/*`; `GET /api/sso/providers` |
|
||||||
|
| List/show projects (legacy read) | `GET /api/projects`, `GET /api/projects/:id` |
|
||||||
|
| List tasks / task detail (legacy read) | `GET /api/tasks`, `GET /api/tasks/:id` — with the filtered legacy project/mission reads the same surfaces use |
|
||||||
|
| Mission list (legacy read) | `GET /api/missions` |
|
||||||
|
| Coordination status (legacy read) | `GET /api/coord/status` |
|
||||||
|
| Conversation CRUD/search/messages | `/api/conversations*` |
|
||||||
|
| Chat turn / stop / thinking / command execute+approve / streaming | `/chat` socket events `message`, `abort`, `set:thinking`, `command:execute`, `command:approve`; `turn:send` (capability-gated — emitted only when the server advertises the pi turn-runtime capability, which the current Gateway does not) |
|
||||||
|
| Harness/model selection | `GET /api/harnesses*`, `GET/PUT /api/chat/preferences/selection` |
|
||||||
|
| Preferences; provider inspect/test | `/api/memory/preferences`, `GET /api/providers`, `POST /api/providers/test` |
|
||||||
|
| Admin users / roles / ban / health | `/api/admin/users*`, `/api/admin/health` |
|
||||||
|
|
||||||
|
P0 rows inherit §4 obligations as their backing controllers are next
|
||||||
|
touched; they are not required to be retrofitted in one sweep.
|
||||||
|
|
||||||
|
## 3. P1 mapping (bound to the build-first tools)
|
||||||
|
|
||||||
|
1. Every P1 operation maps to exactly one build-first command family, in
|
||||||
|
the T10-ruled rank order:
|
||||||
|
|
||||||
|
| Rank | Command family (owning contract) | P1 webUI operations it backs |
|
||||||
|
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | Hierarchy command family (contract 1 §5; grants attach per contract 2) | Company/estate/platform-project/workspace CRUD, parentage and reparenting, hierarchy reads; the wizard's initial-hierarchy step (contract 3 §3.4) |
|
||||||
|
| 2 | Hierarchy RBAC command/evaluator (contract 2) | Grant create/change/revoke at company/estate/platform-project; inherited evaluation down to workspace; authorization-safe hierarchy queries |
|
||||||
|
| 3 | Typed kanban command/query surface (SOT §5, Amendment A1) | Workspace task lifecycle (create/edit/cancel/archive/move), board rank, typed queries |
|
||||||
|
| 4 | Agent enrollment command | Enroll one agent: harness, credential reference/API-key intake (values never echoed), name/persona, assignment scope (contract 3 §3.5) |
|
||||||
|
| 5 | Authorized roll-up query (contract 8) | Read-only aggregated task counts/statuses at every hierarchy level over readable workspaces only |
|
||||||
|
| 6 | Onboarding orchestration (contract 3) | The re-runnable wizard flow, composing ranks 1 and 4 (its only grant write rides inside the rank-1 company-create command, contract 2 §4.3) |
|
||||||
|
|
||||||
|
2. **Legacy non-substitutes.** The following MUST NOT back any P1
|
||||||
|
operation, matching the audit findings: legacy `/api/projects` and
|
||||||
|
`/api/tasks` CRUD (planning-data records, not hierarchy nodes and not
|
||||||
|
the typed kanban boundary); `POST /api/workspaces` (filesystem
|
||||||
|
bootstrap, not audited hierarchy parentage); `/api/teams` reads (no
|
||||||
|
grants, no inheritance); `POST /api/bootstrap/setup` (one-shot
|
||||||
|
epoch transition, identity §3 — not the re-runnable wizard); the MCP
|
||||||
|
`brain_*` task mutations (legacy Brain writes, not the typed kanban
|
||||||
|
commands). These stay serving their existing P0/host consumers until
|
||||||
|
the owning contract (or a successor amendment) schedules each
|
||||||
|
retirement — no such migration is scheduled at this revision; the
|
||||||
|
freeze stands on its own.
|
||||||
|
3. New P1 mapping rows (operations this table does not list) are added by
|
||||||
|
amending this contract, not ad hoc (§5).
|
||||||
|
|
||||||
|
## 4. Command envelope (request / result / error / audit)
|
||||||
|
|
||||||
|
Binding on every mapped operation the build-first families expose:
|
||||||
|
|
||||||
|
1. **Typed request and result.** Each command and query has an explicit
|
||||||
|
request DTO and result DTO in the shared types package, validated at
|
||||||
|
the Gateway boundary; unvalidated pass-through and `any`-typed
|
||||||
|
payloads are non-conformant. Mutations on records with an
|
||||||
|
expected-version rule in their owning contract carry the expected
|
||||||
|
version in the request and fail on mismatch with the conflict error
|
||||||
|
class (SOT §3 invariant 11 and REQ-TASK-001's concurrent-update
|
||||||
|
conflict acceptance; hierarchy per contract 1).
|
||||||
|
2. **Error taxonomy.** Every error result carries a stable
|
||||||
|
machine-readable code from a closed per-family enum plus an HTTP
|
||||||
|
status mapping, distinguishing at minimum: validation failure,
|
||||||
|
authentication failure, authorization refusal, not-found, conflict
|
||||||
|
(version/uniqueness), precondition/state refusal (e.g. bootstrap
|
||||||
|
epoch, suspended team subjects), and internal fault. Where contract
|
||||||
|
2's no-existence-oracle rule applies, authorization refusal and
|
||||||
|
not-found are indistinguishable on the wire for unauthorized readers
|
||||||
|
— same code, same status, same shape.
|
||||||
|
3. **Audit linkage.** A mutating mapped operation emits exactly the
|
||||||
|
audit events its owning contract defines (contract 1 §5.2, contract 2
|
||||||
|
§4.4, identity §§2–4, SOT audit rules); the envelope contributes the
|
||||||
|
correlation: every request accepts/generates a correlation id,
|
||||||
|
carried into the audit events and returned in the result, so a UI
|
||||||
|
action is traceable end to end. The mapping layer itself adds no
|
||||||
|
second audit stream.
|
||||||
|
4. **Fail-closed.** A mapped operation that cannot evaluate its
|
||||||
|
authorization or reach its owning tool refuses (contract 2 §3.5); the
|
||||||
|
envelope never degrades to an unauthorized fallback read or a direct
|
||||||
|
data access.
|
||||||
|
5. **CLI parity.** Each build-first family is invocable through the
|
||||||
|
official CLI against the same Gateway commands with the same
|
||||||
|
request/result/error contracts. No webUI-only command exists; a
|
||||||
|
Gateway command without CLI exposure is a conformance gap tracked at
|
||||||
|
the family's implementing issue.
|
||||||
|
|
||||||
|
## 5. Closure rule (blocked on tooling)
|
||||||
|
|
||||||
|
1. A webUI change that needs an operation with no mapping row is
|
||||||
|
**blocked on tooling**: the backing tool is built and mapped first
|
||||||
|
(D8). Scoring a gap "blocked on tooling" is mandatory, not
|
||||||
|
discretionary; working around it in the UI (direct DB or filesystem
|
||||||
|
access, calling a legacy non-substitute, embedding domain logic in
|
||||||
|
the web app) is non-conformant.
|
||||||
|
2. The mapping is enforced closed by §6.1's inventory witness: the web
|
||||||
|
app's network surface must be a subset of the mapped paths.
|
||||||
|
|
||||||
|
## 6. Verification requirements
|
||||||
|
|
||||||
|
Binding on the implementing PRs:
|
||||||
|
|
||||||
|
1. **Network-surface inventory witness:** a CI assertion extracting the
|
||||||
|
web app's outbound Gateway calls — route literals at request call
|
||||||
|
sites and outbound socket emits in `apps/web` sources (inbound
|
||||||
|
handler registrations are not calls and are out of scope) — and
|
||||||
|
failing on any call outside the §2/§3 mapped paths. The inventory is
|
||||||
|
closed like contract 1 §6.3's allowlist: a new call fails until a
|
||||||
|
mapping row exists in the same PR. Dynamic route construction that
|
||||||
|
evades extraction is resolved toward the witness, enforced by an
|
||||||
|
extractability lint: every request call site takes a literal or
|
||||||
|
template-literal path, and a call site that does not fails the
|
||||||
|
assertion itself (the web-side analogue of contract 1's
|
||||||
|
raw-execution prong), never an exemption for the caller.
|
||||||
|
2. **Non-substitute witness:** the P1 surfaces (hierarchy, RBAC, kanban,
|
||||||
|
enrollment, roll-up, wizard UI) make zero calls to the §3.2 legacy
|
||||||
|
endpoints — asserted by the same inventory, scoped per surface.
|
||||||
|
3. **Envelope witnesses per family:** for each build-first family — a
|
||||||
|
request with an invalid DTO is refused with the validation code; a
|
||||||
|
version-mismatch mutation returns the conflict code; an unauthorized
|
||||||
|
read of an existing node and a read of a nonexistent node return
|
||||||
|
indistinguishable results where the no-existence-oracle rule applies;
|
||||||
|
a correlation id submitted on a mutation appears in its audit
|
||||||
|
event(s) and result. Two static companions: a type-level assertion
|
||||||
|
that the family's boundary accepts no `any`-typed or unvalidated
|
||||||
|
pass-through payload (§4.1), and a single-emitter assertion that the
|
||||||
|
mapped operation's audit events originate only from the owning
|
||||||
|
contract's audit emitter (§4.3's no-second-audit-stream, made
|
||||||
|
checkable).
|
||||||
|
4. **CLI-parity witness:** for each family, a CLI smoke invocation of at
|
||||||
|
least one command and one query against the Gateway succeeds with the
|
||||||
|
same typed result the web client receives.
|
||||||
|
5. **Fail-closed witness:** with the owning tool or grant state
|
||||||
|
unreachable (fault injection), the mapped operation returns the
|
||||||
|
internal-fault or authorization-refusal class and performs no
|
||||||
|
fallback read/write (extends contract 2 §7.6 to the mapping layer).
|
||||||
|
|
||||||
|
## Ruling request
|
||||||
|
|
||||||
|
Ratify sections 1–6 as written, with one decision embedded:
|
||||||
|
|
||||||
|
- Decision (§3.2): the legacy endpoints named there are **frozen for new
|
||||||
|
consumers** as of ratification — existing P0/host consumers keep
|
||||||
|
working, new UI or tool code may not call them, and each is retired by
|
||||||
|
the migration its owning contract schedules. Alternative if rejected:
|
||||||
|
allow P1 surfaces to reuse legacy endpoints as interim backends —
|
||||||
|
rejected by the audit's finding that they cannot satisfy the
|
||||||
|
hierarchy/kanban/RBAC contracts, so the interim would ship
|
||||||
|
non-conformant semantics.
|
||||||
Reference in New Issue
Block a user