diff --git a/.env.example b/.env.example index bfc3cd8..7c48f9a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/App.tsx b/App.tsx index c2dfd47..ed52806 100644 --- a/App.tsx +++ b/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 ; } - if (allowedRole && user.role !== allowedRole) { - return ; + if (allowedRoles && !allowedRoles.includes(user.role)) { + return ; } return <>{children}; @@ -75,12 +76,21 @@ function AppContent() { + } /> + + + + } + /> + {/* Catch all redirect */} } /> diff --git a/BACKEND.md b/BACKEND.md index 7574b73..dd85bd5 100644 --- a/BACKEND.md +++ b/BACKEND.md @@ -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. diff --git a/Dockerfile.api b/Dockerfile.api index 5fba536..8594211 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -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"] diff --git a/PORTAINER.md b/PORTAINER.md index de8bf55..6736a2f 100644 --- a/PORTAINER.md +++ b/PORTAINER.md @@ -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. diff --git a/components/CourseGrid.tsx b/components/CourseGrid.tsx index 9ad1df1..a33c884 100644 --- a/components/CourseGrid.tsx +++ b/components/CourseGrid.tsx @@ -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 = ({ } }; + const categories = Array.from(new Set(courses.map((course) => course.category))) + .sort((left, right) => left.localeCompare(right, 'pt-BR')); + if (loading) { return (
@@ -108,6 +110,17 @@ export const CourseGrid: React.FC = ({ ); } + if (courses.length === 0) { + return ( +
+
+

Cursos em breve

+

Ainda não há cursos publicados. Cadastre o primeiro curso no Painel do Instrutor.

+
+
+ ); + } + // Filter courses if a specific category is active const filteredCourses = activeCategory === 'all' ? courses @@ -178,7 +191,7 @@ export const CourseGrid: React.FC = ({ Todos os Cursos ({courses.length}) - {CATEGORIES.map(cat => { + {categories.map(cat => { const count = courses.filter(c => c.category === cat).length; return (
); }; - diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index 41a07e4..dc4c1b6 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -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 = ({ 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 = ({ 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 = ({ isOpen, onClose }) => { 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 /> - {!isRegistering && ( -
- - -
- )} - {error &&

{error}

} ) : (
- {user.role === 'professor' && ( + {user.role === 'superadmin' ? ( + + ) : user.role === 'professor' && ( + +
+ + +
+ } label="Usuários cadastrados" value={users.length} /> + } label="Contas ativas" value={activeUsers} /> + } label="Instrutores ativos" value={instructorUsers} /> +
+ +
+
+

Usuários

+ 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" + /> +
+ + {isLoading ? ( +
+ ) : filteredUsers.length === 0 ? ( +
Nenhum usuário encontrado.
+ ) : ( +
+ + + + + + + + + + + {filteredUsers.map((account) => { + const isCurrentUser = account.id === user?.id; + const isUpdating = updatingUserId === account.id; + return ( + + + + + + + ); + })} + +
UsuárioPerfilStatusCadastro
+
{account.name}
+
{account.email}
+
+ + + + {new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))}
+
+ )} +
+ + ); +}; + +const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }> = ({ icon, label, value }) => ( +
+
{icon}{label}
+
{value}
+
+); diff --git a/server/migrations/003_user_account_status.sql b/server/migrations/003_user_account_status.sql new file mode 100644 index 0000000..46ecd78 --- /dev/null +++ b/server/migrations/003_user_account_status.sql @@ -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); diff --git a/server/src/app.ts b/server/src/app.ts index b2eb493..e3fde6a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; } diff --git a/server/src/auth/plugin.ts b/server/src/auth/plugin.ts index 24f115e..d0ab6ba 100644 --- a/server/src/auth/plugin.ts +++ b/server/src/auth/plugin.ts @@ -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; 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' }); diff --git a/server/src/config.ts b/server/src/config.ts index 00cc358..b1f7871 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,15 +1,18 @@ import 'dotenv/config'; import { z } from 'zod'; +const optionalEnvironmentValue = (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); diff --git a/server/src/db/bootstrap-admin.ts b/server/src/db/bootstrap-admin.ts index c50bfcc..641a5a5 100644 --- a/server/src/db/bootstrap-admin.ts +++ b/server/src/db/bootstrap-admin.ts @@ -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}`); } diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts new file mode 100644 index 0000000..14f8398 --- /dev/null +++ b/server/src/routes/admin.ts @@ -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 }; + }); +}; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 4130732..571e455 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -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( `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( - `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' }); } diff --git a/services/api.ts b/services/api.ts index 28a83fb..5ee7f51 100644 --- a/services/api.ts +++ b/services/api.ts @@ -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>) { + return apiRequest<{ data: ManagedUser }>(`/admin/users/${userId}`, { + method: 'PATCH', + body: JSON.stringify(update), + }); + }, +}; diff --git a/types.ts b/types.ts index 7996f89..7c8c8b8 100644 --- a/types.ts +++ b/types.ts @@ -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;