diff --git a/.env.example b/.env.example index be6c5af..a8c81ad 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,11 @@ AI_CONNECT_TIMEOUT_SECONDS=5 AI_TIMEOUT_SECONDS=90 AI_RATE_LIMIT_CAPACITY=5 AI_RATE_LIMIT_REFILL_PER_MINUTE=2 + +# Monitoring stack (docker-compose.monitoring.yml — optional overlay). +# Grafana is never public: reach it via SSH tunnel only: +# ssh -L 3000:localhost:3001 user@vps # then http://localhost:3000 +# Generate a strong password once on the host: +# echo "GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)" >> .env +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=change_me_strong_password diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2fa5e36 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Force LF line endings for files that run on the Linux VPS, so they +# work regardless of the contributor's OS / git autocrlf setting. +# (CRLF in a shell script breaks the shebang: "bad interpreter ^M".) +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +Caddyfile text eol=lf +Makefile text eol=lf +Dockerfile text eol=lf +mvnw text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d24a4b8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,49 @@ +version: 2 + +updates: + # ── Backend (Maven) ────────────────────────────────────── + - package-ecosystem: maven + directory: /apps/backend + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies, backend] + groups: + backend-minor-patch: + update-types: [minor, patch] + + # ── Frontend (npm/pnpm) ────────────────────────────────── + - package-ecosystem: npm + directory: /apps/frontend + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies, frontend] + groups: + frontend-minor-patch: + update-types: [minor, patch] + ignore: + # Next.js is an internal fork — never auto-bump (see apps/frontend/AGENTS.md) + - dependency-name: next + + # ── Docker base images ─────────────────────────────────── + - package-ecosystem: docker + directory: /apps/backend + schedule: + interval: weekly + labels: [dependencies, docker] + - package-ecosystem: docker + directory: /apps/frontend + schedule: + interval: weekly + labels: [dependencies, docker] + + # ── GitHub Actions ─────────────────────────────────────── + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: [dependencies, github-actions] + groups: + actions: + update-types: [minor, patch] diff --git a/.github/workflows/build-services.yml b/.github/workflows/build-services.yml deleted file mode 100644 index f9487f3..0000000 --- a/.github/workflows/build-services.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: Build Services CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - changes: - name: Detect changed paths - runs-on: ubuntu-latest - outputs: - frontend: ${{ steps.filter.outputs.frontend }} - backend: ${{ steps.filter.outputs.backend }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - frontend: - - 'apps/frontend/**' - backend: - - 'apps/backend/**' - - frontend: - name: Frontend — Build - needs: changes - if: needs.changes.outputs.frontend == 'true' - runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/frontend - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: '9.15.0' - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: pnpm - cache-dependency-path: apps/frontend/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - backend: - name: Backend — Build - needs: changes - if: needs.changes.outputs.backend == 'true' - runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/backend - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: temurin - cache: maven - - - name: Make mvnw executable - run: chmod +x ./mvnw - - - name: Build - run: ./mvnw package -DskipTests -B - - docker: - name: Docker — Build images - runs-on: ubuntu-latest - needs: [frontend, backend] - if: | - always() && - !contains(needs.*.result, 'failure') && - !contains(needs.*.result, 'cancelled') && - (needs.frontend.result == 'success' || needs.backend.result == 'success') - steps: - - uses: actions/checkout@v4 - - - name: Create env file - run: cp .env.example .env - - - name: Build images - run: docker compose build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a7de5d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,217 @@ +name: CI + +# Fast feedback on every push / PR to dev and main. +# Heavy security scanning lives in security.yml (main + nightly). +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +# Cancel superseded runs on the same ref. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + name: Detect changed paths + runs-on: ubuntu-latest + outputs: + frontend: ${{ steps.filter.outputs.frontend }} + backend: ${{ steps.filter.outputs.backend }} + migrations: ${{ steps.filter.outputs.migrations }} + i18n: ${{ steps.filter.outputs.i18n }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + frontend: + - 'apps/frontend/**' + backend: + - 'apps/backend/**' + migrations: + - 'apps/backend/src/main/resources/db/migration/**' + i18n: + - 'apps/frontend/messages/**' + + frontend: + name: Frontend — lint, typecheck, build + needs: changes + if: needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: apps/frontend + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: '9.15.0' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '20' + cache: pnpm + cache-dependency-path: apps/frontend/pnpm-lock.yaml + - name: Install + run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + - name: Type-check + run: pnpm typecheck + - name: Build + run: pnpm build + + backend: + name: Backend — compile, test, coverage + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: apps/backend + # Ephemeral Postgres for @SpringBootTest (context load runs Flyway against it). + # Created before the steps, reachable at localhost:5432, destroyed with the runner. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: codestar + POSTGRES_PASSWORD: codestar + POSTGRES_DB: codestardb + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U codestar -d codestardb" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + DB_URL: jdbc:postgresql://localhost:5432/codestardb + DB_USER: codestar + DB_PASSWORD: codestar + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + java-version: '17' + distribution: temurin + cache: maven + - name: Make mvnw executable + run: chmod +x ./mvnw + - name: Compile + run: ./mvnw compile -B --no-transfer-progress + - name: Test + coverage + run: ./mvnw verify -B --no-transfer-progress + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: jacoco-coverage + path: apps/backend/target/site/jacoco/ + retention-days: 7 + if-no-files-found: warn + compression-level: 6 + + governance: + name: Codestar governance (migrations, i18n) + needs: changes + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # full history so guards can diff against the base branch + - name: Flyway migration governance + if: needs.changes.outputs.migrations == 'true' + env: + BASE_REF: origin/${{ github.base_ref || 'main' }} + run: bash scripts/ci/flyway-governance.sh + - name: i18n key parity + if: needs.changes.outputs.i18n == 'true' + run: bash scripts/ci/i18n-parity.sh + + secrets: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + # Run the gitleaks binary directly: the GitHub Action requires a paid + # license for organisation repos, the open-source binary does not. + - name: Run gitleaks + env: + GITLEAKS_VERSION: 8.21.2 + run: | + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz gitleaks + ./gitleaks detect --source . --redact --verbose --exit-code 1 + + dependency-review: + name: Dependency review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/dependency-review-action@e58c696e52cac8e62d61cc21fda89565d71505d7 # v4.3.0 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + + docker: + name: Docker — compose build + needs: [frontend, backend] + if: | + always() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + (needs.frontend.result == 'success' || needs.backend.result == 'success') + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Create env file + run: cp .env.example .env + - name: Build images + run: docker compose build + + # Single required status check for branch protection. + ci-required: + name: CI required + needs: [frontend, backend, governance, secrets, docker] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify no required job failed + run: | + results='${{ join(needs.*.result, ',') }}' + echo "Upstream results: $results" + case "$results" in + *failure*|*cancelled*) echo "A required job failed."; exit 1 ;; + *) echo "All required jobs passed (or were skipped)." ;; + esac + + # Discord notification. No-ops when DISCORD_WEBHOOK_URL is unset (fork-safe). + notify: + name: Discord notification + needs: [frontend, backend, governance, secrets, docker] + if: always() + runs-on: ubuntu-latest + steps: + - uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} + status: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }} + title: "CI pipeline" + username: Codestar CI diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..77bbc32 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,61 @@ +name: Deploy + +# Continuous deployment over SSH (build-on-VPS model): connect to the +# server and run ./update.sh, which pulls, rebuilds, health-checks and +# rolls back on failure. +# +# GUARDED: each job runs only if its host secret is set, so a fork without +# deployment secrets simply skips this workflow (no red runs). +# +# Required secrets (per environment): +# prod : SSH_HOST, SSH_USER, SSH_KEY (+ optional SSH_PORT, SSH_PATH) +# staging : SSH_HOST_STAGING, SSH_USER, SSH_KEY (+ optional SSH_PORT, SSH_PATH) +# SSH_PATH defaults to ~/codestar. +on: + push: + branches: [dev, main] + workflow_dispatch: + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false # never interrupt a deployment mid-flight + +permissions: + contents: read + +jobs: + deploy-prod: + name: Deploy → production + if: github.ref == 'refs/heads/main' && vars.HAS_PROD == 'true' + runs-on: ubuntu-latest + environment: production + timeout-minutes: 30 + steps: + - name: Run remote update + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT || 22 }} + script: | + cd ${{ secrets.SSH_PATH || '~/codestar' }} + ./update.sh --yes + + deploy-staging: + name: Deploy → staging + if: github.ref == 'refs/heads/dev' && vars.HAS_STAGING == 'true' + runs-on: ubuntu-latest + environment: staging + timeout-minutes: 30 + steps: + - name: Run remote update + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 + with: + host: ${{ secrets.SSH_HOST_STAGING }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT || 22 }} + script: | + cd ${{ secrets.SSH_PATH || '~/codestar' }} + ./update.sh --yes diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..49e8569 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,146 @@ +name: Security + +# Heavy security scanning: runs on main, nightly, and on demand. +# Fast PR feedback (gitleaks, dependency-review) lives in ci.yml. +on: + push: + branches: [main] + schedule: + - cron: '27 3 * * *' # nightly at 03:27 UTC + workflow_dispatch: + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + codeql-java: + name: CodeQL — Java + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + java-version: '17' + distribution: temurin + cache: maven + - uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + with: + languages: java-kotlin + build-mode: autobuild + - uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + with: + category: /language:java-kotlin + + codeql-js: + name: CodeQL — JavaScript/TypeScript + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + with: + languages: javascript-typescript + build-mode: none + - uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + with: + category: /language:javascript-typescript + + trivy: + name: Trivy — filesystem & config + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + security-events: write + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Trivy filesystem scan (dependencies) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: . + format: sarif + output: trivy-fs.sarif + severity: CRITICAL,HIGH + - name: Upload filesystem SARIF + uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + if: always() + with: + sarif_file: trivy-fs.sarif + category: trivy-fs + + - name: Trivy config scan (Dockerfiles, compose) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: config + scan-ref: . + format: sarif + output: trivy-config.sarif + severity: CRITICAL,HIGH + - name: Upload config SARIF + uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + if: always() + with: + sarif_file: trivy-config.sarif + category: trivy-config + + scorecard: + name: OpenSSF Scorecard + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + security-events: write + id-token: write + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + with: + results_file: scorecard.sarif + results_format: sarif + publish_results: true + - uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + if: always() + with: + sarif_file: scorecard.sarif + category: scorecard + + sbom: + name: SBOM + provenance attestation + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # required for keyless signing of the attestation + attestations: write # required to record the attestation + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Generate SBOM (CycloneDX) + uses: anchore/sbom-action@55dc4ee22412511ee8c3142cbea40418e6cec693 # v0.17.8 + with: + path: . + format: cyclonedx-json + output-file: sbom.cyclonedx.json + artifact-name: sbom.cyclonedx.json + - name: Attest build provenance for the SBOM + # Only on direct pushes to this repo (forks/PRs cannot write attestations). + if: github.event_name != 'pull_request' + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: sbom.cyclonedx.json diff --git a/.gitignore b/.gitignore index d39aa33..7d68508 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,13 @@ *.key *.crt +# Per-instance branding/config (never committed — survives git pull) +/config/* +!/config/.gitkeep + +# Database backups produced by update.sh (stay on the host only) +/backups/ + # OS .DS_Store .AppleDouble diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..13bdcf1 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,70 @@ +# GitLab CI mirror of .github/workflows/ci.yml. +# Same logic, driven by the shared scripts in scripts/ci/ and the same +# build commands, so a fork hosted on GitLab gets equivalent coverage. +# +# Native GitLab security templates provide SAST / secret detection / +# dependency scanning (the GitLab equivalents of CodeQL / gitleaks / Trivy). +include: + - template: Security/SAST.gitlab-ci.yml + - template: Security/Secret-Detection.gitlab-ci.yml + - template: Security/Dependency-Scanning.gitlab-ci.yml + +stages: [build, test, security] + +default: + interruptible: true + +# ── Frontend ───────────────────────────────────────────────── +frontend: + stage: build + image: node:20-alpine + rules: + - changes: [apps/frontend/**/*] + cache: + key: + files: [apps/frontend/pnpm-lock.yaml] + paths: [apps/frontend/.pnpm-store] + before_script: + - corepack enable && corepack prepare pnpm@9.15.0 --activate + - cd apps/frontend + - pnpm config set store-dir .pnpm-store + - pnpm install --frozen-lockfile + script: + - pnpm lint + - pnpm typecheck + - pnpm build + +# ── Backend ────────────────────────────────────────────────── +backend: + stage: build + image: maven:3.9-eclipse-temurin-17 + rules: + - changes: [apps/backend/**/*] + cache: + key: maven + paths: [.m2/repository] + variables: + MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository" + script: + - cd apps/backend + - ./mvnw verify -B --no-transfer-progress + artifacts: + when: always + paths: [apps/backend/target/site/jacoco/] + expire_in: 7 days + +# ── Codestar governance guards ─────────────────────────────── +governance: + stage: test + image: alpine:3.20 + before_script: + - apk add --no-cache bash git jq + script: + - | + if git diff --name-only "origin/$CI_DEFAULT_BRANCH"...HEAD -- apps/backend/src/main/resources/db/migration | grep -q .; then + BASE_REF="origin/$CI_DEFAULT_BRANCH" bash scripts/ci/flyway-governance.sh + fi + - | + if git diff --name-only "origin/$CI_DEFAULT_BRANCH"...HEAD -- apps/frontend/messages | grep -q .; then + bash scripts/ci/i18n-parity.sh + fi diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..1393717 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,41 @@ +# ───────────────────────────────────────────────────────────── +# Caddy reverse proxy — automatic HTTPS (Let's Encrypt). +# The domain is injected at runtime from the DOMAIN env var, so this +# file stays generic: every deployment sets its own DOMAIN in .env. +# Only the frontend is exposed; backend & postgres stay on the +# internal Docker network and are never reachable from the Internet. +# ───────────────────────────────────────────────────────────── + +{ + # Admin API on the internal Docker network so the monitoring stack can + # scrape Caddy's own metrics. This port is never published to the host, + # so it stays unreachable from the Internet. + admin 0.0.0.0:2019 + servers { + metrics + } +} + +{$DOMAIN} { + # Compress responses (zstd preferred, gzip fallback). + encode zstd gzip + + # All public traffic goes to the Next.js frontend over the internal + # Docker network. The frontend talks to the backend server-side. + reverse_proxy frontend:3000 + + # Structured access logs to stdout — ready to be shipped to Loki later. + log { + output stdout + format json + } + + # Baseline security headers. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } +} diff --git a/Makefile b/Makefile index a11b3ba..dfd5204 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup prod dev down logs ps clean +.PHONY: setup prod dev down logs ps clean monitoring ## First-time setup: copy env template setup: @@ -27,3 +27,7 @@ logs: ## Service status ps: docker compose ps + +## Monitoring stack: Prometheus + Grafana + Loki (reach Grafana via SSH tunnel) +monitoring: + docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.monitoring.yml up -d diff --git a/README.md b/README.md index 19530d7..016c733 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Codestar +[![CI](https://github.com/CodeStar-Project/codestar/actions/workflows/ci.yml/badge.svg)](https://github.com/CodeStar-Project/codestar/actions/workflows/ci.yml) +[![Security](https://github.com/CodeStar-Project/codestar/actions/workflows/security.yml/badge.svg)](https://github.com/CodeStar-Project/codestar/actions/workflows/security.yml) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/CodeStar-Project/codestar/badge)](https://scorecard.dev/viewer/?uri=github.com/CodeStar-Project/codestar) +[![License](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE) + Open-source & self-hosted e-learning platform template to build yours easly. ## Backend @@ -76,3 +81,31 @@ Or with frontend hot-reload via Docker: ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d ``` + +## Monitoring (optional) + +An overlay adds Prometheus, Grafana, Loki, Promtail, node-exporter and cAdvisor. +Every container stays on the internal network — no public port is opened. + +**1. Generate the Grafana admin password (one-time, on the host)** + +```bash +echo "GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)" >> .env +``` + +**2. Start the stack** + +```bash +make monitoring +# equivalent to: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.monitoring.yml up -d +``` + +**3. Reach Grafana through an SSH tunnel** (it is never exposed publicly) + +```bash +ssh -L 3000:localhost:3001 user@vps # then open http://localhost:3000 +``` + +Log in with `admin` and the password you generated. Dashboards are provisioned +automatically: Spring/JVM, VPS system, containers, and Loki logs. diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index 4056386..9fe7243 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -100,6 +100,20 @@ 2.7.0 + + + io.micrometer + micrometer-registry-prometheus + runtime + + + + + net.logstash.logback + logstash-logback-encoder + 8.0 + + @@ -108,6 +122,24 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + prepare-agent + + + report + verify + report + + + diff --git a/apps/backend/src/main/java/com/codestar/backend/config/RequestContextFilter.java b/apps/backend/src/main/java/com/codestar/backend/config/RequestLoggingFilter.java similarity index 96% rename from apps/backend/src/main/java/com/codestar/backend/config/RequestContextFilter.java rename to apps/backend/src/main/java/com/codestar/backend/config/RequestLoggingFilter.java index 4be181d..cee58bd 100644 --- a/apps/backend/src/main/java/com/codestar/backend/config/RequestContextFilter.java +++ b/apps/backend/src/main/java/com/codestar/backend/config/RequestLoggingFilter.java @@ -21,9 +21,9 @@ @Component("codestarRequestContextFilter") @Order(Ordered.HIGHEST_PRECEDENCE) -public class RequestContextFilter extends OncePerRequestFilter { +public class RequestLoggingFilter extends OncePerRequestFilter { - private static final Logger log = LoggerFactory.getLogger(RequestContextFilter.class); + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); private static final String REQUEST_ID_HEADER = "X-Request-Id"; private static final String MDC_REQUEST_ID = "requestId"; diff --git a/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java b/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java index 72d8e82..fb9c990 100644 --- a/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java +++ b/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java @@ -49,10 +49,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/settings/branding").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/media/**").permitAll() - // actuator health + // actuator: health probes + info + Prometheus scrape. + // Not internet-facing (backend runs on loopback + internal network). .requestMatchers(HttpMethod.GET, "/actuator/health", - "/actuator/health/**").permitAll() + "/actuator/health/**", + "/actuator/info", + "/actuator/prometheus").permitAll() // swagger / OpenAPI .requestMatchers( "/v3/api-docs/**", diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index 2e01617..0c5e17a 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -12,9 +12,15 @@ spring.jpa.show-sql=false spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.open-in-view=false -# Actuator — expose the health endpoint. Aggregate status includes DB connectivity -management.endpoints.web.exposure.include=health +# Actuator — health (DB connectivity), info, and Prometheus metrics. +# The backend is never public (loopback + internal Docker network), so the +# Prometheus scrape endpoint is only reachable by the monitoring stack. +management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.health.show-details=never +# Kubernetes-style liveness/readiness probes (used by Docker healthcheck & CD) +management.endpoint.health.probes.enabled=true +management.health.livenessstate.enabled=true +management.health.readinessstate.enabled=true # Flyway spring.flyway.enabled=true diff --git a/apps/backend/src/main/resources/logback-spring.xml b/apps/backend/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..82c5ce1 --- /dev/null +++ b/apps/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + traceId + spanId + {"service":"codestar-backend"} + + + + + + + diff --git a/apps/frontend/app/actions/groups.ts b/apps/frontend/app/actions/groups.ts index 717e330..2e3dd8a 100644 --- a/apps/frontend/app/actions/groups.ts +++ b/apps/frontend/app/actions/groups.ts @@ -113,7 +113,61 @@ export interface Invitation { export async function getInvitations(groupId: string, includeRevoked = false): Promise { const qs = includeRevoked ? "?includeRevoked=true" : ""; - return ( - (await apiFetch(`/api/v1/groups/${groupId}/invitations${qs}`)) ?? [] - ); + try { + return (await apiFetch(`/api/v1/groups/${groupId}/invitations${qs}`)) ?? []; + } catch (e) { + if (e instanceof ApiError && (e.status === 403 || e.status === 404)) return []; + throw e; + } +} + +export async function createGroup(payload: { + name: string; + slug?: string; + startsAt?: string | null; + endsAt?: string | null; +}): Promise<{ ok: boolean; error?: string; group?: GroupResponse }> { + try { + const g = await apiFetch("/api/v1/groups", { + method: "POST", + body: payload, + }); + return { ok: true, group: g ?? undefined }; + } catch (e) { + return { ok: false, error: e instanceof ApiError ? e.message : undefined }; + } +} + +export async function updateGroup( + id: string, + payload: { name?: string; startsAt?: string | null; endsAt?: string | null } +): Promise<{ ok: boolean; error?: string; group?: GroupResponse }> { + try { + const g = await apiFetch(`/api/v1/groups/${id}`, { + method: "PATCH", + body: payload, + }); + return { ok: true, group: g ?? undefined }; + } catch (e) { + return { ok: false, error: e instanceof ApiError ? e.message : undefined }; + } +} + +export async function createInvitation( + groupId: string, + maxUses: number, + expiresAt?: string +): Promise<{ ok: boolean; error?: string; invitation?: Invitation }> { + try { + const inv = await apiFetch( + `/api/v1/groups/${groupId}/invitations`, + { + method: "POST", + body: { maxUses, ...(expiresAt ? { expiresAt } : {}) }, + } + ); + return { ok: true, invitation: inv ?? undefined }; + } catch (e) { + return { ok: false, error: e instanceof ApiError ? e.message : undefined }; + } } diff --git a/apps/frontend/app/actions/theme.ts b/apps/frontend/app/actions/theme.ts new file mode 100644 index 0000000..0e399d3 --- /dev/null +++ b/apps/frontend/app/actions/theme.ts @@ -0,0 +1,23 @@ +"use server"; + +/** + * Persist the chosen theme via `THEME_COOKIE` (non-httpOnly so the inline + * can read it). Mirrors `actions/locale.ts`. + */ + +import { cookies } from "next/headers"; +import { revalidatePath } from "next/cache"; + +import { THEME_COOKIE, isTheme } from "@/lib/theme"; + +export async function setThemeAction(value: string) { + if (!isTheme(value)) return; + const cookieStore = await cookies(); + cookieStore.set(THEME_COOKIE, value, { + path: "/", + maxAge: 60 * 60 * 24 * 365, // 1 year + sameSite: "lax", + httpOnly: false, + }); + revalidatePath("/", "layout"); +} diff --git a/apps/frontend/app/admin/groups/[id]/curriculum/page.tsx b/apps/frontend/app/admin/groups/[id]/curriculum/page.tsx index 43b27e3..ff5854e 100644 --- a/apps/frontend/app/admin/groups/[id]/curriculum/page.tsx +++ b/apps/frontend/app/admin/groups/[id]/curriculum/page.tsx @@ -47,7 +47,8 @@ export default async function CurriculumPage({ params }: PageProps) { diff --git a/apps/frontend/app/admin/groups/[id]/members/page.tsx b/apps/frontend/app/admin/groups/[id]/members/page.tsx index ff0255d..52b1a21 100644 --- a/apps/frontend/app/admin/groups/[id]/members/page.tsx +++ b/apps/frontend/app/admin/groups/[id]/members/page.tsx @@ -43,7 +43,8 @@ export default async function GroupMembersPage({ params }: PageProps) { diff --git a/apps/frontend/app/admin/groups/[id]/page.tsx b/apps/frontend/app/admin/groups/[id]/page.tsx new file mode 100644 index 0000000..fda9872 --- /dev/null +++ b/apps/frontend/app/admin/groups/[id]/page.tsx @@ -0,0 +1,93 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; + +import { getGroup, getInvitations, getMyGroups } from "@/app/actions/groups"; +import { AdminBreadcrumb, AdminShell } from "@/components/admin/admin-shell"; +import { InvitationsPanel } from "@/components/admin/invitations-panel"; +import { requireRole } from "@/components/admin/role-guard"; +import { PageHeader } from "@/components/course/page-header"; +import { GlassCard, GlassCardContent } from "@/components/ui/glass-card"; +import { isAdmin } from "@/lib/roles"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export const metadata: Metadata = { title: "Groupe" }; + +export default async function GroupHubPage({ params }: PageProps) { + const me = await requireRole("TEACHER"); + const { id } = await params; + const admin = isAdmin(me.role); + const t = await getTranslations("admin.groups"); + + let groupName: string | null = null; + if (admin) { + const g = await getGroup(id); + groupName = g?.name ?? null; + } else { + const mine = await getMyGroups(); + groupName = mine.find((g) => g.id === id)?.name ?? null; + } + if (!groupName) notFound(); + + const invitations = await getInvitations(id); + + return ( + + + + +
+ + + +

