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 (