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

@@ -10,10 +10,11 @@ FRONTEND_ORIGIN=http://localhost:3000
# Required in production. The development fallback must never be used outside localhost.
JWT_SECRET=replace-this-with-a-long-random-secret-before-deploying
# Run `npm run db:bootstrap-admin` after setting these values to create the first admin.
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
BOOTSTRAP_ADMIN_PASSWORD=change-this-password
BOOTSTRAP_ADMIN_NAME=Compor HUB Admin
# In Docker/Portainer, these create or update the superadmin every time the API starts.
# Keep them out of Git and use a password with at least 12 characters.
SUPERADMIN_EMAIL=admin@example.com
SUPERADMIN_PASSWORD=change-this-password
SUPERADMIN_NAME=Compor HUB Superadmin
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api

20
App.tsx
View File

@@ -8,10 +8,11 @@ import { MaterialsPage } from './pages/MaterialsPage';
import { CoursePlayerModal } from './components/CoursePlayerModal';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ToastProvider } from './context/ToastContext';
import { Course } from './types';
import { Course, UserRole } from './types';
import { SuperAdmin } from './pages/SuperAdmin';
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRole?: 'professor' | 'student' }> = ({ children, allowedRole }) => {
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
const { user, isLoading } = useAuth();
if (isLoading) return null;
@@ -20,8 +21,8 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRole?: 'profe
return <Navigate to="/" replace />;
}
if (allowedRole && user.role !== allowedRole) {
return <Navigate to={user.role === 'professor' ? '/gerenciar' : '/'} replace />;
if (allowedRoles && !allowedRoles.includes(user.role)) {
return <Navigate to={user.role === 'superadmin' ? '/admin' : user.role === 'professor' ? '/gerenciar' : '/'} replace />;
}
return <>{children}</>;
@@ -75,12 +76,21 @@ function AppContent() {
<Route
path="/gerenciar"
element={
<ProtectedRoute allowedRole="professor">
<ProtectedRoute allowedRoles={['professor', 'superadmin']}>
<ManageCourses />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute allowedRoles={['superadmin']}>
<SuperAdmin />
</ProtectedRoute>
}
/>
{/* Catch all redirect */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -24,7 +24,7 @@ The API health endpoint is available at `http://localhost:3001/api/v1/health` an
- `GET /api/v1/auth/me` restores an existing session.
- `GET`, `POST`, `PATCH`, and `DELETE` under `/api/v1/manage/courses` require an instructor or administrator session. Instructors can manage only their own courses.
To create the first local administrator, set `BOOTSTRAP_ADMIN_EMAIL`, `BOOTSTRAP_ADMIN_PASSWORD`, and optionally `BOOTSTRAP_ADMIN_NAME`, then run `npm run db:bootstrap-admin`. This command is deliberate and must not be exposed through the public API.
To create the first local administrator, set `SUPERADMIN_EMAIL`, `SUPERADMIN_PASSWORD`, and optionally `SUPERADMIN_NAME`, then run `npm run db:bootstrap-admin`. In Docker/Portainer, the API runs this command automatically after migrations.
For local demos, `npm run db:seed-demo-content` imports the original frontend catalogue into PostgreSQL. It requires the bootstrap administrator to exist and skips courses already present.

View File

@@ -10,4 +10,4 @@ COPY constants.ts types.ts ./
ENV APP_ENV=production
EXPOSE 3001
CMD ["sh", "-c", "npm run db:migrate && npm run start:api"]
CMD ["sh", "-c", "npm run db:migrate && npm run db:bootstrap-admin && npm run start:api"]

View File

@@ -18,6 +18,8 @@ The built-in Actions job token can be disabled or lack registry scope on self-ho
- `POSTGRES_PASSWORD`: a long, unique database password. Avoid characters that are not URL-safe because it is used in `DATABASE_URL`.
- `JWT_SECRET`: a unique random string of at least 32 characters.
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
- `SUPERADMIN_EMAIL`: email address for the initial platform administrator.
- `SUPERADMIN_PASSWORD`: password for that administrator (at least 12 characters).
Optional variables:
@@ -32,7 +34,7 @@ Optional variables:
1. Push to `main` and wait for Gitea Actions to publish `gitea.blyzer.com.br/blyzer/compor-academy-api:latest` and `gitea.blyzer.com.br/blyzer/compor-academy-web:latest`.
2. Ensure the Portainer endpoint can pull from the Gitea Container Registry. If the images are private, add Gitea registry credentials to the endpoint/stack deployment configuration.
3. Deploy the stack with a temporary `WEB_PORT` and verify `/api/v1/health` through the public domain. A healthy response is `{"status":"ok","database":"connected"}`; Portainer also runs this check automatically for the API service.
4. Create the production administrator using the API container's console and `npm run db:bootstrap-admin`, with the `BOOTSTRAP_ADMIN_*` variables supplied only for that one command.
4. Set `SUPERADMIN_EMAIL`, `SUPERADMIN_PASSWORD`, and optionally `SUPERADMIN_NAME`. The API creates or updates this superadmin automatically when it starts. Keep these values in Portainer only; changing the password and redeploying resets that account's password.
5. Place the web service behind HTTPS, normally through your existing reverse proxy (Traefik, Nginx Proxy Manager, or Cloudflare Tunnel), and set `FRONTEND_ORIGIN` to that HTTPS address.
6. Back up the `compor_postgres_data` volume before updates.

View File

@@ -4,7 +4,6 @@ import { Section, Course } from '../types';
import { getCourses } from '../services/db';
import { useAuth } from '../context/AuthContext';
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
import { CATEGORIES } from '../constants';
interface CourseGridProps {
onCourseSelect?: (course: Course) => void;
@@ -100,6 +99,9 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
}
};
const categories = Array.from(new Set<string>(courses.map((course) => course.category)))
.sort((left, right) => left.localeCompare(right, 'pt-BR'));
if (loading) {
return (
<div className="flex justify-center items-center h-64 w-full">
@@ -108,6 +110,17 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
);
}
if (courses.length === 0) {
return (
<section className="min-h-[60vh] flex items-center justify-center px-6 pt-28 pb-20">
<div className="max-w-md text-center rounded-2xl border border-white/10 bg-zinc-950/80 p-8">
<h2 className="text-xl font-bold text-white">Cursos em breve</h2>
<p className="mt-3 text-sm leading-relaxed text-gray-400">Ainda não há cursos publicados. Cadastre o primeiro curso no Painel do Instrutor.</p>
</div>
</section>
);
}
// Filter courses if a specific category is active
const filteredCourses = activeCategory === 'all'
? courses
@@ -178,7 +191,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
Todos os Cursos ({courses.length})
</button>
{CATEGORIES.map(cat => {
{categories.map(cat => {
const count = courses.filter(c => c.category === cat).length;
return (
<button

View File

@@ -1,21 +1,28 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Play, Plus, Sparkles } from 'lucide-react';
import { FEATURED_COURSE } from '../constants';
import { Course } from '../types';
import { getCourses } from '../services/db';
interface HeroProps {
onPlay?: (course: Course) => void;
}
export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
const [featuredCourse, setFeaturedCourse] = useState<Course | null>(null);
useEffect(() => {
getCourses().then((courses) => setFeaturedCourse(courses[0] || null)).catch(() => setFeaturedCourse(null));
}, []);
if (!featuredCourse) return null;
return (
<div className="relative w-full min-h-[75vh] lg:min-h-[82vh] max-h-[800px] h-auto overflow-hidden group flex flex-col justify-end pt-28 pb-12 sm:pb-16 px-6 md:px-12">
{/* Background Image with Cinematic Gradient Overlay */}
<div className="absolute inset-0 transition-transform duration-[20s] ease-in-out group-hover:scale-105">
<img
src={FEATURED_COURSE.thumbnail}
alt="Featured Course"
src={featuredCourse.thumbnail}
alt={featuredCourse.title}
className="w-full h-full object-cover object-center"
/>
{/* Gradients for text readability and seamless transition */}
@@ -33,26 +40,26 @@ export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
<Sparkles className="w-3 h-3 fill-current" />
Destaque
</span>
<span className="text-gray-300">{FEATURED_COURSE.category}</span>
<span className="text-gray-300">{featuredCourse.category}</span>
<span className="text-gray-500">•</span>
<span className="text-gray-300">{FEATURED_COURSE.duration}</span>
<span className="text-gray-300">{featuredCourse.duration}</span>
</div>
{/* Main Display Headline */}
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight drop-shadow-2xl mb-4">
{FEATURED_COURSE.title}
{featuredCourse.title}
</h1>
{/* Subhead / Lead Description */}
<p className="text-sm sm:text-base md:text-lg font-normal text-gray-200 line-clamp-2 sm:line-clamp-3 max-w-2xl leading-relaxed opacity-90 mb-6 sm:mb-8">
{FEATURED_COURSE.description}
{featuredCourse.description}
</p>
{/* Action Buttons with comfortable width, borders, and spacing */}
<div className="flex flex-wrap items-center gap-4">
{/* Primary CTA */}
<button
onClick={() => onPlay?.(FEATURED_COURSE)}
onClick={() => onPlay?.(featuredCourse)}
className="flex items-center justify-center gap-3 bg-white text-black px-8 sm:px-10 py-3.5 sm:py-4 rounded-[980px] text-sm sm:text-base font-bold hover:bg-gray-100 active:scale-95 transition-all duration-200 shadow-xl min-w-[200px]"
>
<Play className="w-4 h-4 sm:w-5 sm:h-5 fill-current" />
@@ -72,4 +79,3 @@ export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { X, Loader2, GraduationCap, School, KeyRound, UserPlus } from 'lucide-react';
import { X, Loader2, GraduationCap, School, UserPlus } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { UserRole } from '../types';
@@ -45,7 +45,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
return;
}
if (signedInUser.role !== role) {
if (signedInUser.role !== 'superadmin' && signedInUser.role !== role) {
logout();
setError(role === 'professor'
? 'Esta conta não possui acesso de instrutor.'
@@ -54,20 +54,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
}
onClose();
navigate(signedInUser.role === 'professor' ? '/gerenciar' : '/');
};
const handleDemoLogin = (roleType: UserRole) => {
setRole(roleType);
setIsRegistering(false);
if (roleType === 'professor') {
setEmail('admin@compor.local');
setPassword('ComporAdmin123!');
} else {
setEmail('student@compor.local');
setPassword('ComporStudent123!');
}
setError('');
navigate(signedInUser.role === 'superadmin' ? '/admin' : signedInUser.role === 'professor' ? '/gerenciar' : '/');
};
return (
@@ -103,17 +90,6 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
<input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required />
</div>
{!isRegistering && (
<div className="flex gap-2 justify-center">
<button type="button" onClick={() => handleDemoLogin('student')} className="text-caption font-semibold tracking-caption text-orange-400 hover:text-orange-300 px-3 py-1 rounded-[980px] bg-orange-500/10 border border-orange-500/20 flex items-center gap-1 transition-colors">
<KeyRound className="w-3 h-3" /> Acesso Aluno (Demo)
</button>
<button type="button" onClick={() => handleDemoLogin('professor')} className="text-caption font-semibold tracking-caption text-emerald-400 hover:text-emerald-300 px-3 py-1 rounded-[980px] bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-1 transition-colors">
<KeyRound className="w-3 h-3" /> Acesso Instrutor (Demo)
</button>
</div>
)}
{error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>}
<button type="submit" disabled={isSubmitting} className="w-full bg-white text-black text-body-sm font-semibold tracking-body-sm py-3.5 rounded-[980px] hover:bg-gray-200 active:scale-95 transition-all duration-200 flex items-center justify-center gap-2 shadow-xl">

View File

@@ -100,7 +100,18 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
</button>
) : (
<div className="flex items-center gap-3">
{user.role === 'professor' && (
{user.role === 'superadmin' ? (
<button
onClick={() => navigate('/admin')}
className={`text-sm font-semibold px-5 py-2.5 rounded-[980px] transition-colors ${
location.pathname === '/admin'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'text-gray-300 hover:text-white bg-white/5 hover:bg-white/10'
}`}
>
Painel Administrativo
</button>
) : user.role === 'professor' && (
<button
onClick={() => navigate('/gerenciar')}
className={`text-sm font-semibold px-5 py-2.5 rounded-[980px] transition-colors ${
@@ -140,4 +151,3 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
);
};

View File

@@ -74,10 +74,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
);
};
const toFrontendUser = (user: { email: string; name: string; role: string }): User => ({
const toFrontendUser = (user: { id: string; email: string; name: string; role: string }): User => ({
id: user.id,
email: user.email,
name: user.name,
role: user.role === 'student' ? 'student' : 'professor',
role: user.role === 'admin' ? 'superadmin' : user.role === 'instructor' ? 'professor' : 'student',
});
export const useAuth = () => {

View File

@@ -25,6 +25,9 @@ services:
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:?Set the public https URL in Portainer}
DATABASE_URL: postgresql://${POSTGRES_USER:-compor}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-compor_hub}
JWT_SECRET: ${JWT_SECRET:?Set a long random JWT_SECRET in Portainer}
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL:-}
SUPERADMIN_PASSWORD: ${SUPERADMIN_PASSWORD:-}
SUPERADMIN_NAME: ${SUPERADMIN_NAME:-Compor HUB Superadmin}
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/api/v1/health || exit 1"]
interval: 15s

171
pages/SuperAdmin.tsx Normal file
View File

@@ -0,0 +1,171 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Loader2, RefreshCw, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
import { adminApi, ManagedUser } from '../services/api';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../context/ToastContext';
import { useNavigate } from 'react-router-dom';
const roleLabel: Record<ManagedUser['role'], string> = {
admin: 'Superadmin',
instructor: 'Instrutor',
student: 'Aluno',
};
export const SuperAdmin: React.FC = () => {
const { user } = useAuth();
const { showToast } = useToast();
const navigate = useNavigate();
const [users, setUsers] = useState<ManagedUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const loadUsers = async () => {
setIsLoading(true);
try {
const response = await adminApi.listUsers();
setUsers(response.data);
} catch {
showToast('Não foi possível carregar os usuários.', 'error');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadUsers();
}, []);
const filteredUsers = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return users;
return users.filter((account) =>
account.name.toLowerCase().includes(normalized) || account.email.toLowerCase().includes(normalized),
);
}, [query, users]);
const updateUser = async (account: ManagedUser, update: Partial<Pick<ManagedUser, 'role' | 'isActive'>>) => {
setUpdatingUserId(account.id);
try {
const response = await adminApi.updateUser(account.id, update);
setUsers((current) => current.map((item) => item.id === account.id ? response.data : item));
showToast('Usuário atualizado.', 'success');
} catch {
showToast('Não foi possível atualizar este usuário.', 'error');
} finally {
setUpdatingUserId(null);
}
};
const activeUsers = users.filter((account) => account.isActive).length;
const instructorUsers = users.filter((account) => account.role === 'instructor' && account.isActive).length;
return (
<main className="min-h-screen pt-28 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-6 mb-10">
<div>
<div className="flex items-center gap-2 text-orange-400 text-xs font-bold uppercase tracking-wider mb-3">
<ShieldCheck className="w-4 h-4" /> Superadmin
</div>
<h1 className="text-3xl md:text-4xl font-bold text-white">Painel Administrativo</h1>
<p className="text-gray-400 mt-2">Gerencie os acessos da plataforma. Cursos e aulas continuam no Painel do Instrutor.</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={() => navigate('/gerenciar')}
className="inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-semibold bg-orange-500 hover:bg-orange-600 text-white transition-colors"
>
Gerenciar cursos
</button>
<button
onClick={() => void loadUsers()}
className="inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-semibold bg-white/10 hover:bg-white/15 text-white transition-colors"
>
<RefreshCw className="w-4 h-4" /> Atualizar
</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<StatCard icon={<Users className="w-5 h-5" />} label="Usuários cadastrados" value={users.length} />
<StatCard icon={<UserCheck className="w-5 h-5" />} label="Contas ativas" value={activeUsers} />
<StatCard icon={<ShieldCheck className="w-5 h-5" />} label="Instrutores ativos" value={instructorUsers} />
</div>
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden">
<div className="p-5 border-b border-white/10 flex flex-col sm:flex-row gap-4 sm:items-center sm:justify-between">
<h2 className="text-lg font-bold text-white">Usuários</h2>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Buscar por nome ou e-mail"
className="w-full sm:w-80 rounded-xl bg-zinc-900 border border-white/10 px-4 py-2.5 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:border-orange-500"
/>
</div>
{isLoading ? (
<div className="h-64 flex items-center justify-center"><Loader2 className="w-8 h-8 animate-spin text-orange-400" /></div>
) : filteredUsers.length === 0 ? (
<div className="h-48 flex items-center justify-center text-sm text-gray-500">Nenhum usuário encontrado.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="bg-white/[0.03] text-xs uppercase tracking-wider text-gray-500">
<tr>
<th className="px-5 py-4">Usuário</th>
<th className="px-5 py-4">Perfil</th>
<th className="px-5 py-4">Status</th>
<th className="px-5 py-4">Cadastro</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredUsers.map((account) => {
const isCurrentUser = account.id === user?.id;
const isUpdating = updatingUserId === account.id;
return (
<tr key={account.id} className="text-gray-300">
<td className="px-5 py-4">
<div className="font-semibold text-white">{account.name}</div>
<div className="text-xs text-gray-500 mt-1">{account.email}</div>
</td>
<td className="px-5 py-4">
<select
value={account.role}
disabled={isCurrentUser || isUpdating}
onChange={(event) => void updateUser(account, { role: event.target.value as ManagedUser['role'] })}
className="rounded-lg bg-zinc-900 border border-white/10 px-3 py-2 text-xs text-white disabled:opacity-50"
>
{Object.entries(roleLabel).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
</select>
</td>
<td className="px-5 py-4">
<button
disabled={isCurrentUser || isUpdating}
onClick={() => void updateUser(account, { isActive: !account.isActive })}
className={`inline-flex items-center gap-2 rounded-full px-3 py-2 text-xs font-semibold disabled:opacity-50 ${
account.isActive ? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25' : 'bg-red-500/15 text-red-400 hover:bg-red-500/25'
}`}
>
{account.isActive ? <UserCheck className="w-3.5 h-3.5" /> : <UserX className="w-3.5 h-3.5" />}
{account.isActive ? 'Ativo' : 'Desativado'}
</button>
</td>
<td className="px-5 py-4 text-xs text-gray-500">{new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
</main>
);
};
const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }> = ({ icon, label, value }) => (
<div className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5">
<div className="flex items-center gap-2 text-orange-400 mb-3">{icon}<span className="text-xs font-semibold uppercase tracking-wider">{label}</span></div>
<div className="text-3xl font-bold text-white">{value}</div>
</div>
);

View 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);

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' });
}

View File

@@ -10,6 +10,15 @@ export interface ApiUser {
role: ApiRole;
}
export interface ManagedUser {
id: string;
email: string;
name: string;
role: ApiRole;
isActive: boolean;
createdAt: string;
}
interface Session {
token: string;
user: ApiUser;
@@ -67,3 +76,15 @@ export const authApi = {
return apiRequest<{ user: ApiUser }>('/auth/me');
},
};
export const adminApi = {
async listUsers() {
return apiRequest<{ data: ManagedUser[] }>('/admin/users');
},
async updateUser(userId: string, update: Partial<Pick<ManagedUser, 'role' | 'isActive'>>) {
return apiRequest<{ data: ManagedUser }>(`/admin/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(update),
});
},
};

View File

@@ -46,9 +46,10 @@ export interface Section {
courses: Course[];
}
export type UserRole = 'professor' | 'student';
export type UserRole = 'superadmin' | 'professor' | 'student';
export interface User {
id: string;
email: string;
name: string;
role: UserRole;