{t("members")}

+

{t("hub.membersDesc")}

+
+
+ + + + + +

{t("curriculum")}

+

{t("hub.curriculumDesc")}

+
+
+ +
+ + +
+ ); +} diff --git a/apps/frontend/app/admin/groups/page.tsx b/apps/frontend/app/admin/groups/page.tsx new file mode 100644 index 0000000..5ce0983 --- /dev/null +++ b/apps/frontend/app/admin/groups/page.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +import { getAllGroups, getMyGroups } from "@/app/actions/groups"; +import { AdminBreadcrumb, AdminShell } from "@/components/admin/admin-shell"; +import { CreateGroupTrigger } from "@/components/admin/create-group-trigger"; +import { GroupsGrid } from "@/components/admin/groups-grid"; +import { requireRole } from "@/components/admin/role-guard"; +import { PageHeader } from "@/components/course/page-header"; +import { isAdmin } from "@/lib/roles"; + +export const metadata: Metadata = { title: "Groupes" }; + +export default async function GroupsPage() { + const me = await requireRole("TEACHER"); + const admin = isAdmin(me.role); + const t = await getTranslations("admin.groups"); + + const groups = admin ? await getAllGroups() : await getMyGroups(); + + return ( + + + + ) : null + } + className="mb-8" + /> + + + ); +} diff --git a/apps/frontend/app/admin/page.tsx b/apps/frontend/app/admin/page.tsx index 5f398d0..f8c5df3 100644 --- a/apps/frontend/app/admin/page.tsx +++ b/apps/frontend/app/admin/page.tsx @@ -3,11 +3,10 @@ import Link from "next/link"; import { getLocale, getTranslations } from "next-intl/server"; import { getInstanceBranding } from "@/app/actions/instance"; -import { - getAllCourses, - getMyAuthoredCourses, -} from "@/app/actions/courses"; -import { getMyGroups, getAllGroups } from "@/app/actions/groups"; +import { getAllCourses } from "@/app/actions/courses"; +import { getAllGroups } from "@/app/actions/groups"; +import { getSettings } from "@/app/actions/settings"; +import { getAllUsers } from "@/app/actions/users"; import { AdminShell } from "@/components/admin/admin-shell"; import { GroupCardActions } from "@/components/admin/group-card-actions"; import { requireRole } from "@/components/admin/role-guard"; @@ -15,10 +14,8 @@ import { CourseMeta } from "@/components/course/course-meta"; import { PageHeader } from "@/components/course/page-header"; import { StatCard } from "@/components/course/stat-card"; import { GlassButton } from "@/components/ui/glass-button"; -import { - GlassCard, - GlassCardContent, -} from "@/components/ui/glass-card"; +import { GlassCard, GlassCardContent } from "@/components/ui/glass-card"; +import { GlassChip } from "@/components/ui/glass-chip"; import { BookIcon, ChartIcon, @@ -26,40 +23,59 @@ import { SparklesIcon, UsersIcon, } from "@/components/ui/icons"; -import { isAdmin } from "@/lib/roles"; -import type { CourseSummary } from "@/lib/types"; +import { isSuperAdmin } from "@/lib/roles"; +import type { CourseSummary, GroupResponse, Role } from "@/lib/types"; export const metadata: Metadata = { title: "Admin" }; +const ROLE_ORDER: Role[] = ["STUDENT", "TEACHER", "ADMIN", "SUPER_ADMIN"]; + +/** Module-scope (not a component) so the `now` read stays out of render. */ +function countActiveGroups(groups: GroupResponse[]): number { + const now = Date.now(); + return groups.filter( + (g) => !g.endsAt || new Date(g.endsAt).getTime() >= now + ).length; +} + export default async function AdminDashboardPage() { - const me = await requireRole("TEACHER"); - const admin = isAdmin(me.role); + const me = await requireRole("ADMIN"); + const superAdmin = isSuperAdmin(me.role); const locale = (await getLocale()) as "fr" | "en"; - const [t, branding] = await Promise.all([ - getTranslations("admin"), - getInstanceBranding(), - ]); - const [courses, groups] = await Promise.all([ - admin ? getAllCourses() : getMyAuthoredCourses(), - admin ? getAllGroups() : getMyGroups(), - ]); + const [t, tRoles, branding, courses, groups, users, settings] = + await Promise.all([ + getTranslations("admin"), + getTranslations("roles"), + getInstanceBranding(), + getAllCourses(), + getAllGroups(), + getAllUsers(), + getSettings(), + ]); const published = courses.filter((c) => c.status === "PUBLISHED").length; const drafts = courses.filter((c) => c.status === "DRAFT").length; - const sorted = [...courses].sort( + const archived = courses.filter((c) => c.status === "ARCHIVED").length; + + const activeGroups = countActiveGroups(groups); + + const disabledUsers = users.filter((u) => u.disabledAt).length; + const usersByRole = ROLE_ORDER.map((r) => ({ + role: r, + count: users.filter((u) => u.role === r).length, + })); + const maxRoleCount = Math.max(1, ...usersByRole.map((x) => x.count)); + + const recent = [...courses].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); return ( @@ -75,27 +91,22 @@ export default async function AdminDashboardPage() { - {t("recentTitle")} + {t("usersLink")} + + + {t("settingsLink")} - {admin && ( - - {t("usersLink")} - - )} - {admin && ( - - {t("settingsLink")} - - )} } className="mb-10" /> + {/* KPIs */}
} /> } /> } + label={t("kpi.users")} + value={users.length} + hint={t("kpi.disabled", { count: disabledUsers })} + icon={} /> } />
+ {/* Catalog health + users by role */} +
+ + +

