Compare commits

..
Author SHA1 Message Date
fred 8a2473da11 fix(gateway): serialize bootstrap setup with an advisory lock (#1430)
ci/woodpecker/pr/ci Pipeline was successful
POST /api/bootstrap/setup checked the zero-user count and then created the
first admin with nothing making the two atomic, so two concurrent setup
requests could both pass the check and each create an admin user plus an
admin API token.

Setup now runs inside a db.transaction that first takes
pg_advisory_xact_lock(BOOTSTRAP_SETUP_LOCK_KEY): the second concurrent
caller blocks until the first commits, re-reads the count, and gets 403.
The lock is transaction-scoped, so it also serializes across gateway
replicas sharing the database.

Spec: mock db gained transaction/execute; new assertions pin
lock-before-count ordering, and a new suite pins 403 + no createUser call
when users already exist.

Closes #1430
2026-08-26 17:23:58 -05:00
12 changed files with 181 additions and 2040 deletions
+14 -27
View File
@@ -32,11 +32,6 @@ variables:
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
# #1407: ONE shared anchor for all three image steps. A second main-only
# anchor previously gated build-web/build-appservice, so next-lane pushes
# published gateway sha images with no web/appservice counterpart — no
# sha-parity set existed for next-lane containerized deploys. Every image
# step now builds on next too (sha-only destinations, enforced per step).
- &image_build_when
- event: tag
- event: [push, manual]
@@ -49,6 +44,16 @@ variables:
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main, next]
@@ -469,7 +474,7 @@ steps:
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -483,17 +488,8 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next appservice publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
@@ -513,7 +509,7 @@ steps:
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -527,17 +523,8 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next web publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
+82 -65
View File
@@ -8,7 +8,7 @@ import {
Post,
} from '@nestjs/common';
import { randomBytes, createHash } from 'node:crypto';
import { count, eq, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
import { count, eq, sql, type Db, users as usersTable, adminTokens } from '@mosaicstack/db';
import type { Auth } from '@mosaicstack/auth';
import { v4 as uuid } from 'uuid';
import { AUTH } from '../auth/auth.tokens.js';
@@ -16,6 +16,12 @@ import { DB } from '../database/database.module.js';
import { BootstrapSetupDto } from './bootstrap.dto.js';
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
/**
* Advisory lock key serializing bootstrap setup. Arbitrary constant; must only
* be unique among advisory lock keys used against this database.
*/
export const BOOTSTRAP_SETUP_LOCK_KEY = 0x626f6f74; // 'boot'
@Controller('api/bootstrap')
export class BootstrapController {
constructor(
@@ -31,72 +37,83 @@ export class BootstrapController {
@Post('setup')
async setup(@Body() dto: BootstrapSetupDto): Promise<BootstrapResultDto> {
// Only allow setup when zero users exist
const [result] = await this.db.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
// #1430: the zero-user check and the admin creation must be one critical
// section, or two concurrent setup calls can each create an admin. The
// transaction-scoped advisory lock serializes setup across all gateway
// instances sharing this database; the second caller blocks on the lock,
// then re-reads the count and gets 403.
return this.db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(${BOOTSTRAP_SETUP_LOCK_KEY})`);
// Create admin user via BetterAuth API
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
// Only allow setup when zero users exist
const [result] = await tx.select({ total: count() }).from(usersTable);
if ((result?.total ?? 0) > 0) {
throw new ForbiddenException('Setup already completed — users exist');
}
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
// Create admin user via BetterAuth API. BetterAuth writes on its own
// connection and commits independently of this transaction; the reads
// below still see the committed row (READ COMMITTED statement snapshot).
const authApi = this.auth.api as unknown as {
createUser: (opts: {
body: { name: string; email: string; password: string; role?: string };
}) => Promise<{
user: { id: string; name: string; email: string };
}>;
};
const created = await authApi.createUser({
body: {
name: dto.name,
email: dto.email,
password: dto.password,
role: 'admin',
},
});
// Verify user was created
const [user] = await tx
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await tx.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await tx
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
});
// Verify user was created
const [user] = await this.db
.select()
.from(usersTable)
.where(eq(usersTable.id, created.user.id))
.limit(1);
if (!user) throw new InternalServerErrorException('User created but not found');
// Ensure role is admin (createUser may not set it via BetterAuth)
if (user.role !== 'admin') {
await this.db.update(usersTable).set({ role: 'admin' }).where(eq(usersTable.id, user.id));
}
// Generate admin API token
const plaintext = randomBytes(32).toString('hex');
const tokenHash = createHash('sha256').update(plaintext).digest('hex');
const tokenId = uuid();
const [token] = await this.db
.insert(adminTokens)
.values({
id: tokenId,
userId: user.id,
tokenHash,
label: 'Initial setup token',
scope: 'admin',
})
.returning();
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: 'admin',
},
token: {
id: token!.id,
plaintext,
label: token!.label,
},
};
}
}
+73 -4
View File
@@ -20,7 +20,7 @@
*/
import 'reflect-metadata';
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { describe, it, expect, afterAll, beforeAll, vi } from 'vitest';
import { Test } from '@nestjs/testing';
import { ValidationPipe, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
@@ -52,13 +52,22 @@ const mockAuth = {
},
};
// Override db.select() so the second query (verify user exists) returns a user.
// The bootstrap controller calls select().from() twice:
// The controller runs setup inside db.transaction(tx) and first takes the
// #1430 advisory lock via tx.execute(). Inside the transaction it calls
// select().from() twice:
// 1. count() to check zero users → returns [{total: 0}]
// 2. select().where().limit() → returns [the created user]
// callLog records the tx call order so the lock-before-check invariant is
// testable.
let selectCallCount = 0;
const mockDbWithUser = {
const callLog: string[] = [];
const mockTx = {
execute: () => {
callLog.push('execute');
return Promise.resolve([]);
},
select: () => {
callLog.push('select');
selectCallCount++;
return {
from: () => {
@@ -100,6 +109,9 @@ const mockDbWithUser = {
}),
}),
};
const mockDbWithUser = {
transaction: <T>(cb: (tx: typeof mockTx) => Promise<T>) => cb(mockTx),
};
// ─── Test suite ───────────────────────────────────────────────────────────────
@@ -153,6 +165,11 @@ describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
expect(body.user.email).toBe('[email protected]');
expect(body.token).toBeDefined();
expect(body.token.plaintext).toBeDefined();
// #1430: the advisory lock must be taken before the zero-user count, or
// two concurrent setups can both pass the check.
expect(callLog[0]).toBe('execute');
expect(callLog[1]).toBe('select');
});
it('returns 400 when extra forbidden properties are sent', async () => {
@@ -188,3 +205,55 @@ describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
expect(res.status).toBe(400);
});
});
// ─── #1430 regression: users-exist check runs inside the locked transaction ──
describe('POST /api/bootstrap/setup — setup already completed', () => {
let app: INestApplication;
const createUserSpy = vi.fn();
const lockedTx = {
execute: () => Promise.resolve([]),
select: () => ({
// count() sees an existing user — the locked re-check must reject.
from: () => Promise.resolve([{ total: 1 }]),
}),
};
const mockDbUsersExist = {
transaction: <T>(cb: (tx: typeof lockedTx) => Promise<T>) => cb(lockedTx),
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [BootstrapController],
providers: [
{ provide: AUTH, useValue: { api: { createUser: createUserSpy } } },
{ provide: DB, useValue: mockDbUsersExist },
],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
});
it('returns 403 and never calls createUser when users already exist', async () => {
const res = await request(app.getHttpServer())
.post('/api/bootstrap/setup')
.send({ name: 'Admin', email: '[email protected]', password: 'password123' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(403);
expect(createUserSpy).not.toHaveBeenCalled();
});
});
@@ -1,123 +0,0 @@
import 'reflect-metadata';
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { TeamsController } from './teams.controller.js';
import { TeamsService } from './teams.service.js';
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
const teamBeta = { id: 'team-beta', name: 'Beta' };
// user-1 is a member of team-alpha only; admin-1 has role admin.
let currentUser: { id: string; role?: string } = { id: 'user-1' };
const teamsServiceMock = {
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
findAllForUser: vi.fn((userId: string) =>
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
),
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
isMember: vi.fn((teamId: string, userId: string) =>
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
),
};
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context
.switchToHttp()
.getRequest<{ user?: { id: string; role?: string } }>();
requestContext.user = currentUser;
return true;
},
};
describe('teams endpoints are scoped to membership', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [TeamsController],
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
beforeEach(() => {
currentUser = { id: 'user-1' };
vi.clearAllMocks();
});
afterAll(async () => {
await app.close();
});
it('GET /api/teams returns only the teams the user belongs to', async () => {
const response = await request(app.getHttpServer()).get('/api/teams');
expect(response.status).toBe(200);
expect(response.body).toEqual([teamAlpha]);
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
});
it('GET /api/teams returns every team for an admin', async () => {
currentUser = { id: 'admin-1', role: 'admin' };
const response = await request(app.getHttpServer()).get('/api/teams');
expect(response.status).toBe(200);
expect(response.body).toEqual([teamAlpha, teamBeta]);
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
});
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
expect(response.status).toBe(403);
});
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
expect(response.status).toBe(404);
});
it('GET /api/teams/:teamId returns the team for a member', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
expect(response.status).toBe(200);
expect(response.body).toEqual(teamAlpha);
});
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
expect(denied.status).toBe(403);
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
expect(allowed.status).toBe(200);
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
});
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
expect(response.status).toBe(200);
expect(response.body).toEqual({ isMember: false });
});
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
expect(response.status).toBe(403);
});
it('an admin can look up any membership', async () => {
currentUser = { id: 'admin-1', role: 'admin' };
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
expect(response.status).toBe(200);
expect(response.body).toEqual({ isMember: true });
});
});
+7 -45
View File
@@ -1,68 +1,30 @@
import {
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { TeamsService } from './teams.service.js';
type RequestUser = { id: string; role?: string };
@Controller('api/teams')
@UseGuards(AuthGuard)
export class TeamsController {
constructor(private readonly teams: TeamsService) {}
@Get()
async list(@CurrentUser() user: RequestUser) {
if (user.role === 'admin') {
return this.teams.findAll();
}
return this.teams.findAllForUser(user.id);
async list() {
return this.teams.findAll();
}
@Get(':teamId')
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
return this.getAccessibleTeam(teamId, user);
async findOne(@Param('teamId') teamId: string) {
return this.teams.findById(teamId);
}
@Get(':teamId/members')
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
await this.getAccessibleTeam(teamId, user);
async listMembers(@Param('teamId') teamId: string) {
return this.teams.listMembers(teamId);
}
@Get(':teamId/members/:userId')
async checkMembership(
@Param('teamId') teamId: string,
@Param('userId') userId: string,
@CurrentUser() user: RequestUser,
) {
// A user may always ask about their own membership; anything else is
// team-scoped like the other routes.
if (userId !== user.id) {
await this.getAccessibleTeam(teamId, user);
}
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
const isMember = await this.teams.isMember(teamId, userId);
return { isMember };
}
/**
* Team-scoped access: admins see any team; everyone else only teams they
* are a member of. NotFoundException when the team does not exist and
* ForbiddenException when the user lacks access (same convention as the
* projects controller).
*/
private async getAccessibleTeam(teamId: string, user: RequestUser) {
const team = await this.teams.findById(teamId);
if (!team) throw new NotFoundException('Team not found');
if (user.role === 'admin') return team;
const isMember = await this.teams.isMember(teamId, user.id);
if (!isMember) throw new ForbiddenException('Not a member of this team');
return team;
}
}
+1 -16
View File
@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
@Injectable()
@@ -56,21 +56,6 @@ export class TeamsService {
return this.db.select().from(teams);
}
/**
* List only the teams the user is a member of.
*/
async findAllForUser(userId: string) {
const memberRows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, userId));
const teamIds = memberRows.map((r) => r.teamId);
if (teamIds.length === 0) return [];
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
}
/**
* Find a team by ID.
*/
+4 -8
View File
@@ -13,9 +13,8 @@ import {
TasksRouteErrorBoundary,
} from '@/spa/pages/resource-route-error-boundaries';
import { TasksPage } from '@/spa/pages/tasks';
import { SettingsPage } from '@/spa/pages/settings';
import { AdminPage } from '@/spa/pages/admin';
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
import { AuthGuard, GuestGuard } from '@/spa/guards';
import { Placeholder } from '@/spa/placeholder';
function GuestLayout(): ReactElement {
return (
@@ -57,11 +56,8 @@ export const routes: RouteObject[] = [
errorElement: <ProjectDetailRouteErrorBoundary />,
},
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
{ path: '/settings', element: <SettingsPage /> },
{
element: <AdminGuard />,
children: [{ path: '/admin', element: <AdminPage /> }],
},
{ path: '/settings', element: <Placeholder title="Settings" /> },
{ path: '/admin', element: <Placeholder title="Admin" /> },
],
},
];
-20
View File
@@ -21,23 +21,3 @@ export function AuthGuard(): ReactElement {
return session ? <Outlet /> : <Navigate to="/login" replace />;
}
export function AdminGuard(): ReactElement {
const { data: session, isPending } = useSession();
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (!session) {
return <Navigate to="/login" replace />;
}
const user = session.user as typeof session.user & { role?: string };
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
}
-206
View File
@@ -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');
});
});
-522
View File
@@ -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>
);
}
-178
View File
@@ -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');
});
});
-826
View File
@@ -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);
}