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 ( +
+
Loading...
+
+ ); + } + + 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 ? ( +
+

No users found

+
+ ) : ( +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
Name / EmailRoleStatusCreatedActions
+
{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 ( +
+

Create New User

+
void handleSubmit(e)} className="space-y-3"> + {error &&

{error}

} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+
+ + +
+
+
+ ); +} + +// ── 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.name} + +
+

+ {provider.models.length} model{provider.models.length !== 1 ? 's' : ''} +

+
+
+ +
+ + +
+
+ + {/* Test result banner */} + {testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && ( + + )} + + {/* Model list */} + {expanded && ( +
+ + + + + + + + + + + + {provider.models.map((model) => ( + + ))} + +
ModelCapabilitiesContextCost (in/out)Default
+
+ )} +
+ ); +} + +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); +}