diff --git a/apps/gateway/package.json b/apps/gateway/package.json index 28a8813..d8ef757 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -17,6 +17,7 @@ "@mariozechner/pi-coding-agent": "~0.57.1", "@modelcontextprotocol/sdk": "^1.27.1", "@mosaic/auth": "workspace:^", + "@mosaic/queue": "workspace:^", "@mosaic/brain": "workspace:^", "@mosaic/coord": "workspace:^", "@mosaic/db": "workspace:^", diff --git a/apps/gateway/src/admin/admin-health.controller.ts b/apps/gateway/src/admin/admin-health.controller.ts new file mode 100644 index 0000000..96ce18e --- /dev/null +++ b/apps/gateway/src/admin/admin-health.controller.ts @@ -0,0 +1,73 @@ +import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; +import { sql, type Db } from '@mosaic/db'; +import { createQueue } from '@mosaic/queue'; +import { DB } from '../database/database.module.js'; +import { AgentService } from '../agent/agent.service.js'; +import { ProviderService } from '../agent/provider.service.js'; +import { AdminGuard } from './admin.guard.js'; +import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js'; + +@Controller('api/admin/health') +@UseGuards(AdminGuard) +export class AdminHealthController { + constructor( + @Inject(DB) private readonly db: Db, + @Inject(AgentService) private readonly agentService: AgentService, + @Inject(ProviderService) private readonly providerService: ProviderService, + ) {} + + @Get() + async check(): Promise { + const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]); + + const sessions = this.agentService.listSessions(); + const providers = this.providerService.listProviders(); + + const allOk = database.status === 'ok' && cache.status === 'ok'; + + return { + status: allOk ? 'ok' : 'degraded', + database, + cache, + agentPool: { activeSessions: sessions.length }, + providers: providers.map((p) => ({ + id: p.id, + name: p.name, + available: p.available, + modelCount: p.models.length, + })), + checkedAt: new Date().toISOString(), + }; + } + + private async checkDatabase(): Promise { + const start = Date.now(); + try { + await this.db.execute(sql`SELECT 1`); + return { status: 'ok', latencyMs: Date.now() - start }; + } catch (err) { + return { + status: 'error', + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + private async checkCache(): Promise { + const start = Date.now(); + const handle = createQueue(); + try { + await handle.redis.ping(); + return { status: 'ok', latencyMs: Date.now() - start }; + } catch (err) { + return { + status: 'error', + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + }; + } finally { + await handle.close().catch(() => {}); + } + } +} diff --git a/apps/gateway/src/admin/admin.controller.ts b/apps/gateway/src/admin/admin.controller.ts new file mode 100644 index 0000000..f1806ce --- /dev/null +++ b/apps/gateway/src/admin/admin.controller.ts @@ -0,0 +1,146 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Inject, + InternalServerErrorException, + NotFoundException, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { eq, type Db, users as usersTable } from '@mosaic/db'; +import type { Auth } from '@mosaic/auth'; +import { AUTH } from '../auth/auth.tokens.js'; +import { DB } from '../database/database.module.js'; +import { AdminGuard } from './admin.guard.js'; +import type { + BanUserDto, + CreateUserDto, + UpdateUserRoleDto, + UserDto, + UserListDto, +} from './admin.dto.js'; + +type UserRow = typeof usersTable.$inferSelect; + +function toUserDto(u: UserRow): UserDto { + return { + id: u.id, + name: u.name, + email: u.email, + role: u.role, + banned: u.banned ?? false, + banReason: u.banReason ?? null, + createdAt: u.createdAt.toISOString(), + updatedAt: u.updatedAt.toISOString(), + }; +} + +async function requireUpdated( + db: Db, + id: string, + update: Partial>, +): Promise { + const [updated] = await db + .update(usersTable) + .set({ ...update, updatedAt: new Date() }) + .where(eq(usersTable.id, id)) + .returning(); + if (!updated) throw new InternalServerErrorException('Update returned no rows'); + return toUserDto(updated); +} + +@Controller('api/admin/users') +@UseGuards(AdminGuard) +export class AdminController { + constructor( + @Inject(DB) private readonly db: Db, + @Inject(AUTH) private readonly auth: Auth, + ) {} + + @Get() + async listUsers(): Promise { + const rows = await this.db.select().from(usersTable).orderBy(usersTable.createdAt); + const userList: UserDto[] = rows.map(toUserDto); + return { users: userList, total: userList.length }; + } + + @Get(':id') + async getUser(@Param('id') id: string): Promise { + const [user] = await this.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1); + if (!user) throw new NotFoundException('User not found'); + return toUserDto(user); + } + + @Post() + async createUser(@Body() body: CreateUserDto): Promise { + // Use auth API to create user so password is properly hashed + 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; createdAt: unknown; updatedAt: unknown }; + }>; + }; + + const result = await authApi.createUser({ + body: { + name: body.name, + email: body.email, + password: body.password, + role: body.role ?? 'member', + }, + }); + + // Re-fetch from DB to get full row with our schema + const [user] = await this.db + .select() + .from(usersTable) + .where(eq(usersTable.id, result.user.id)) + .limit(1); + + if (!user) throw new InternalServerErrorException('User created but not found in DB'); + return toUserDto(user); + } + + @Patch(':id/role') + async setRole(@Param('id') id: string, @Body() body: UpdateUserRoleDto): Promise { + await this.ensureExists(id); + return requireUpdated(this.db, id, { role: body.role }); + } + + @Post(':id/ban') + @HttpCode(HttpStatus.OK) + async banUser(@Param('id') id: string, @Body() body: BanUserDto): Promise { + await this.ensureExists(id); + return requireUpdated(this.db, id, { banned: true, banReason: body.reason ?? null }); + } + + @Post(':id/unban') + @HttpCode(HttpStatus.OK) + async unbanUser(@Param('id') id: string): Promise { + await this.ensureExists(id); + return requireUpdated(this.db, id, { banned: false, banReason: null, banExpires: null }); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteUser(@Param('id') id: string): Promise { + await this.ensureExists(id); + await this.db.delete(usersTable).where(eq(usersTable.id, id)); + } + + private async ensureExists(id: string): Promise { + const [existing] = await this.db + .select({ id: usersTable.id }) + .from(usersTable) + .where(eq(usersTable.id, id)) + .limit(1); + if (!existing) throw new NotFoundException('User not found'); + } +} diff --git a/apps/gateway/src/admin/admin.dto.ts b/apps/gateway/src/admin/admin.dto.ts new file mode 100644 index 0000000..7a6e2fc --- /dev/null +++ b/apps/gateway/src/admin/admin.dto.ts @@ -0,0 +1,56 @@ +export interface UserDto { + id: string; + name: string; + email: string; + role: string; + banned: boolean; + banReason: string | null; + createdAt: string; + updatedAt: string; +} + +export interface UserListDto { + users: UserDto[]; + total: number; +} + +export interface CreateUserDto { + name: string; + email: string; + password: string; + role?: string; +} + +export interface UpdateUserRoleDto { + role: string; +} + +export interface BanUserDto { + reason?: string; +} + +export interface HealthStatusDto { + status: 'ok' | 'degraded' | 'error'; + database: ServiceStatusDto; + cache: ServiceStatusDto; + agentPool: AgentPoolStatusDto; + providers: ProviderStatusDto[]; + checkedAt: string; +} + +export interface ServiceStatusDto { + status: 'ok' | 'error'; + latencyMs?: number; + error?: string; +} + +export interface AgentPoolStatusDto { + activeSessions: number; +} + +export interface ProviderStatusDto { + id: string; + name: string; + available: boolean; + modelCount: number; +} diff --git a/apps/gateway/src/admin/admin.guard.ts b/apps/gateway/src/admin/admin.guard.ts new file mode 100644 index 0000000..7648219 --- /dev/null +++ b/apps/gateway/src/admin/admin.guard.ts @@ -0,0 +1,44 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Inject, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { fromNodeHeaders } from 'better-auth/node'; +import type { Auth } from '@mosaic/auth'; +import type { FastifyRequest } from 'fastify'; +import { AUTH } from '../auth/auth.tokens.js'; + +interface UserWithRole { + id: string; + role?: string; +} + +@Injectable() +export class AdminGuard implements CanActivate { + constructor(@Inject(AUTH) private readonly auth: Auth) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const headers = fromNodeHeaders(request.raw.headers); + + const result = await this.auth.api.getSession({ headers }); + + if (!result) { + throw new UnauthorizedException('Invalid or expired session'); + } + + const user = result.user as UserWithRole; + + if (user.role !== 'admin') { + throw new ForbiddenException('Admin access required'); + } + + (request as FastifyRequest & { user: unknown; session: unknown }).user = result.user; + (request as FastifyRequest & { user: unknown; session: unknown }).session = result.session; + + return true; + } +} diff --git a/apps/gateway/src/admin/admin.module.ts b/apps/gateway/src/admin/admin.module.ts new file mode 100644 index 0000000..72aff71 --- /dev/null +++ b/apps/gateway/src/admin/admin.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AdminController } from './admin.controller.js'; +import { AdminHealthController } from './admin-health.controller.js'; +import { AdminGuard } from './admin.guard.js'; + +@Module({ + controllers: [AdminController, AdminHealthController], + providers: [AdminGuard], +}) +export class AdminModule {} diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index 5848e7a..f635425 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -16,6 +16,7 @@ import { LogModule } from './log/log.module.js'; import { SkillsModule } from './skills/skills.module.js'; import { PluginModule } from './plugin/plugin.module.js'; import { McpModule } from './mcp/mcp.module.js'; +import { AdminModule } from './admin/admin.module.js'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; @Module({ @@ -36,6 +37,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; SkillsModule, PluginModule, McpModule, + AdminModule, ], controllers: [HealthController], providers: [ diff --git a/apps/web/src/app/(dashboard)/admin/page.tsx b/apps/web/src/app/(dashboard)/admin/page.tsx index 35e6cf3..4fc235c 100644 --- a/apps/web/src/app/(dashboard)/admin/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/page.tsx @@ -1,99 +1,531 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; +import { AdminRoleGuard } from '@/components/admin-role-guard'; import { api } from '@/lib/api'; +import { cn } from '@/lib/cn'; -interface SessionInfo { +// ── Types ────────────────────────────────────────────────────────────────────── + +interface UserDto { id: string; - provider: string; - modelId: string; + name: string; + email: string; + role: string; + banned: boolean; + banReason: string | null; createdAt: string; - promptCount: number; - channels: string[]; - durationMs: number; + updatedAt: string; } -interface SessionsResponse { - sessions: SessionInfo[]; +interface UserListDto { + users: UserDto[]; total: number; } -export default function AdminPage(): React.ReactElement { - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(true); +interface ServiceStatusDto { + status: 'ok' | 'error'; + latencyMs?: number; + error?: string; +} - useEffect(() => { - api('/api/sessions') - .then((res) => setSessions(res.sessions)) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); +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 ───────────────────────────────────────────────────────────────── + +export default function AdminPage(): React.ReactElement { + return ( + + + + ); +} + +function AdminContent(): React.ReactElement { + const [activeTab, setActiveTab] = useState<'users' | 'health'>('users'); return ( -
-

