feat(admin): web admin panel — user CRUD, role assignment, system health (#150)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #150.
This commit is contained in:
@@ -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:^",
|
||||
|
||||
73
apps/gateway/src/admin/admin-health.controller.ts
Normal file
73
apps/gateway/src/admin/admin-health.controller.ts
Normal file
@@ -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<HealthStatusDto> {
|
||||
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<ServiceStatusDto> {
|
||||
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<ServiceStatusDto> {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
146
apps/gateway/src/admin/admin.controller.ts
Normal file
146
apps/gateway/src/admin/admin.controller.ts
Normal file
@@ -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<Omit<UserRow, 'id' | 'createdAt'>>,
|
||||
): Promise<UserDto> {
|
||||
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<UserListDto> {
|
||||
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<UserDto> {
|
||||
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<UserDto> {
|
||||
// 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<UserDto> {
|
||||
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<UserDto> {
|
||||
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<UserDto> {
|
||||
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<void> {
|
||||
await this.ensureExists(id);
|
||||
await this.db.delete(usersTable).where(eq(usersTable.id, id));
|
||||
}
|
||||
|
||||
private async ensureExists(id: string): Promise<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
56
apps/gateway/src/admin/admin.dto.ts
Normal file
56
apps/gateway/src/admin/admin.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
44
apps/gateway/src/admin/admin.guard.ts
Normal file
44
apps/gateway/src/admin/admin.guard.ts
Normal file
@@ -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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
10
apps/gateway/src/admin/admin.module.ts
Normal file
10
apps/gateway/src/admin/admin.module.ts
Normal file
@@ -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 {}
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user