feat: add superadmin management
This commit is contained in:
4
server/migrations/003_user_account_status.sql
Normal file
4
server/migrations/003_user_account_status.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
alter table users
|
||||
add column if not exists is_active boolean not null default true;
|
||||
|
||||
create index if not exists users_active_index on users (is_active);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
49
server/src/routes/admin.ts
Normal file
49
server/src/routes/admin.ts
Normal 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 };
|
||||
});
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user