feat: add superadmin management
All checks were successful
CI / Validate frontend and API (push) Successful in 1m59s
CI / Build and publish Docker images (push) Successful in 22s

This commit is contained in:
Cauê Faleiros
2026-09-01 09:54:45 -03:00
parent 2dfca33758
commit a5055238b9
21 changed files with 366 additions and 74 deletions

View File

@@ -8,6 +8,7 @@ import { authRoutes } from './routes/auth.js';
import { courseRoutes } from './routes/courses.js';
import { manageCourseRoutes } from './routes/manage-courses.js';
import { learningRoutes } from './routes/learning.js';
import { adminRoutes } from './routes/admin.js';
export function buildApp() {
const app = Fastify({ logger: true });
@@ -42,5 +43,6 @@ export function buildApp() {
app.register(courseRoutes, { prefix: '/api/v1/courses' });
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
app.register(learningRoutes, { prefix: '/api/v1' });
app.register(adminRoutes, { prefix: '/api/v1/admin' });
return app;
}

View File

@@ -2,6 +2,7 @@ import fastifyJwt from '@fastify/jwt';
import type { FastifyPluginAsync, preHandlerHookHandler } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { config } from '../config.js';
import { pool } from '../db/pool.js';
export type UserRole = 'student' | 'instructor' | 'admin';
@@ -29,20 +30,35 @@ declare module 'fastify' {
const registerAuth: FastifyPluginAsync = async (app) => {
await app.register(fastifyJwt, { secret: config.JWT_SECRET });
app.decorate('authenticate', async (request, reply) => {
const verifyActiveUser = async (request: { jwtVerify: () => Promise<void>; user: AuthUser }, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
try {
await request.jwtVerify();
} catch {
return reply.code(401).send({ error: 'Authentication required' });
reply.code(401).send({ error: 'Authentication required' });
return false;
}
const result = await pool.query<{ role: UserRole; display_name: string }>(
'select role, display_name from users where id = $1 and is_active = true',
[request.user.id],
);
const account = result.rows[0];
if (!account) {
reply.code(401).send({ error: 'This account is no longer active' });
return false;
}
request.user.role = account.role;
request.user.name = account.display_name;
return true;
};
app.decorate('authenticate', async (request, reply) => {
await verifyActiveUser(request, reply);
});
app.decorate('requireRoles', (roles: UserRole[]) => async (request, reply) => {
try {
await request.jwtVerify();
} catch {
return reply.code(401).send({ error: 'Authentication required' });
}
if (!(await verifyActiveUser(request, reply))) return;
if (!roles.includes(request.user.role)) {
return reply.code(403).send({ error: 'Insufficient permissions' });

View File

@@ -1,15 +1,18 @@
import 'dotenv/config';
import { z } from 'zod';
const optionalEnvironmentValue = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess((value) => value === '' ? undefined : value, schema.optional());
const environmentSchema = z.object({
API_PORT: z.coerce.number().int().positive().default(3001),
DATABASE_URL: z.string().url().default('postgres://compor:compor_local_password@localhost:5433/compor_hub'),
FRONTEND_ORIGIN: z.string().url().default('http://localhost:3000'),
APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
BOOTSTRAP_ADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Admin'),
SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()),
SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(12)),
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
});
export const config = environmentSchema.parse(process.env);

View File

@@ -3,20 +3,22 @@ import { config } from '../config.js';
import { closePool, pool } from './pool.js';
async function bootstrapAdmin() {
if (!config.BOOTSTRAP_ADMIN_EMAIL || !config.BOOTSTRAP_ADMIN_PASSWORD) {
throw new Error('Set BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD before running this command.');
if (!config.SUPERADMIN_EMAIL || !config.SUPERADMIN_PASSWORD) {
console.warn('SUPERADMIN_EMAIL and SUPERADMIN_PASSWORD are not set; skipping superadmin bootstrap.');
return;
}
const passwordHash = await hashPassword(config.BOOTSTRAP_ADMIN_PASSWORD);
const passwordHash = await hashPassword(config.SUPERADMIN_PASSWORD);
const result = await pool.query<{ email: string }>(
`insert into users (email, password_hash, display_name, role)
values ($1, $2, $3, 'admin')
on conflict (email) do update
set password_hash = excluded.password_hash,
display_name = excluded.display_name,
role = 'admin'
role = 'admin',
is_active = true
returning email`,
[config.BOOTSTRAP_ADMIN_EMAIL, passwordHash, config.BOOTSTRAP_ADMIN_NAME],
[config.SUPERADMIN_EMAIL, passwordHash, config.SUPERADMIN_NAME],
);
console.log(`Administrator ready: ${result.rows[0].email}`);
}

View File

@@ -0,0 +1,49 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { pool } from '../db/pool.js';
const userParamsSchema = z.object({
userId: z.string().uuid(),
});
const updateUserSchema = z.object({
role: z.enum(['student', 'instructor', 'admin']).optional(),
isActive: z.boolean().optional(),
}).refine((input) => input.role !== undefined || input.isActive !== undefined, {
message: 'Provide at least one field to update',
});
export const adminRoutes: FastifyPluginAsync = async (app) => {
const adminAccess = { preHandler: app.requireRoles(['admin']) };
app.get('/users', adminAccess, async () => {
const result = await pool.query(
`select id, email, display_name as name, role, is_active as "isActive",
created_at as "createdAt"
from users
order by created_at desc`,
);
return { data: result.rows };
});
app.patch('/users/:userId', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params);
const input = updateUserSchema.parse(request.body);
if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) {
return reply.code(400).send({ error: 'You cannot remove your own superadmin access' });
}
const result = await pool.query(
`update users
set role = coalesce($2::user_role, role),
is_active = coalesce($3, is_active)
where id = $1
returning id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt"`,
[userId, input.role ?? null, input.isActive ?? null],
);
const account = result.rows[0];
if (!account) return reply.code(404).send({ error: 'User not found' });
return { data: account };
});
};

View File

@@ -19,6 +19,7 @@ type UserRow = {
display_name: string;
role: AuthUser['role'];
password_hash: string;
is_active: boolean;
};
const serializeUser = (user: UserRow): AuthUser => ({
@@ -37,7 +38,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const result = await pool.query<UserRow>(
`insert into users (email, password_hash, display_name)
values ($1, $2, $3)
returning id, email, display_name, role, password_hash`,
returning id, email, display_name, role, password_hash, is_active`,
[input.email, passwordHash, input.name],
);
const user = serializeUser(result.rows[0]);
@@ -54,12 +55,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/login', async (request, reply) => {
const input = credentialsSchema.parse(request.body);
const result = await pool.query<UserRow>(
`select id, email, display_name, role, password_hash from users where email = $1`,
`select id, email, display_name, role, password_hash, is_active from users where email = $1`,
[input.email],
);
const account = result.rows[0];
if (!account || !(await verifyPassword(input.password, account.password_hash))) {
if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) {
return reply.code(401).send({ error: 'Invalid email or password' });
}