Compare commits

...

3 Commits

Author SHA1 Message Date
18cb9d5d80 refactor(infra) : reorganize docker config into infra/dev and infra/prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Align project structure with Lesstime: move docker/ to infra/dev/ and
deploy/ to infra/prod/. Update all references in docker-compose,
makefile, CI workflow, Dockerfile, .gitignore and .dockerignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:11:35 +02:00
gitea-actions
4ba134dd69 chore : bump version to v1.9.9
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 32s
Build Release Artefact / build (push) Failing after 1m27s
2026-04-01 14:18:42 +00:00
Matthieu
5e7a744151 feat : add maintenance mode toggle in admin panel
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
- Backend: MaintenanceModeListener blocks non-admin API requests when
  var/maintenance flag file exists. MaintenanceController provides
  toggle (PUT /api/admin/maintenance) and public check endpoint
  (GET /api/maintenance/check).
- Frontend: Toggle button in admin page, maintenance.vue page for
  blocked users, middleware redirects non-admins to /maintenance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:18:32 +02:00
29 changed files with 230 additions and 23 deletions

View File

@@ -2,10 +2,10 @@
.gitea
.env.local
.env.test
docker/
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example
infra/dev/
infra/prod/docker-compose.yml
infra/prod/deploy.sh
infra/prod/.env.example
frontend/node_modules
frontend/.nuxt
frontend/.output

View File

@@ -19,7 +19,7 @@ jobs:
- name: Build Docker image
run: |
docker build \
-f deploy/docker/Dockerfile.prod \
-f infra/prod/Dockerfile \
-t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/inventory:latest \
.

2
.gitignore vendored
View File

@@ -20,7 +20,7 @@
###< phpunit/phpunit ###
###> docker ###
docker/.env.docker.local
infra/dev/.env.docker.local
###< docker ###
###> migration archives ###
/_archives/

View File

