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.
|
# Required in production. The development fallback must never be used outside localhost.
|
||||||
JWT_SECRET=replace-this-with-a-long-random-secret-before-deploying
|
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.
|
# In Docker/Portainer, these create or update the superadmin every time the API starts.
|
||||||
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
# Keep them out of Git and use a password with at least 12 characters.
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=change-this-password
|
SUPERADMIN_EMAIL=admin@example.com
|
||||||
BOOTSTRAP_ADMIN_NAME=Compor HUB Admin
|
SUPERADMIN_PASSWORD=change-this-password
|
||||||
|
SUPERADMIN_NAME=Compor HUB Superadmin
|
||||||
|
|
||||||
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
|
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
|
||||||
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
|
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 { CoursePlayerModal } from './components/CoursePlayerModal';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
import { ToastProvider } from './context/ToastContext';
|
import { ToastProvider } from './context/ToastContext';
|
||||||
import { Course } from './types';
|
import { Course, UserRole } from './types';
|
||||||
|
import { SuperAdmin } from './pages/SuperAdmin';
|
||||||
|
|
||||||
// Protected Route Component
|
// 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();
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
@@ -20,8 +21,8 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRole?: 'profe
|
|||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowedRole && user.role !== allowedRole) {
|
if (allowedRoles && !allowedRoles.includes(user.role)) {
|
||||||
return <Navigate to={user.role === 'professor' ? '/gerenciar' : '/'} replace />;
|
return <Navigate to={user.role === 'superadmin' ? '/admin' : user.role === 'professor' ? '/gerenciar' : '/'} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
@@ -75,12 +76,21 @@ function AppContent() {
|
|||||||
<Route
|
<Route
|
||||||
path="/gerenciar"
|
path="/gerenciar"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRole="professor">
|
<ProtectedRoute allowedRoles={['professor', 'superadmin']}>
|
||||||
<ManageCourses />
|
<ManageCourses />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['superadmin']}>
|
||||||
|
<SuperAdmin />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Catch all redirect */}
|
{/* Catch all redirect */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</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 /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.
|
- `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.
|
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
|
ENV APP_ENV=production
|
||||||
EXPOSE 3001
|
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`.
|
- `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.
|
- `JWT_SECRET`: a unique random string of at least 32 characters.
|
||||||
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
|
- `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:
|
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`.
|
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.
|
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.
|
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.
|
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.
|
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 { getCourses } from '../services/db';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { CATEGORIES } from '../constants';
|
|
||||||
|
|
||||||
interface CourseGridProps {
|
interface CourseGridProps {
|
||||||
onCourseSelect?: (course: Course) => void;
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64 w-full">
|
<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
|
// Filter courses if a specific category is active
|
||||||
const filteredCourses = activeCategory === 'all'
|
const filteredCourses = activeCategory === 'all'
|
||||||
? courses
|
? courses
|
||||||
@@ -178,7 +191,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
|||||||
Todos os Cursos ({courses.length})
|
Todos os Cursos ({courses.length})
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{CATEGORIES.map(cat => {
|
{categories.map(cat => {
|
||||||
const count = courses.filter(c => c.category === cat).length;
|
const count = courses.filter(c => c.category === cat).length;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Play, Plus, Sparkles } from 'lucide-react';
|
import { Play, Plus, Sparkles } from 'lucide-react';
|
||||||
import { FEATURED_COURSE } from '../constants';
|
|
||||||
import { Course } from '../types';
|
import { Course } from '../types';
|
||||||
|
import { getCourses } from '../services/db';
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
onPlay?: (course: Course) => void;
|
onPlay?: (course: Course) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
|
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 (
|
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">
|
<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 */}
|
{/* Background Image with Cinematic Gradient Overlay */}
|
||||||
<div className="absolute inset-0 transition-transform duration-[20s] ease-in-out group-hover:scale-105">
|
<div className="absolute inset-0 transition-transform duration-[20s] ease-in-out group-hover:scale-105">
|
||||||
<img
|
<img
|
||||||
src={FEATURED_COURSE.thumbnail}
|
src={featuredCourse.thumbnail}
|
||||||
alt="Featured Course"
|
alt={featuredCourse.title}
|
||||||
className="w-full h-full object-cover object-center"
|
className="w-full h-full object-cover object-center"
|
||||||
/>
|
/>
|
||||||
{/* Gradients for text readability and seamless transition */}
|
{/* 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" />
|
<Sparkles className="w-3 h-3 fill-current" />
|
||||||
Destaque
|
Destaque
|
||||||
</span>
|
</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-500">•</span>
|
||||||
<span className="text-gray-300">{FEATURED_COURSE.duration}</span>
|
<span className="text-gray-300">{featuredCourse.duration}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Display Headline */}
|
{/* 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">
|
<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>
|
</h1>
|
||||||
|
|
||||||
{/* Subhead / Lead Description */}
|
{/* 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">
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Action Buttons with comfortable width, borders, and spacing */}
|
{/* Action Buttons with comfortable width, borders, and spacing */}
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
{/* Primary CTA */}
|
{/* Primary CTA */}
|
||||||
<button
|
<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]"
|
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" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { useAuth } from '../context/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { UserRole } from '../types';
|
import { UserRole } from '../types';
|
||||||
@@ -45,7 +45,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signedInUser.role !== role) {
|
if (signedInUser.role !== 'superadmin' && signedInUser.role !== role) {
|
||||||
logout();
|
logout();
|
||||||
setError(role === 'professor'
|
setError(role === 'professor'
|
||||||
? 'Esta conta não possui acesso de instrutor.'
|
? 'Esta conta não possui acesso de instrutor.'
|
||||||
@@ -54,20 +54,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
navigate(signedInUser.role === 'professor' ? '/gerenciar' : '/');
|
navigate(signedInUser.role === 'superadmin' ? '/admin' : 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('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 />
|
<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>
|
</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>}
|
{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">
|
<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>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<button
|
||||||
onClick={() => navigate('/gerenciar')}
|
onClick={() => navigate('/gerenciar')}
|
||||||
className={`text-sm font-semibold px-5 py-2.5 rounded-[980px] transition-colors ${
|
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,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role === 'student' ? 'student' : 'professor',
|
role: user.role === 'admin' ? 'superadmin' : user.role === 'instructor' ? 'professor' : 'student',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ services:
|
|||||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:?Set the public https URL in Portainer}
|
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}
|
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}
|
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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/api/v1/health || exit 1"]
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3001/api/v1/health || exit 1"]
|
||||||
interval: 15s
|
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 { courseRoutes } from './routes/courses.js';
|
||||||
import { manageCourseRoutes } from './routes/manage-courses.js';
|
import { manageCourseRoutes } from './routes/manage-courses.js';
|
||||||
import { learningRoutes } from './routes/learning.js';
|
import { learningRoutes } from './routes/learning.js';
|
||||||
|
import { adminRoutes } from './routes/admin.js';
|
||||||
|
|
||||||
export function buildApp() {
|
export function buildApp() {
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
@@ -42,5 +43,6 @@ export function buildApp() {
|
|||||||
app.register(courseRoutes, { prefix: '/api/v1/courses' });
|
app.register(courseRoutes, { prefix: '/api/v1/courses' });
|
||||||
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
|
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
|
||||||
app.register(learningRoutes, { prefix: '/api/v1' });
|
app.register(learningRoutes, { prefix: '/api/v1' });
|
||||||
|
app.register(adminRoutes, { prefix: '/api/v1/admin' });
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fastifyJwt from '@fastify/jwt';
|
|||||||
import type { FastifyPluginAsync, preHandlerHookHandler } from 'fastify';
|
import type { FastifyPluginAsync, preHandlerHookHandler } from 'fastify';
|
||||||
import fastifyPlugin from 'fastify-plugin';
|
import fastifyPlugin from 'fastify-plugin';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
import { pool } from '../db/pool.js';
|
||||||
|
|
||||||
export type UserRole = 'student' | 'instructor' | 'admin';
|
export type UserRole = 'student' | 'instructor' | 'admin';
|
||||||
|
|
||||||
@@ -29,20 +30,35 @@ declare module 'fastify' {
|
|||||||
const registerAuth: FastifyPluginAsync = async (app) => {
|
const registerAuth: FastifyPluginAsync = async (app) => {
|
||||||
await app.register(fastifyJwt, { secret: config.JWT_SECRET });
|
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 {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
} catch {
|
} 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) => {
|
app.decorate('requireRoles', (roles: UserRole[]) => async (request, reply) => {
|
||||||
try {
|
if (!(await verifyActiveUser(request, reply))) return;
|
||||||
await request.jwtVerify();
|
|
||||||
} catch {
|
|
||||||
return reply.code(401).send({ error: 'Authentication required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!roles.includes(request.user.role)) {
|
if (!roles.includes(request.user.role)) {
|
||||||
return reply.code(403).send({ error: 'Insufficient permissions' });
|
return reply.code(403).send({ error: 'Insufficient permissions' });
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const optionalEnvironmentValue = <T extends z.ZodTypeAny>(schema: T) =>
|
||||||
|
z.preprocess((value) => value === '' ? undefined : value, schema.optional());
|
||||||
|
|
||||||
const environmentSchema = z.object({
|
const environmentSchema = z.object({
|
||||||
API_PORT: z.coerce.number().int().positive().default(3001),
|
API_PORT: z.coerce.number().int().positive().default(3001),
|
||||||
DATABASE_URL: z.string().url().default('postgres://compor:compor_local_password@localhost:5433/compor_hub'),
|
DATABASE_URL: z.string().url().default('postgres://compor:compor_local_password@localhost:5433/compor_hub'),
|
||||||
FRONTEND_ORIGIN: z.string().url().default('http://localhost:3000'),
|
FRONTEND_ORIGIN: z.string().url().default('http://localhost:3000'),
|
||||||
APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
|
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
|
||||||
BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
|
SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()),
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
|
SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(12)),
|
||||||
BOOTSTRAP_ADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Admin'),
|
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const config = environmentSchema.parse(process.env);
|
export const config = environmentSchema.parse(process.env);
|
||||||
|
|||||||
@@ -3,20 +3,22 @@ import { config } from '../config.js';
|
|||||||
import { closePool, pool } from './pool.js';
|
import { closePool, pool } from './pool.js';
|
||||||
|
|
||||||
async function bootstrapAdmin() {
|
async function bootstrapAdmin() {
|
||||||
if (!config.BOOTSTRAP_ADMIN_EMAIL || !config.BOOTSTRAP_ADMIN_PASSWORD) {
|
if (!config.SUPERADMIN_EMAIL || !config.SUPERADMIN_PASSWORD) {
|
||||||
throw new Error('Set BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD before running this command.');
|
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 }>(
|
const result = await pool.query<{ email: string }>(
|
||||||
`insert into users (email, password_hash, display_name, role)
|
`insert into users (email, password_hash, display_name, role)
|
||||||
values ($1, $2, $3, 'admin')
|
values ($1, $2, $3, 'admin')
|
||||||
on conflict (email) do update
|
on conflict (email) do update
|
||||||
set password_hash = excluded.password_hash,
|
set password_hash = excluded.password_hash,
|
||||||
display_name = excluded.display_name,
|
display_name = excluded.display_name,
|
||||||
role = 'admin'
|
role = 'admin',
|
||||||
|
is_active = true
|
||||||
returning email`,
|
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}`);
|
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;
|
display_name: string;
|
||||||
role: AuthUser['role'];
|
role: AuthUser['role'];
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
|
is_active: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serializeUser = (user: UserRow): AuthUser => ({
|
const serializeUser = (user: UserRow): AuthUser => ({
|
||||||
@@ -37,7 +38,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const result = await pool.query<UserRow>(
|
const result = await pool.query<UserRow>(
|
||||||
`insert into users (email, password_hash, display_name)
|
`insert into users (email, password_hash, display_name)
|
||||||
values ($1, $2, $3)
|
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],
|
[input.email, passwordHash, input.name],
|
||||||
);
|
);
|
||||||
const user = serializeUser(result.rows[0]);
|
const user = serializeUser(result.rows[0]);
|
||||||
@@ -54,12 +55,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.post('/login', async (request, reply) => {
|
app.post('/login', async (request, reply) => {
|
||||||
const input = credentialsSchema.parse(request.body);
|
const input = credentialsSchema.parse(request.body);
|
||||||
const result = await pool.query<UserRow>(
|
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],
|
[input.email],
|
||||||
);
|
);
|
||||||
const account = result.rows[0];
|
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' });
|
return reply.code(401).send({ error: 'Invalid email or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ export interface ApiUser {
|
|||||||
role: ApiRole;
|
role: ApiRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManagedUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: ApiRole;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
token: string;
|
token: string;
|
||||||
user: ApiUser;
|
user: ApiUser;
|
||||||
@@ -67,3 +76,15 @@ export const authApi = {
|
|||||||
return apiRequest<{ user: ApiUser }>('/auth/me');
|
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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
3
types.ts
3
types.ts
@@ -46,9 +46,10 @@ export interface Section {
|
|||||||
courses: Course[];
|
courses: Course[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRole = 'professor' | 'student';
|
export type UserRole = 'superadmin' | 'professor' | 'student';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
|
|||||||
Reference in New Issue
Block a user