feat: add superadmin management
This commit is contained in:
@@ -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
20
App.tsx
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
171
pages/SuperAdmin.tsx
Normal 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>
|
||||
);
|
||||
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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user