@@ -57,7 +57,7 @@ make start
make install
```
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `infra/dev/.env.docker.local`.
### Que fait `make install` ?
@@ -254,7 +254,7 @@ Configuration PhpStorm / VSCode :
- **Port** : `8081`
- **Path mapping** : racine du projet → `/var/www/html`
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale.
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `infra/dev/.env.docker.local` avec votre IP locale.
## Git

View File

@@ -55,6 +55,7 @@ security:
- { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
- { path: ^/api/maintenance/check$, roles: PUBLIC_ACCESS }
- { path: ^/_mcp, roles: ROLE_USER }
- { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/contexts, roles: PUBLIC_ACCESS }

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.8'
app.version: '1.9.9'

View File

@@ -2,7 +2,7 @@ services:
web:
container_name: php-${DOCKER_APP_NAME}-apache
build:
context: ./docker/php
context: ./infra/dev
dockerfile: Dockerfile
args:
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
@@ -20,9 +20,9 @@ services:
- ~/.cache:/var/www/.cache # Pour la cache de composer
- ~/.config:/var/www/.config # Pour la config de yarn
- ~/.composer:/var/www/.composer # Pour la config de composer
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/vhost.conf:/etc/apache2/sites-available/000-default.conf
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
- ./infra/dev/vhost.conf:/etc/apache2/sites-available/000-default.conf
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG
- ./LOG/logs_apache:/var/log/apache2/
extra_hosts:

View File

@@ -0,0 +1,30 @@
import { ref } from 'vue'
import { useApi } from './useApi'
const maintenanceEnabled = ref(false)
export function useMaintenance() {
const { apiCall } = useApi()
const loading = ref(false)
const fetchStatus = async () => {
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance')
if (res.success && res.data) {
maintenanceEnabled.value = res.data.enabled
}
}
const toggle = async () => {
loading.value = true
try {
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance', { method: 'PUT' })
if (res.success && res.data) {
maintenanceEnabled.value = res.data.enabled
}
} finally {
loading.value = false
}
}
return { maintenanceEnabled, loading, fetchStatus, toggle }
}

View File

@@ -1,4 +1,4 @@
import { useProfileSession, usePermissions } from "#imports";
import { useProfileSession, usePermissions, useApi } from "#imports";
export default defineNuxtRouteMiddleware(async (to) => {
const { ensureSession, activeProfile } = useProfileSession();
@@ -12,9 +12,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
normalizedPath.startsWith("/profiles") ||
fullPath.startsWith("/profiles") ||
routeName.startsWith("profiles");
const isMaintenanceRoute = normalizedPath === "/maintenance";
// Redirect to login if no active profile
if (!activeProfile.value && !isProfilesRoute) {
if (!activeProfile.value && !isProfilesRoute && !isMaintenanceRoute) {
return navigateTo("/profiles");
}
@@ -29,5 +30,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
}
// Maintenance mode check for non-admin users
if (!isAdmin.value && !isMaintenanceRoute && !isProfilesRoute) {
const { apiCall } = useApi();
const res = await apiCall<{ enabled: boolean }>('/maintenance/check');
if (res.success && res.data?.enabled) {
return navigateTo("/maintenance");
}
}
}
});

View File

@@ -1,5 +1,28 @@
<template>
<div class="container mx-auto p-6 max-w-6xl">
<!-- Maintenance Mode -->
<div class="alert mb-6" :class="maintenanceEnabled ? 'alert-warning' : 'alert-info'">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span class="font-medium">Mode maintenance</span>
<span v-if="maintenanceEnabled" class="badge badge-warning badge-sm">Actif</span>
<span v-else class="badge badge-ghost badge-sm">Inactif</span>
</div>
<button
class="btn btn-sm"
:class="maintenanceEnabled ? 'btn-ghost' : 'btn-warning'"
:disabled="maintenanceLoading"
@click="handleToggleMaintenance"
>
<span v-if="maintenanceLoading" class="loading loading-spinner loading-xs" />
{{ maintenanceEnabled ? 'Désactiver' : 'Activer' }}
</button>
</div>
<p class="text-sm opacity-70 mt-1">
{{ maintenanceEnabled ? 'Seuls les administrateurs peuvent accéder à l\'application.' : 'L\'application est accessible à tous les utilisateurs.' }}
</p>
</div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">
Administration des profils
@@ -153,9 +176,14 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useAdminProfiles } from '#imports'
import { useAdminProfiles, useMaintenance } from '#imports'
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
const { maintenanceEnabled, loading: maintenanceLoading, fetchStatus: fetchMaintenanceStatus, toggle: toggleMaintenance } = useMaintenance()
const handleToggleMaintenance = async () => {
await toggleMaintenance()
}
const loaded = ref(false)
const isLoading = computed(() => loading.value || !loaded.value)
@@ -264,7 +292,7 @@ const handleDeactivate = async (profileId) => {
}
onMounted(async () => {
await fetchAll()
await Promise.all([fetchAll(), fetchMaintenanceStatus()])
loaded.value = true
})
</script>

View File

@@ -0,0 +1,21 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-base-200">
<div class="text-center max-w-md">
<h1 class="text-4xl font-bold mb-4">
Maintenance
</h1>
<p class="text-lg text-base-content/70 mb-6">
L'application est actuellement en maintenance. Veuillez réessayer ultérieurement.
</p>
<button class="btn btn-primary" @click="retry">
Réessayer
</button>
</div>
</div>
</template>
<script setup>
const retry = () => {
navigateTo('/')
}
</script>

View File

@@ -61,8 +61,8 @@ RUN ln -sf /dev/stdout /var/log/nginx/access.log \
RUN rm -f /etc/nginx/sites-enabled/default
# Configs
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/inventory.conf
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/inventory.conf
# Backend from stage 1
COPY --from=backend-build /app /var/www/html

View File

@@ -1,6 +1,6 @@
# Permet d'utiliser un .env.docker.local pour override
ENV_DEFAULT = docker/.env.docker
ENV_LOCAL = docker/.env.docker.local
ENV_DEFAULT = infra/dev/.env.docker
ENV_LOCAL = infra/dev/.env.docker.local
ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
# Permet d'avoir les variables du fichier .env.docker.local
@@ -25,13 +25,13 @@ DATA_SQL_NORM ?= data_norm.sql
#========================================================================================
env-init:
@mkdir -p docker
@mkdir -p infra/dev
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
# Lance le container
start: env-init
@echo "**** START CONTAINERS ****"
@cp --update=none docker/.env.docker docker/.env.docker.local
@cp --update=none infra/dev/.env.docker infra/dev/.env.docker.local
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
@echo ""
@echo "URLs disponibles:"

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
final class MaintenanceController extends AbstractController
{
public function __construct(
private readonly KernelInterface $kernel,
) {}
#[Route('/api/maintenance/check', name: 'maintenance_check', methods: ['GET'])]
public function check(): JsonResponse
{
return new JsonResponse([
'enabled' => file_exists($this->flagPath()),
]);
}
#[Route('/api/admin/maintenance', name: 'admin_maintenance_status', methods: ['GET'])]
public function status(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
return new JsonResponse([
'enabled' => file_exists($this->flagPath()),
]);
}
#[Route('/api/admin/maintenance', name: 'admin_maintenance_toggle', methods: ['PUT'])]
public function toggle(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$path = $this->flagPath();
if (file_exists($path)) {
unlink($path);
$enabled = false;
} else {
file_put_contents($path, (string) time());
$enabled = true;
}
return new JsonResponse(['enabled' => $enabled]);
}
private function flagPath(): string
{
return $this->kernel->getProjectDir() . '/var/maintenance';
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
#[AsEventListener(event: 'kernel.request', priority: 10)]
final class MaintenanceModeListener
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly TokenStorageInterface $tokenStorage,
) {}
public function __invoke(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$flagFile = $this->kernel->getProjectDir() . '/var/maintenance';
if (!file_exists($flagFile)) {
return;
}
$request = $event->getRequest();
$path = $request->getPathInfo();
// Always allow maintenance status endpoint and session endpoints
if (str_starts_with($path, '/api/admin/maintenance')
|| str_starts_with($path, '/api/maintenance/check')
|| str_starts_with($path, '/api/session')
|| str_starts_with($path, '/api/health')
|| str_starts_with($path, '/api/docs')
) {
return;
}
// Allow admin users through
$token = $this->tokenStorage->getToken();
if ($token && $token->getUser()) {
$roles = $token->getRoleNames();
if (in_array('ROLE_ADMIN', $roles, true)) {
return;
}
}
$event->setResponse(new JsonResponse(
['message' => 'Application en maintenance. Veuillez réessayer ultérieurement.'],
JsonResponse::HTTP_SERVICE_UNAVAILABLE,
));
}
}