feat: expand admin and instructor tools
All checks were successful
CI / Validate frontend and API (push) Successful in 50s
CI / Build and publish Docker images (push) Successful in 20s

This commit is contained in:
Cauê Faleiros
2026-09-01 13:19:29 -03:00
parent caaf904281
commit 4736fb5208
13 changed files with 385 additions and 22 deletions

View File

@@ -1,6 +1,7 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { hashPassword, verifyPassword } from '../auth/passwords.js';
import { hashToken } from '../auth/account-tokens.js';
import type { AuthUser } from '../auth/plugin.js';
import { pool } from '../db/pool.js';
@@ -12,6 +13,7 @@ const credentialsSchema = z.object({
const registerSchema = credentialsSchema.extend({
name: z.string().trim().min(2).max(120),
});
const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(12).max(200), name: z.string().trim().min(2).max(120).optional() });
type UserRow = {
id: string;
@@ -30,6 +32,60 @@ const serializeUser = (user: UserRow): AuthUser => ({
});
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/accept-invitation', async (request, reply) => {
const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const token = await client.query<{ email: string; role: AuthUser['role'] }>(
`update account_access_tokens set used_at = now()
where token_hash = $1 and purpose = 'invitation'::account_token_purpose and used_at is null and expires_at > now()
returning email, role`, [hashToken(input.token)],
);
if (!token.rows[0]) {
await client.query('rollback');
return reply.code(400).send({ error: 'This invitation is invalid or expired' });
}
const result = await client.query<UserRow>(
`insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4)
returning id, email, display_name, role, password_hash, is_active`,
[token.rows[0].email, await hashPassword(input.password), input.name, token.rows[0].role],
);
await client.query('commit');
const user = serializeUser(result.rows[0]);
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: '7d' }), user });
} catch {
await client.query('rollback');
return reply.code(409).send({ error: 'This invitation email already has an account' });
} finally {
client.release();
}
});
app.post('/reset-password', async (request, reply) => {
const input = tokenPasswordSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const token = await client.query<{ email: string }>(
`update account_access_tokens set used_at = now()
where token_hash = $1 and purpose = 'password_reset'::account_token_purpose and used_at is null and expires_at > now()
returning email`, [hashToken(input.token)],
);
if (!token.rows[0]) {
await client.query('rollback');
return reply.code(400).send({ error: 'This reset link is invalid or expired' });
}
await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]);
await client.query('commit');
return reply.code(204).send();
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
app.post('/register', async (request, reply) => {
const input = registerSchema.parse(request.body);
const passwordHash = await hashPassword(input.password);