+ {t("kpi.catalogHealth")} +

+
+ + + +
+
+
+ + + +

+ {t("kpi.usersByRole")} +

+
    + {usersByRole.map(({ role, count }) => ( +
  • + + {tRoles(role)} + + + + + + {count} + +
  • + ))} +
+
+
+
+ + {/* Super-admin instance block */} + {superAdmin && ( +
+ + +
+ + + +
+
+ {t("superAdmin.kicker")} +
+

+ {t("superAdmin.title", { name: branding.name })} +

+
+ + + {branding.accent} + + · + + {branding.locale.toUpperCase()} + + {settings && ( + <> + · + + {t("superAdmin.maxBlocks", { + n: settings.maxBlocksPerPage, + })} + + + )} +
+
+
+
+ + {t("settingsLink")} + + + {t("superAdmin.manageRoles")} + +
+
+
+
+ )} + + {/* Recent courses + groups */}
@@ -126,13 +256,16 @@ export default async function AdminDashboardPage() {
    - {sorted.slice(0, 5).map((c) => ( + {recent.slice(0, 5).map((c) => (
  • ))} - {sorted.length === 0 && ( - + {recent.length === 0 && ( + {t("table.empty")} )} @@ -144,7 +277,10 @@ export default async function AdminDashboardPage() { {t("groupsTitle")} {groups.length === 0 ? ( - + {t("noGroups")} ) : ( @@ -155,7 +291,9 @@ export default async function AdminDashboardPage() {
    {g.name}
    -
    {g.slug}
    +
    + {g.slug} +
    {t("groupsManage")} → - · + + · + 0 ? Math.round((value / total) * 100) : 0; + return ( +
    + + {label} + + + + + + {value} + +
    + ); +} + function RecentCourse({ course, locale, diff --git a/apps/frontend/app/bookmarks/page.tsx b/apps/frontend/app/bookmarks/page.tsx index f13ec81..87780a4 100644 --- a/apps/frontend/app/bookmarks/page.tsx +++ b/apps/frontend/app/bookmarks/page.tsx @@ -4,14 +4,13 @@ import { getTranslations } from "next-intl/server"; import { getMyBookmarks } from "@/app/actions/bookmarks"; import { requireAuth } from "@/components/admin/role-guard"; -import { BookmarkRow } from "@/components/course/bookmark-row"; import { EmptyState } from "@/components/course/empty-state"; import { PageHeader } from "@/components/course/page-header"; import { StudentShell } from "@/components/course/student-shell"; -import { BookmarkIcon } from "@/components/ui/icons"; -import type { BookmarkEnriched } from "@/lib/types"; +import { GlassCard, GlassCardContent, GlassCardTitle } from "@/components/ui/glass-card"; +import { ArrowRightIcon, BookmarkFilledIcon } from "@/components/ui/icons"; -export const metadata: Metadata = { title: "Mes favoris" }; +export const metadata: Metadata = { title: "Cours enregistrés" }; export default async function BookmarksPage() { await requireAuth(); @@ -20,17 +19,14 @@ export default async function BookmarksPage() { getMyBookmarks(), ]); - const byCourse = new Map(); + // One saved course per courseId (the bookmark is anchored on the first block). + const byCourse = new Map(); for (const b of bookmarks) { - const k = b.courseId; - if (!byCourse.has(k)) { - byCourse.set(k, { title: b.courseTitle, slug: b.courseSlug, items: [] }); + if (!byCourse.has(b.courseId)) { + byCourse.set(b.courseId, { title: b.courseTitle, slug: b.courseSlug }); } - byCourse.get(k)!.items.push(b); - } - for (const entry of byCourse.values()) { - entry.items.sort((a, b) => a.blockOrderIndex - b.blockOrderIndex); } + const courses = [...byCourse.values()]; return ( @@ -41,39 +37,31 @@ export default async function BookmarksPage() { className="mb-10" /> - {bookmarks.length === 0 ? ( - } title={t("empty")} /> + {courses.length === 0 ? ( + } title={t("empty")} /> ) : ( -
    - {[...byCourse.entries()].map(([id, group]) => ( -
    -
    -

    - - {group.title} - -

    - - {t("bookmarksCount", { count: group.items.length })} - -
    -
      - {group.items.map((b) => ( -
    • - -
    • - ))} -
    -
    +
      + {courses.map((c) => ( +
    • + + + +
      + + {t("kicker")} +
      + + {c.title} + + + {t("open")} + +
      +
      + +
    • ))} -
    +
)} ); diff --git a/apps/frontend/app/courses/[slug]/page.tsx b/apps/frontend/app/courses/[slug]/page.tsx index 73ba7bd..88ec99a 100644 --- a/apps/frontend/app/courses/[slug]/page.tsx +++ b/apps/frontend/app/courses/[slug]/page.tsx @@ -8,20 +8,15 @@ import { getCourseBySlug } from "@/app/actions/courses"; import { getMyEnrollments } from "@/app/actions/enrollments"; import { requireAuth } from "@/components/admin/role-guard"; import { CourseMeta } from "@/components/course/course-meta"; +import { CourseSaveButton } from "@/components/course/course-save-button"; import { PageHeader } from "@/components/course/page-header"; import { ProgressBar } from "@/components/course/progress-bar"; import { StatCard } from "@/components/course/stat-card"; import { StudentShell } from "@/components/course/student-shell"; import { GlassButton } from "@/components/ui/glass-button"; -import { - GlassCard, - GlassCardContent, - GlassCardTitle, -} from "@/components/ui/glass-card"; import { ArrowRightIcon, BookIcon, - BookmarkIcon, ClockIcon, PlayIcon, UsersIcon, @@ -70,12 +65,22 @@ export default async function CourseIntroPage({ params }: PageProps) { ? t("resume") : t("start"); - const allBlocks = (course.pages ?? []).flatMap((p) => p.blocks); + const sortedPages = [...(course.pages ?? [])].sort( + (a, b) => a.orderIndex - b.orderIndex + ); + const allBlocks = sortedPages.flatMap((p) => p.blocks); const blocksCount = allBlocks.length; const headings = allBlocks.filter((b) => ["H1", "H2"].includes(b.kind)); // A page maps to a lesson; fall back to headings/blocks for legacy single-page courses. const lessonCount = (course.pages?.length ?? 0) || headings.length || blocksCount; + // Course-level "save": anchored on the first block (cf. CourseSaveButton). + const firstBlockId = + [...(sortedPages[0]?.blocks ?? [])].sort( + (a, b) => a.orderIndex - b.orderIndex + )[0]?.id ?? null; + const savedId = bookmarks[0]?.id ?? null; + return ( @@ -87,13 +92,23 @@ export default async function CourseIntroPage({ params }: PageProps) { title={course.title} description={course.description} actions={ - - - - {ctaLabel} - - - + <> + + + + {ctaLabel} + + + + + } className="mb-8" /> @@ -142,47 +157,6 @@ export default async function CourseIntroPage({ params }: PageProps) { {t("publishedOn", { date: formatDate(course.publishedAt, locale) })}

)} - -
-

- {t("bookmarksTitle")} -

- {bookmarks.length === 0 ? ( - - -

{t("bookmarksEmpty")}

- - {t("openReader")} - -
- ) : ( -
    - {bookmarks.map((b) => ( -
  • - - - -
    - - {b.blockKind} · #{b.blockOrderIndex + 1} -
    - - {b.blockPreview ?? `Bloc ${b.blockOrderIndex + 1}`} - -
    -
    - -
  • - ))} -
- )} -
); } diff --git a/apps/frontend/app/courses/[slug]/read/page.tsx b/apps/frontend/app/courses/[slug]/read/page.tsx index 32a9f0e..610c3c9 100644 --- a/apps/frontend/app/courses/[slug]/read/page.tsx +++ b/apps/frontend/app/courses/[slug]/read/page.tsx @@ -3,12 +3,10 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import { getCourseBookmarks } from "@/app/actions/bookmarks"; import { getCourseBySlug } from "@/app/actions/courses"; import { requireAuth } from "@/components/admin/role-guard"; import { BlockRenderer } from "@/components/course/block-renderer"; import { BlockToc, blockSlug } from "@/components/course/block-toc"; -import { BookmarkButton } from "@/components/course/bookmark-button"; import { MobileToc } from "@/components/course/mobile-toc"; import { StudentShell } from "@/components/course/student-shell"; import { GlassButton } from "@/components/ui/glass-button"; @@ -50,11 +48,6 @@ export default async function CourseReaderPage({ params, searchParams }: PagePro const course = await getCourseBySlug(slug); if (!course) notFound(); - const bookmarks = await getCourseBookmarks(course.id); - const bookmarkMap = new Map( - bookmarks.map((b) => [b.blockId, b.id]) - ); - const pages = [...(course.pages ?? [])] .sort((a, b) => a.orderIndex - b.orderIndex) .map((p) => ({ @@ -173,21 +166,9 @@ export default async function CourseReaderPage({ params, searchParams }: PagePro ) : ( -
+
{pageBlocks.map((b) => ( -
-
- -
- -
+ ))}
)} diff --git a/apps/frontend/app/courses/page.tsx b/apps/frontend/app/courses/page.tsx index 9528576..49d45c0 100644 --- a/apps/frontend/app/courses/page.tsx +++ b/apps/frontend/app/courses/page.tsx @@ -45,13 +45,6 @@ export default async function CoursesCatalogPage() { diff --git a/apps/frontend/app/dashboard/page.tsx b/apps/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..b7bef9b --- /dev/null +++ b/apps/frontend/app/dashboard/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; + +import { requireAuth } from "@/components/admin/role-guard"; +import { isAdmin, isStaff } from "@/lib/roles"; + +/** + * Role dispatcher — the single source of truth for "where does a logged-in + * user land". Post-login and the public "/" send authenticated users here. + * ADMIN / SUPER_ADMIN → /admin + * TEACHER → /studio + * STUDENT → /learn + */ +export default async function DashboardPage() { + const me = await requireAuth(); + + if (isAdmin(me.role)) redirect("/admin"); + if (isStaff(me.role)) redirect("/studio"); + redirect("/learn"); +} diff --git a/apps/frontend/app/globals.css b/apps/frontend/app/globals.css index d211660..6422bae 100644 --- a/apps/frontend/app/globals.css +++ b/apps/frontend/app/globals.css @@ -1,54 +1,77 @@ @import "tailwindcss"; +/* ============================================================ + Codestar — Design System "Liquid Glass · Citron" + Source de vérité : apps/frontend/design-liquid-glass-citron-dark.md + Accent citron #EAB12E · modes clair (parchemin) & sombre (navy). + ============================================================ */ + +/* ── Tokens fixes (indépendants du mode) ── */ :root { - --color-bg-base-raw: #f4f6fb; - --color-bg-mesh-1-raw: #dce8ff; - --color-bg-mesh-2-raw: #ffe4d6; - --color-bg-mesh-3-raw: #e1f5e8; + --color-accent-raw: #eab12e; + --color-accent-fg-raw: #1a1f2e; + --glass-blur: blur(29px) saturate(180%); + + --r-sm: 8px; + --r: 14px; + --r-lg: 22px; + --r-xl: 32px; + --radius: 0.875rem; +} + +/* ── Mode clair (défaut) — parchemin solaire ── */ +:root, +[data-theme="light"] { + color-scheme: light; + + --color-bg-base-raw: #fbf9ee; + --color-bg-mesh-1-raw: #fff1bf; + --color-bg-mesh-2-raw: #ffe2be; + --color-bg-mesh-3-raw: #ebf6c8; --glass-bg: rgba(255, 255, 255, 0.55); --glass-bg-strong: rgba(255, 255, 255, 0.72); --glass-border: rgba(255, 255, 255, 0.65); - --glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.08); - --glass-blur: blur(20px) saturate(180%); + --glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.1); --color-text-raw: #1a1f2e; --color-text-soft-raw: #4a5366; --color-muted-raw: #8892a6; - --color-accent-raw: #7aa9ff; - --color-accent-fg-raw: #ffffff; - --color-accent-soft-raw: rgba(122, 169, 255, 0.18); + --color-accent-soft-raw: rgba(234, 177, 46, 0.16); - --color-success-raw: #5dc9a8; - --color-warning-raw: #ffb672; - --color-danger-raw: #ff8a95; - --color-green-raw: #7bc86c; - --color-tip-raw: #ffd66b; - - --r-sm: 8px; - --r: 14px; - --r-lg: 22px; - --r-xl: 32px; - --radius: 0.875rem; + --color-success-raw: #2faa7e; + --color-warning-raw: #e08a2b; + --color-danger-raw: #e0556a; + --color-green-raw: #5aa84a; + --color-tip-raw: #d6a01f; } +/* ── Mode sombre — navy profond ── */ [data-theme="dark"] { + color-scheme: dark; + --color-bg-base-raw: #0e1422; - --color-bg-mesh-1-raw: #1a2440; - --color-bg-mesh-2-raw: #2a1f30; - --color-bg-mesh-3-raw: #14283a; + --color-bg-mesh-1-raw: #38352a; + --color-bg-mesh-2-raw: #332d26; + --color-bg-mesh-3-raw: #2f3128; --glass-bg: rgba(20, 28, 48, 0.55); - --glass-bg-strong: rgba(20, 28, 48, 0.78); - --glass-border: rgba(255, 255, 255, 0.1); + --glass-bg-strong: rgba(20, 28, 48, 0.8); + --glass-border: rgba(255, 255, 255, 0.12); --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); --color-text-raw: #edf1f9; --color-text-soft-raw: #b6c0d6; --color-muted-raw: #7c8ba8; - --color-accent-soft-raw: rgba(122, 169, 255, 0.22); + --color-accent-soft-raw: rgba(234, 177, 46, 0.24); + + --color-success-raw: #5dc9a8; + --color-warning-raw: #ffb672; + --color-danger-raw: #ff8a95; + --color-green-raw: #7bc86c; + --color-tip-raw: #ffd66b; } @theme inline { @@ -91,19 +114,12 @@ --radius-xl: calc(var(--radius) + 4px); --font-sans: - var(--font-outfit), -apple-system, BlinkMacSystemFont, "Segoe UI", + var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-display: - var(--font-instrument-serif), "Fraunces", Georgia, serif; + --font-display: var(--font-fraunces), "Fraunces", Georgia, serif; --font-mono: - "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; -} - -:root { - color-scheme: light; -} -[data-theme="dark"] { - color-scheme: dark; + var(--font-jetbrains-mono), "JetBrains Mono", ui-monospace, SFMono-Regular, + Menlo, monospace; } html { @@ -127,7 +143,7 @@ body { color: var(--color-accent-fg-raw); } - +/* ── Surfaces verre ── */ .glass { background: var(--glass-bg); border: 1px solid var(--glass-border); @@ -155,6 +171,29 @@ body { border-radius: var(--r-lg); } +/* Pseudo-reflet linéaire — cards héro / vcard (design §5) */ +.glass-reflect { + position: relative; + isolation: isolate; +} +.glass-reflect::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient( + 135deg, + color-mix(in oklab, #ffffff 50%, transparent), + transparent 42% + ); + pointer-events: none; + z-index: 0; +} +.glass-reflect > * { + position: relative; + z-index: 1; +} + /* Focus ring — non-supprimable (a11y WCAG AA) */ :where(a, button, input, textarea, select, [tabindex]):focus-visible { outline: 2px solid var(--color-accent-raw); @@ -162,6 +201,7 @@ body { border-radius: var(--r-sm); } +/* ── Animations (design §6) ── */ @keyframes mesh-drift { 0%, 100% { @@ -175,23 +215,197 @@ body { } } +@keyframes gh-bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + .mesh-spot { will-change: transform; } +.gh-bob { + animation: gh-bob 7s ease-in-out infinite; +} +.gh-bob-1 { + animation: gh-bob 6s ease-in-out infinite 0.4s; +} +.gh-bob-2 { + animation: gh-bob 8s ease-in-out infinite 0.8s; +} + .font-display { font-family: var(--font-display); - font-weight: 400; + font-weight: 500; letter-spacing: -0.01em; - line-height: 1.02; + line-height: 1.08; } .font-mono { font-family: var(--font-mono); } +/* ============================================================ + Landing FX — effets visuels des pages publiques (home, auth). + Tous gated par prefers-reduced-motion (cf. bloc plus bas). + ============================================================ */ + +/* Entrée en cascade : opacité + montée + flou qui se résorbe. */ +@keyframes fx-rise { + from { + opacity: 0; + transform: translateY(24px); + filter: blur(8px); + } + to { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } +} +.fx-rise { + opacity: 0; + animation: fx-rise 0.95s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* CTA : halo accent pulsé. */ +@keyframes fx-cta-glow { + 0%, + 100% { + box-shadow: + 0 8px 28px -6px color-mix(in oklab, var(--color-accent) 55%, transparent); + } + 50% { + box-shadow: + 0 16px 46px -4px color-mix(in oklab, var(--color-accent) 82%, transparent); + } +} +.fx-cta-glow { + animation: fx-cta-glow 3.2s ease-in-out infinite; +} + +/* Balayage de lumière au survol des surfaces verre. */ +.fx-sheen { + position: relative; + overflow: hidden; +} +.fx-sheen::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 115deg, + transparent 30%, + color-mix(in oklab, #fff 55%, transparent) 50%, + transparent 70% + ); + transform: translateX(-130%); + transition: transform 0.75s ease; + pointer-events: none; +} +.fx-sheen:hover::after { + transform: translateX(130%); +} + +/* Aurora — faisceau conique en rotation lente. */ +@keyframes fx-aurora { + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} +.fx-aurora { + animation: fx-aurora 30s linear infinite; + will-change: transform; +} + +/* Orbes flottants. */ +@keyframes fx-orb { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1); + } + 50% { + transform: translate3d(0, -26px, 0) scale(1.08); + } +} +.fx-orb { + animation: fx-orb 9s ease-in-out infinite; + will-change: transform; +} + +/* Particules scintillantes. */ +@keyframes fx-twinkle { + 0%, + 100% { + opacity: 0.12; + transform: scale(0.6); + } + 50% { + opacity: 0.85; + transform: scale(1.2); + } +} +.fx-particle { + animation: fx-twinkle 4.5s ease-in-out infinite; + will-change: opacity, transform; +} + +/* Apparition « pop » élastique — coche de validation live. */ +@keyframes fx-pop { + 0% { + opacity: 0; + transform: scale(0.2) rotate(-22deg); + } + 60% { + opacity: 1; + transform: scale(1.18) rotate(7deg); + } + 100% { + opacity: 1; + transform: scale(1) rotate(0); + } +} +.fx-pop { + animation: fx-pop 0.42s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +/* Reflet qui circule dans la piste « glisser pour confirmer ». */ +@keyframes fx-track-flow { + to { + background-position: 200% center; + } +} +.fx-track-flow { + background-image: linear-gradient( + 100deg, + color-mix(in oklab, var(--color-accent) 70%, transparent) 0%, + color-mix(in oklab, var(--color-accent) 100%, white 12%) 50%, + color-mix(in oklab, var(--color-accent) 70%, transparent) 100% + ); + background-size: 200% auto; + animation: fx-track-flow 1.6s linear infinite; +} + @media (prefers-reduced-motion: reduce) { - .mesh-spot { + .mesh-spot, + .gh-bob, + .gh-bob-1, + .gh-bob-2, + .fx-aurora, + .fx-orb, + .fx-particle, + .fx-cta-glow, + .fx-track-flow { + animation: none !important; + } + .fx-rise, + .fx-pop { + opacity: 1; animation: none !important; } html { @@ -209,4 +423,7 @@ body { border-color: var(--color-text-raw); box-shadow: none; } + .glass-reflect::before { + display: none; + } } diff --git a/apps/frontend/app/layout.tsx b/apps/frontend/app/layout.tsx index dbd23e1..318750b 100644 --- a/apps/frontend/app/layout.tsx +++ b/apps/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; -import { Outfit, Instrument_Serif } from "next/font/google"; +import { Fraunces, Inter, JetBrains_Mono } from "next/font/google"; +import { cookies } from "next/headers"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; @@ -7,26 +8,36 @@ import { getMe } from "@/app/actions/auth"; import { getInstanceBranding } from "@/app/actions/instance"; import { AuthProvider } from "@/components/auth-provider"; import { BrandingProvider } from "@/components/branding-provider"; +import { ThemeProvider } from "@/components/theme-provider"; +import { ThemeScript } from "@/components/theme-script"; import { MeshBackground } from "@/components/ui/mesh-background"; import { SITE_URL } from "@/lib/site"; +import { DEFAULT_THEME, isTheme, resolveTheme, THEME_COOKIE } from "@/lib/theme"; import "./globals.css"; -const outfit = Outfit({ - variable: "--font-outfit", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], - weight: ["300", "400", "500", "600", "700", "800", "900"], + weight: ["400", "500", "600", "700"], display: "swap", }); -const instrumentSerif = Instrument_Serif({ - variable: "--font-instrument-serif", +const fraunces = Fraunces({ + variable: "--font-fraunces", subsets: ["latin"], - weight: ["400"], + weight: ["400", "500", "600"], style: ["normal", "italic"], display: "swap", }); +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains-mono", + subsets: ["latin"], + weight: ["400", "500", "700"], + display: "swap", +}); + export const metadata: Metadata = { metadataBase: new URL(SITE_URL), title: { @@ -102,17 +113,23 @@ const jsonLd = { export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { - const [locale, messages, branding, me] = await Promise.all([ + const [locale, messages, branding, me, cookieStore] = await Promise.all([ getLocale(), getMessages(), getInstanceBranding(), getMe(), + cookies(), ]); + const themeCookie = cookieStore.get(THEME_COOKIE)?.value; + const theme = isTheme(themeCookie) ? themeCookie : DEFAULT_THEME; + const resolvedTheme = resolveTheme(theme); + return ( +