Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ba134dd69 | ||
|
|
5e7a744151 |
@@ -55,6 +55,7 @@ security:
|
|||||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/maintenance/check$, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/_mcp, roles: ROLE_USER }
|
- { path: ^/_mcp, roles: ROLE_USER }
|
||||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '1.9.8'
|
app.version: '1.9.9'
|
||||||
|
|||||||
30
frontend/app/composables/useMaintenance.ts
Normal file
30
frontend/app/composables/useMaintenance.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useProfileSession, usePermissions } from "#imports";
|
import { useProfileSession, usePermissions, useApi } from "#imports";
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
const { ensureSession, activeProfile } = useProfileSession();
|
const { ensureSession, activeProfile } = useProfileSession();
|
||||||
@@ -12,9 +12,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
normalizedPath.startsWith("/profiles") ||
|
normalizedPath.startsWith("/profiles") ||
|
||||||
fullPath.startsWith("/profiles") ||
|
fullPath.startsWith("/profiles") ||
|
||||||
routeName.startsWith("profiles");
|
routeName.startsWith("profiles");
|
||||||
|
const isMaintenanceRoute = normalizedPath === "/maintenance";
|
||||||
|
|
||||||
// Redirect to login if no active profile
|
// Redirect to login if no active profile
|
||||||
if (!activeProfile.value && !isProfilesRoute) {
|
if (!activeProfile.value && !isProfilesRoute && !isMaintenanceRoute) {
|
||||||
return navigateTo("/profiles");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto p-6 max-w-6xl">
|
<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">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold">
|
<h1 class="text-2xl font-bold">
|
||||||
Administration des profils
|
Administration des profils
|
||||||
@@ -153,9 +176,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import DataTable from '~/components/common/DataTable.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 { 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 loaded = ref(false)
|
||||||
const isLoading = computed(() => loading.value || !loaded.value)
|
const isLoading = computed(() => loading.value || !loaded.value)
|
||||||
@@ -264,7 +292,7 @@ const handleDeactivate = async (profileId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchAll()
|
await Promise.all([fetchAll(), fetchMaintenanceStatus()])
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
21
frontend/app/pages/maintenance.vue
Normal file
21
frontend/app/pages/maintenance.vue
Normal 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>
|
||||||
58
src/Controller/MaintenanceController.php
Normal file
58
src/Controller/MaintenanceController.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/EventListener/MaintenanceModeListener.php
Normal file
60
src/EventListener/MaintenanceModeListener.php
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user