diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx
index 0dd525ec..699d0466 100644
--- a/apps/web/src/routes.tsx
+++ b/apps/web/src/routes.tsx
@@ -13,8 +13,9 @@ import {
TasksRouteErrorBoundary,
} from '@/spa/pages/resource-route-error-boundaries';
import { TasksPage } from '@/spa/pages/tasks';
-import { AuthGuard, GuestGuard } from '@/spa/guards';
-import { Placeholder } from '@/spa/placeholder';
+import { SettingsPage } from '@/spa/pages/settings';
+import { AdminPage } from '@/spa/pages/admin';
+import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
function GuestLayout(): ReactElement {
return (
@@ -56,8 +57,11 @@ export const routes: RouteObject[] = [
errorElement: ,
},
{ path: '/tasks', element: , errorElement: },
- { path: '/settings', element: },
- { path: '/admin', element: },
+ { path: '/settings', element: },
+ {
+ element: ,
+ children: [{ path: '/admin', element: }],
+ },
],
},
];
diff --git a/apps/web/src/spa/guards.tsx b/apps/web/src/spa/guards.tsx
index 71ea0fea..b46e0028 100644
--- a/apps/web/src/spa/guards.tsx
+++ b/apps/web/src/spa/guards.tsx
@@ -21,3 +21,23 @@ export function AuthGuard(): ReactElement {
return session ? : ;
}
+
+export function AdminGuard(): ReactElement {
+ const { data: session, isPending } = useSession();
+
+ if (isPending) {
+ return (
+
+ );
+ }
+
+ if (!session) {
+ return ;
+ }
+
+ const user = session.user as typeof session.user & { role?: string };
+
+ return user.role === 'admin' ? : ;
+}
diff --git a/apps/web/src/spa/pages/admin.spec.tsx b/apps/web/src/spa/pages/admin.spec.tsx
new file mode 100644
index 00000000..d5e42c4d
--- /dev/null
+++ b/apps/web/src/spa/pages/admin.spec.tsx
@@ -0,0 +1,206 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+const { apiMock, useSessionMock } = vi.hoisted(() => ({
+ apiMock: vi.fn(),
+ useSessionMock: vi.fn(),
+}));
+
+vi.mock('@/lib/api', () => ({
+ api: apiMock,
+}));
+
+vi.mock('@/lib/auth-client', () => ({
+ useSession: useSessionMock,
+ authClient: {},
+}));
+
+import { AdminPage } from './admin';
+import { AdminGuard } from '@/spa/guards';
+
+const userFixtures = {
+ users: [
+ {
+ id: 'u-admin',
+ name: 'Ada Admin',
+ email: 'ada@example.test',
+ 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: 'mel@example.test',
+ 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 {
+ const routes: RouteObject[] = [
+ {
+ element: ,
+ children: [{ path: '/admin', element: }],
+ },
+ { path: '/', element: home page
},
+ { path: '/login', element: login page
},
+ ];
+ const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+}
+
+function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
+ return {
+ data: { user: { id: 'u-1', name: 'Test', email: 't@example.test', 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');
+ });
+});
diff --git a/apps/web/src/spa/pages/admin.tsx b/apps/web/src/spa/pages/admin.tsx
new file mode 100644
index 00000000..a202ab99
--- /dev/null
+++ b/apps/web/src/spa/pages/admin.tsx
@@ -0,0 +1,522 @@
+import { useEffect, useState, useCallback } from 'react';
+import { api } from '@/lib/api';
+import { cn } from '@/lib/cn';
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+interface UserDto {
+ id: string;
+ name: string;
+ email: string;
+ role: string;
+ banned: boolean;
+ banReason: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface UserListDto {
+ users: UserDto[];
+ total: number;
+}
+
+interface ServiceStatusDto {
+ status: 'ok' | 'error';
+ latencyMs?: number;
+ error?: string;
+}
+
+interface ProviderStatusDto {
+ id: string;
+ name: string;
+ available: boolean;
+ modelCount: number;
+}
+
+interface HealthStatusDto {
+ status: 'ok' | 'degraded' | 'error';
+ database: ServiceStatusDto;
+ cache: ServiceStatusDto;
+ agentPool: { activeSessions: number };
+ providers: ProviderStatusDto[];
+ checkedAt: string;
+}
+
+// ── Admin Page ─────────────────────────────────────────────────────────────────
+
+// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
+// assumes an authenticated admin session.
+export function AdminPage(): React.ReactElement {
+ const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
+
+ return (
+
+
+
Admin Panel
+
+
+
+ {(['users', 'health'] as const).map((tab) => (
+
+ ))}
+
+
+ {activeTab === 'users' ?
:
}
+
+ );
+}
+
+// ── Users Tab ──────────────────────────────────────────────────────────────────
+
+function UsersTab(): React.ReactElement {
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [showCreate, setShowCreate] = useState(false);
+
+ const loadUsers = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await api('/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 {
+ 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 {
+ 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 {
+ 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 Loading users...
;
+ }
+
+ if (error) {
+ return (
+
+
{error}
+
+
+ );
+ }
+
+ return (
+
+
+
{users.length} user(s)
+
+
+
+ {showCreate && (
+
setShowCreate(false)}
+ onCreated={() => {
+ setShowCreate(false);
+ void loadUsers();
+ }}
+ />
+ )}
+
+ {users.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | Name / Email |
+ Role |
+ Status |
+ Created |
+ Actions |
+
+
+
+ {users.map((user) => (
+
+ |
+ {user.name}
+ {user.email}
+ |
+
+
+ {user.role}
+
+ |
+
+ {user.banned ? (
+
+ Banned
+
+ ) : (
+
+ Active
+
+ )}
+ |
+
+ {new Date(user.createdAt).toLocaleDateString()}
+ |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+// ── 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(null);
+
+ async function handleSubmit(e: React.FormEvent): Promise {
+ 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 (
+
+ );
+}
+
+// ── Health Tab ────────────────────────────────────────────────────────────────
+
+function HealthTab(): React.ReactElement {
+ const [health, setHealth] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const loadHealth = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await api('/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 Loading health status...
;
+ }
+
+ if (error) {
+ return (
+
+
{error}
+
+
+ );
+ }
+
+ if (!health) return <>>;
+
+ return (
+
+
+
+
+
+ Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
+
+
+
+
+
+
+ {/* Database */}
+
+ {health.database.latencyMs !== undefined && (
+ Latency: {health.database.latencyMs}ms
+ )}
+ {health.database.error && {health.database.error}
}
+
+
+ {/* Cache */}
+
+ {health.cache.latencyMs !== undefined && (
+ Latency: {health.cache.latencyMs}ms
+ )}
+ {health.cache.error && {health.cache.error}
}
+
+
+ {/* Agent Pool */}
+
+
+ Active sessions: {health.agentPool.activeSessions}
+
+
+
+ {/* Providers */}
+
p.available) ? 'ok' : 'error'}
+ >
+ {health.providers.length === 0 ? (
+ No providers configured
+ ) : (
+
+ {health.providers.map((p) => (
+ -
+ {p.name}
+
+ {p.available ? `${p.modelCount} models` : 'unavailable'}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+// ── 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 (
+
+ {status}
+
+ );
+}
+
+interface HealthCardProps {
+ title: string;
+ status: 'ok' | 'error';
+ children?: React.ReactNode;
+}
+
+function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
+ return (
+
+
+
{title}
+
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/spa/pages/settings.spec.tsx b/apps/web/src/spa/pages/settings.spec.tsx
new file mode 100644
index 00000000..037c2517
--- /dev/null
+++ b/apps/web/src/spa/pages/settings.spec.tsx
@@ -0,0 +1,178 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+
+const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
+ apiMock: vi.fn(),
+ useSessionMock: vi.fn(),
+ updateUserMock: vi.fn(),
+}));
+
+vi.mock('@/lib/api', () => ({
+ api: apiMock,
+}));
+
+vi.mock('@/lib/auth-client', () => ({
+ useSession: useSessionMock,
+ authClient: { updateUser: updateUserMock },
+}));
+
+import { SettingsPage } from './settings';
+
+let root: Root | null = null;
+let container: HTMLDivElement;
+
+beforeAll(() => {
+ Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
+ configurable: true,
+ value: true,
+ });
+});
+
+afterAll(() => {
+ Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
+});
+
+afterEach(async () => {
+ await act(async () => {
+ root?.unmount();
+ });
+ document.body.replaceChildren();
+ root = null;
+ apiMock.mockReset();
+ useSessionMock.mockReset();
+ updateUserMock.mockReset();
+});
+
+async function renderSettingsPage(): Promise {
+ const routes: RouteObject[] = [{ path: '/settings', element: }];
+ const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+}
+
+function clickButtonByText(text: string): Promise {
+ 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: 'user@example.test', 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('#profile-name');
+ const emailInput = container.querySelector('#profile-email');
+ expect(nameInput?.value).toBe('Test User');
+ expect(emailInput?.value).toBe('user@example.test');
+ 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('#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');
+ });
+});
diff --git a/apps/web/src/spa/pages/settings.tsx b/apps/web/src/spa/pages/settings.tsx
new file mode 100644
index 00000000..2e8ee779
--- /dev/null
+++ b/apps/web/src/spa/pages/settings.tsx
@@ -0,0 +1,826 @@
+import { useCallback, useEffect, useState } from 'react';
+import { api } from '@/lib/api';
+import { authClient, useSession } from '@/lib/auth-client';
+import type { SsoProviderDiscovery } from '@/lib/sso';
+import { SsoProviderSection } from '@/components/settings/sso-provider-section';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+interface ModelInfo {
+ id: string;
+ provider: string;
+ name: string;
+ reasoning: boolean;
+ contextWindow: number;
+ maxTokens: number;
+ inputTypes: ('text' | 'image')[];
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
+}
+
+interface ProviderInfo {
+ id: string;
+ name: string;
+ available: boolean;
+ models: ModelInfo[];
+}
+
+interface TestConnectionResult {
+ providerId: string;
+ reachable: boolean;
+ latencyMs?: number;
+ error?: string;
+ discoveredModels?: string[];
+}
+
+type TestState = 'idle' | 'testing' | 'success' | 'error';
+
+interface ProviderTestStatus {
+ state: TestState;
+ result?: TestConnectionResult;
+}
+
+interface Preference {
+ key: string;
+ value: unknown;
+ category: string;
+}
+
+type Theme = 'light' | 'dark' | 'system';
+type SaveState = 'idle' | 'saving' | 'saved' | 'error';
+type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function prefValue(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('profile');
+
+ const tabs: { id: Tab; label: string }[] = [
+ { id: 'profile', label: 'Profile' },
+ { id: 'appearance', label: 'Appearance' },
+ { id: 'notifications', label: 'Notifications' },
+ { id: 'providers', label: 'Providers' },
+ ];
+
+ return (
+
+
Settings
+
+ {/* Tab bar */}
+
+ {tabs.map((tab) => (
+
+ ))}
+
+
+ {activeTab === 'profile' &&
}
+ {activeTab === 'appearance' &&
}
+ {activeTab === 'notifications' &&
}
+ {activeTab === 'providers' &&
}
+
+ );
+}
+
+// ─── 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('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 => {
+ 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 (
+
+ Profile
+
+
+ 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"
+ />
+
+
+
+
+ Email cannot be changed here.
+
+
+
+ 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"
+ />
+
+
+
+
+ {saveState === 'error' && errorMsg &&
{errorMsg}
}
+
+
+
+ );
+}
+
+// ─── Appearance Tab ───────────────────────────────────────────────────────────
+
+function AppearanceTab(): React.ReactElement {
+ const [loading, setLoading] = useState(true);
+ const [theme, setTheme] = useState('system');
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
+ const [defaultModel, setDefaultModel] = useState('');
+ const [saveState, setSaveState] = useState('idle');
+ const [errorMsg, setErrorMsg] = useState('');
+
+ useEffect(() => {
+ api('/api/memory/preferences?category=appearance')
+ .catch(() => [] as Preference[])
+ .then((p) => {
+ setTheme(prefValue(p, 'ui.theme', 'system'));
+ setSidebarCollapsed(prefValue(p, 'ui.sidebar_collapsed', false));
+ setDefaultModel(prefValue(p, 'ui.default_model', ''));
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ const handleSave = async (): Promise => {
+ 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 (
+
+ Appearance
+ Loading preferences...
+
+ );
+ }
+
+ return (
+
+ Appearance
+
+ {/* Theme */}
+
+
+
+ {(['system', 'light', 'dark'] as Theme[]).map((t) => (
+
+ ))}
+
+
+
+ {/* Sidebar collapsed default */}
+
+
+
Collapse sidebar by default
+
Start with sidebar collapsed on page load
+
+
+
+
+ {/* Default model */}
+
+ 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"
+ />
+
+ Model ID to pre-select for new conversations.
+
+
+
+
+
+ {saveState === 'error' && errorMsg &&
{errorMsg}
}
+
+
+
+ );
+}
+
+// ─── 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('idle');
+ const [errorMsg, setErrorMsg] = useState('');
+
+ useEffect(() => {
+ api('/api/memory/preferences?category=communication')
+ .catch(() => [] as Preference[])
+ .then((p) => {
+ setEmailAgentComplete(prefValue(p, 'notify.email_agent_complete', false));
+ setEmailMentions(prefValue(p, 'notify.email_mentions', true));
+ setEmailDigest(prefValue(p, 'notify.email_digest', false));
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ const handleSave = async (): Promise => {
+ 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 (
+
+ Notifications
+ Loading preferences...
+
+ );
+ }
+
+ return (
+
+ Notifications
+
+
Configure when you receive email notifications.
+
+
+
+
+
+
+
+ {saveState === 'error' && errorMsg &&
{errorMsg}
}
+
+
+
+ );
+}
+
+// ─── Providers Tab ────────────────────────────────────────────────────────────
+
+function ProvidersTab(): React.ReactElement {
+ const [providers, setProviders] = useState([]);
+ const [ssoProviders, setSsoProviders] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [ssoLoading, setSsoLoading] = useState(true);
+ const [testStatuses, setTestStatuses] = useState>({});
+
+ useEffect(() => {
+ api('/api/providers')
+ .catch(() => [] as ProviderInfo[])
+ .then((p) => setProviders(p))
+ .finally(() => setLoading(false));
+ }, []);
+
+ useEffect(() => {
+ api('/api/sso/providers')
+ .catch(() => [] as SsoProviderDiscovery[])
+ .then((providers) => setSsoProviders(providers))
+ .finally(() => setSsoLoading(false));
+ }, []);
+
+ const testConnection = useCallback(async (providerId: string): Promise => {
+ setTestStatuses((prev) => ({
+ ...prev,
+ [providerId]: { state: 'testing' },
+ }));
+ try {
+ const result = await api('/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 (
+
+
+
SSO Providers
+
+
+
+
+
LLM Providers
+ {loading ? (
+
Loading providers...
+ ) : providers.length === 0 ? (
+
+
+ No providers configured. Set{' '}
+
+ OLLAMA_BASE_URL
+ {' '}
+ or{' '}
+
+ MOSAIC_CUSTOM_PROVIDERS
+ {' '}
+ to add providers.
+
+
+ ) : (
+
+ {providers.map((provider) => (
+
void testConnection(provider.id)}
+ />
+ ))}
+
+ )}
+
+
+ );
+}
+
+// ─── Shared UI Components ─────────────────────────────────────────────────────
+
+function FormField({
+ label,
+ id,
+ children,
+}: {
+ label: string;
+ id: string;
+ children: React.ReactNode;
+}): React.ReactElement {
+ return (
+
+
+ {children}
+
+ );
+}
+
+function Toggle({
+ checked,
+ onChange,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}): React.ReactElement {
+ return (
+
+ );
+}
+
+function NotifyRow({
+ label,
+ description,
+ checked,
+ onChange,
+}: {
+ label: string;
+ description: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}): React.ReactElement {
+ return (
+
+
+
{label}
+
{description}
+
+
+
+ );
+}
+
+function SaveButton({
+ state,
+ onClick,
+}: {
+ state: SaveState;
+ onClick: () => void;
+}): React.ReactElement {
+ return (
+
+ );
+}
+
+// ─── 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 (
+
+ {/* Header row */}
+
+
+
+
+
+
+ {provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
+
+
+
+
+
+
+
+
+
+
+ {/* Test result banner */}
+ {testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
+
+ )}
+
+ {/* Model list */}
+ {expanded && (
+
+
+
+
+ | Model |
+ Capabilities |
+ Context |
+ Cost (in/out) |
+ Default |
+
+
+
+ {provider.models.map((model) => (
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+interface ModelRowProps {
+ model: ModelInfo;
+ isDefault: boolean;
+}
+
+function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
+ return (
+
+ |
+ {model.name}
+ |
+
+
+
+ {model.reasoning && }
+ {model.inputTypes.includes('image') && }
+
+ |
+
+ {formatContext(model.contextWindow)}
+ |
+
+ {model.cost.input === 0 && model.cost.output === 0
+ ? 'free'
+ : `$${model.cost.input} / $${model.cost.output}`}
+ |
+
+ {isDefault && (
+
+ default
+
+ )}
+ |
+
+ );
+}
+
+function ProviderAvatar({ id }: { id: string }): React.ReactElement {
+ const letter = id.charAt(0).toUpperCase();
+ return (
+
+ {letter}
+
+ );
+}
+
+function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
+ return (
+
+ {available ? 'Active' : 'Inactive'}
+
+ );
+}
+
+interface TestConnectionButtonProps {
+ status: ProviderTestStatus;
+ onTest: () => void;
+}
+
+function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
+ const isTesting = status.state === 'testing';
+ return (
+
+ );
+}
+
+function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
+ return (
+
+ {result.reachable ? (
+ <>
+ Connected
+ {result.latencyMs !== undefined && (
+ ({result.latencyMs}ms)
+ )}
+ {result.discoveredModels && result.discoveredModels.length > 0 && (
+
+ — {result.discoveredModels.length} model
+ {result.discoveredModels.length !== 1 ? 's' : ''} discovered
+
+ )}
+ >
+ ) : (
+ <>Connection failed{result.error ? `: ${result.error}` : ''}>
+ )}
+
+ );
+}
+
+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 {label};
+}
+
+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);
+}