Admin

+
+
+

Admin Panel

+
- {/* User Management placeholder */} -
-

User Management

-
-

- User management will be available when the admin API is implemented -

-
-
+
+ {(['users', 'health'] as const).map((tab) => ( + + ))} +
- {/* Active Agent Sessions */} -
-

Active Agent Sessions

- {loading ? ( -

Loading sessions...

- ) : sessions.length === 0 ? ( -
-

No active sessions

-
- ) : ( -
- - - - - - - - - - - - {sessions.map((s) => ( - - - - - - - - ))} - -
Session IDProviderModelPromptsDuration
- {s.id} - {s.provider}{s.modelId} - {s.promptCount} - - {formatDuration(s.durationMs)} -
-
- )} -
+ {activeTab === 'users' ? : }
); } -function formatDuration(ms: number): string { - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ${seconds % 60}s`; - const hours = Math.floor(minutes / 60); - return `${hours}h ${minutes % 60}m`; +// ── 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/components/admin-role-guard.tsx b/apps/web/src/components/admin-role-guard.tsx new file mode 100644 index 0000000..bd14797 --- /dev/null +++ b/apps/web/src/components/admin-role-guard.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { useSession } from '@/lib/auth-client'; + +interface AdminRoleGuardProps { + children: React.ReactNode; +} + +export function AdminRoleGuard({ children }: AdminRoleGuardProps): React.ReactElement | null { + const { data: session, isPending } = useSession(); + const router = useRouter(); + + const user = session?.user as + | (NonNullable['user'] & { role?: string }) + | undefined; + + useEffect(() => { + if (!isPending && !session) { + router.replace('/login'); + } else if (!isPending && session && user?.role !== 'admin') { + router.replace('/'); + } + }, [isPending, session, user?.role, router]); + + if (isPending) { + return ( +
+
Loading...
+
+ ); + } + + if (!session || user?.role !== 'admin') { + return null; + } + + return <>{children}; +} diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index ed0b13f..398c251 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,7 +1,9 @@ import { createAuthClient } from 'better-auth/react'; +import { adminClient } from 'better-auth/client/plugins'; -export const authClient: ReturnType = createAuthClient({ +export const authClient = createAuthClient({ baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000', + plugins: [adminClient()], }); export const { useSession, signIn, signUp, signOut } = authClient; diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 329039e..0d97718 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -1,6 +1,6 @@ import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; -import { genericOAuth } from 'better-auth/plugins'; +import { admin, genericOAuth } from 'better-auth/plugins'; import type { Db } from '@mosaic/db'; export interface AuthConfig { @@ -68,7 +68,7 @@ export function createAuth(config: AuthConfig) { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // refresh daily }, - plugins, + plugins: [...(plugins ?? []), admin({ defaultRole: 'member', adminRoles: ['admin'] })], }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd0c158..eb95fb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@mosaic/memory': specifier: workspace:^ version: link:../../packages/memory + '@mosaic/queue': + specifier: workspace:^ + version: link:../../packages/queue '@mosaic/telegram-plugin': specifier: workspace:^ version: link:../../plugins/telegram