diff --git a/CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md b/CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md
deleted file mode 100644
index 2b5c2ec..0000000
--- a/CORRECTIFS_APERCUS_SESSIONS_QUOTAS.md
+++ /dev/null
@@ -1,42 +0,0 @@
-# GoodDrive — Correctifs aperçus, sessions et quotas
-
-## Aperçus
-
-- Les appels `/api` passent par le proxy Vite, du port frontend `1028` vers le service `backend:1029` ; l'URL signée pour l'aperçu reste sur le **même domaine**.
-- L'API vérifie les permissions lors de la création du lien **et** de chaque demande de lecture, y compris les plages `Range` des vidéos.
-- PDF et texte peuvent être affichés dans une iframe **de même origine** ; les autres pages restent protégées des intégrations externes.
-- Les liens d'aperçu expirent après deux heures et ne dispensent jamais de la vérification des permissions. Une URL déjà ouverte pourra expirer pendant une vidéo très longue : rouvrir l'aperçu pour régénérer son lien.
-- Une vérification HEAD signale un aperçu inaccessible au lieu d'afficher une page vide. Formats visés : images, PDF, audio, vidéo et textes courants ; les codecs vidéo dépendent du navigateur.
-
-## Connexion
-
-- `api` et renouvellement JWT utilisent la même origine `/api`.
-- Le renouvellement est partagé entre requêtes concurrentes (une seule requête en cours).
-- On déconnecte uniquement lorsque le serveur rejette **explicitement** le refresh token. Un problème réseau ou un backend indisponible conserve la session et permet de réessayer.
-
-## Navigation et gestion des espaces
-
-- Suppression de la rubrique « Espaces & invitations » de la navigation.
-- Clic droit sur l'espace (bureau) ou `…` (mobile) : paramètres, accès et invitations via fenêtres dédiées.
-- Le bouton `+` à côté d'« Espaces partagés » reste disponible pour créer un espace enfant.
-
-## Quotas imbriqués
-
-- Le calcul distingue **occupation réelle** (fichiers actifs, corbeille et uploads réservés) et **quotas attribués** aux sous-espaces.
-- Chaque espace enfant avec quota réserve cette capacité au sein du quota de son parent ; les réservations ne sont jamais additionnées deux fois.
-- Une limite est refusée si elle dépasse la capacité non attribuée restante du parent, ou si elle devient inférieure aux engagements des descendants.
-- Un espace sans quota propre affiche son occupation et la capacité restante héritée de ses ancêtres.
-- Les créations d'espaces, modifications de quotas et uploads utilisent des verrous transactionnels sur les ancêtres ; les paramètres de quota restent stockés dans les champs déjà présents, donc **aucune migration**.
-- Les invitations non utilisées ne constituent pas des espaces physiques réservés. Un quota est revérifié lors de l'acceptation, au cas où la capacité disponible aurait changé.
-
-## Déploiement
-
-Remplacer les fichiers de l'archive puis reconstruire :
-
-```bash
-docker compose up -d --build frontend backend
-```
-
-Après redémarrage, tester en conditions réelles : un aperçu PDF, une image, une vidéo MP4 compatible navigateur, le rafraîchissement du jeton après 30 minutes, et un parent de 10 Go avec deux sous-espaces de 7 et 4 Go (le second doit être refusé).
-
-Tests locaux effectués : analyse syntaxique JS/JSX, compilation syntaxique Python, tests Node frontend. Les tests Django avec base de données et le build Vite complet requièrent les dépendances non présentes dans cet environnement.
diff --git a/backend/dockerfile b/backend/dockerfile
index 0a056ef..5f1fe37 100644
--- a/backend/dockerfile
+++ b/backend/dockerfile
@@ -14,4 +14,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/
-CMD ["python", "manage.py", "runserver", "0.0.0.0:1029"]
\ No newline at end of file
+CMD ["sh", "/app/entrypoint.sh"]
\ No newline at end of file
diff --git a/backend/drive/management/commands/ensure_admin.py b/backend/drive/management/commands/ensure_admin.py
new file mode 100644
index 0000000..81df509
--- /dev/null
+++ b/backend/drive/management/commands/ensure_admin.py
@@ -0,0 +1,45 @@
+"""Create the initial superuser from environment, without ever updating another."""
+from django.db.models import Q
+from decouple import config
+from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand
+from django.db import IntegrityError, transaction
+
+
+class Command(BaseCommand):
+ help = 'Crée le premier administrateur depuis ADMIN_* si aucun administrateur n’existe.'
+
+ def handle(self, *args, **options):
+ User = get_user_model()
+ if User.objects.filter(Q(is_superuser=True) | Q(is_staff=True)).exists():
+ self.stdout.write('Administrateur déjà présent : aucune modification.')
+ return
+
+ username = config('ADMIN_USERNAME', default='').strip()
+ password = config('ADMIN_PASSWORD', default='')
+ first_name = config('ADMIN_FIRST_NAME', default='').strip()
+ last_name = config('ADMIN_LAST_NAME', default='').strip()
+ email = config('ADMIN_EMAIL', default='').strip()
+ if not username or not password:
+ self.stdout.write(self.style.WARNING('ADMIN_USERNAME ou ADMIN_PASSWORD absent : création ignorée.'))
+ return
+
+ try:
+ with transaction.atomic():
+ if User.objects.select_for_update().filter(Q(is_superuser=True) | Q(is_staff=True)).exists():
+ return
+ if User.objects.filter(username=username).exists():
+ self.stdout.write(self.style.WARNING(
+ 'Identifiant déjà utilisé : compte existant inchangé, création ignorée.'))
+ return
+ User.objects.create_superuser(
+ username=username, password=password, email=email,
+ first_name=first_name, last_name=last_name,
+ )
+ except IntegrityError:
+ # Another container could have initialized the superuser in parallel.
+ if User.objects.filter(Q(is_superuser=True) | Q(is_staff=True)).exists():
+ self.stdout.write('Administrateur déjà initialisé par un autre processus.')
+ return
+ raise
+ self.stdout.write(self.style.SUCCESS('Administrateur initial créé.'))
diff --git a/backend/drive/tests.py b/backend/drive/tests.py
index db185bb..8456078 100644
--- a/backend/drive/tests.py
+++ b/backend/drive/tests.py
@@ -526,3 +526,99 @@ class InterruptedUploadTests(HierarchicalDriveTests):
result=self.client.patch(f'/api/drive/spaces/{self.root}/quota/',
{'quota_bytes':999},format='json')
self.assertEqual(result.status_code,400,result.data)
+
+
+class SharedSpaceDeletionTests(TestCase):
+ """Deleting managed spaces may never erase an attached user's data."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ override = override_settings(DRIVE_STORAGE_ROOT=Path(self.tmp.name),
+ FILE_UPLOAD_TEMP_DIR=str(Path(self.tmp.name) / 'tmp'))
+ override.enable()
+ self.addCleanup(override.disable)
+ self.admin = get_user_model().objects.create_superuser('admin', 'admin@example.test', 'Strong-pass123!')
+ self.client = APIClient()
+ self.client.force_authenticate(user=self.admin)
+ self.root = self.client.get('/api/auth/me/').data['space_id']
+
+ def create_space(self, name, parent=None):
+ reply = self.client.post('/api/drive/spaces/create/',
+ {'display_name': name, 'parent_space': parent or self.root}, format='json')
+ self.assertEqual(reply.status_code, 201, reply.data)
+ return reply.data['id']
+
+ def delete(self, space_id, confirmed):
+ return self.client.delete(f'/api/drive/spaces/{space_id}/',
+ {'confirm_name': confirmed}, format='json')
+
+ def test_deleting_managed_subtree_removes_files_and_uploads(self):
+ from .models import UploadSession
+ parent = self.create_space('Projet éphémère')
+ child = self.create_space('Sous-projet', parent)
+ uploaded = self.client.post('/api/drive/uploads/',
+ {'space': child, 'files': [SimpleUploadedFile('notes.txt', b'bonjour')]}, format='multipart')
+ self.assertEqual(uploaded.status_code, 201, uploaded.data)
+ item = DriveItem.objects.get(pk=uploaded.data['items'][0]['id'])
+ path = Path(self.tmp.name) / item.storage_location
+ self.assertTrue(path.exists())
+ UploadSession.objects.create(owner=self.admin, space_id=child,
+ name='inachevé.zip', total_size=1024)
+ parent_path = Path(self.tmp.name) / DriveSpace.objects.get(pk=parent).relative_path
+ with self.captureOnCommitCallbacks(execute=True):
+ result = self.delete(parent, 'Projet éphémère')
+ self.assertEqual(result.status_code, 200, result.data)
+ self.assertFalse(DriveSpace.objects.filter(pk__in=[parent, child]).exists())
+ self.assertFalse(DriveItem.objects.filter(pk=item.pk).exists())
+ self.assertFalse(parent_path.exists())
+
+ def test_refuses_wrong_confirmation_root_and_attached_user_space(self):
+ managed = self.create_space('Confidentiel')
+ self.assertEqual(self.delete(managed, 'autre').status_code, 400)
+ self.assertEqual(self.delete(self.root, 'Racine').status_code, 403)
+ from .spaces import ensure_private_space, create_space
+ assigned = create_space(get_user_model().objects.create_user('alice', password='Strong-pass123!'),
+ DriveSpace.objects.get(pk=managed), 'Alice')
+ ensure_private_space(assigned.user)
+ response = self.delete(managed, 'Confidentiel')
+ self.assertEqual(response.status_code, 409, response.data)
+ self.assertTrue(DriveSpace.objects.filter(pk=managed).exists())
+ self.assertTrue(DriveSpace.objects.filter(pk=assigned.pk).exists())
+
+ def test_non_manager_cannot_delete_managed_space(self):
+ managed = self.create_space('Team')
+ alice = get_user_model().objects.create_user('alice', password='Strong-pass123!')
+ self.client.force_authenticate(user=alice)
+ self.assertEqual(self.delete(managed, 'Team').status_code, 403)
+
+
+class BootstrapAdminCommandTests(TestCase):
+ def test_bootstraps_from_env_only_when_no_superuser_exists(self):
+ from unittest.mock import patch
+ from django.core.management import call_command
+ env = {'ADMIN_USERNAME': 'rootuser', 'ADMIN_PASSWORD': 'Safe-pass123!',
+ 'ADMIN_FIRST_NAME': 'Ada', 'ADMIN_LAST_NAME': 'Test',
+ 'ADMIN_EMAIL': 'root@example.test'}
+ with patch.dict('os.environ', env):
+ call_command('ensure_admin')
+ account = get_user_model().objects.get(username='rootuser')
+ self.assertTrue(account.is_superuser)
+ self.assertTrue(account.is_staff)
+ self.assertEqual(account.first_name, 'Ada')
+ self.assertTrue(account.check_password('Safe-pass123!'))
+ with patch.dict('os.environ', {'ADMIN_PASSWORD': 'Different-pass!'}):
+ call_command('ensure_admin')
+ account.refresh_from_db()
+ self.assertTrue(account.check_password('Safe-pass123!'))
+ self.assertEqual(get_user_model().objects.filter(is_superuser=True).count(), 1)
+
+ def test_existing_username_is_not_promoted_without_permission(self):
+ from unittest.mock import patch
+ from django.core.management import call_command
+ user = get_user_model().objects.create_user('rootuser', password='Current-pass123!')
+ with patch.dict('os.environ', {'ADMIN_USERNAME': 'rootuser', 'ADMIN_PASSWORD': 'Other-pass123!'}):
+ call_command('ensure_admin')
+ user.refresh_from_db()
+ self.assertFalse(user.is_superuser)
+ self.assertTrue(user.check_password('Current-pass123!'))
diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py
index e879318..807f623 100644
--- a/backend/drive/views_spaces.py
+++ b/backend/drive/views_spaces.py
@@ -1,5 +1,10 @@
"""Invitation-only registration and hierarchical space management."""
import secrets
+import logging
+import shutil
+from pathlib import Path
+from django.conf import settings
+from django.db.models import Q
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
@@ -10,7 +15,7 @@ from rest_framework.decorators import api_view, permission_classes, throttle_cla
from rest_framework.permissions import AllowAny
from rest_framework.throttling import AnonRateThrottle
from rest_framework.response import Response
-from .models import DriveSpace, SpaceInvitation, SpaceGrant
+from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession
from .services import error, valid_name
from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_manage_space
from .quotas import parse_quota, locked_quota_tree, assert_new_child_quota, assert_updated_quota
@@ -340,11 +345,13 @@ def create_shared_space(request):
return Response(space_info(space,request.user),status=201)
-@api_view(['PATCH'])
+@api_view(['PATCH','DELETE'])
def edit_shared_space(request,space_id):
try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if space.private_owner_id or not manager(request.user,space):return error('Modification interdite.',403)
+ if request.method == 'DELETE':
+ return delete_shared_space(request, space_id)
try:
with transaction.atomic():
if 'display_name' in request.data:
@@ -356,3 +363,79 @@ def edit_shared_space(request,space_id):
_update_quota(space,request.data['quota_bytes'])
except ValueError as exc:return error(str(exc))
return Response(space_info(space,request.user))
+
+
+_logger = logging.getLogger(__name__)
+
+
+def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids):
+ """Executed only AFTER commit: keep SQL changes atomic before touching disk."""
+ root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
+ folder = (root / relative_path).resolve()
+ if folder == root or not folder.is_relative_to(root):
+ _logger.error('Invalid space path for disk cleanup: %s', relative_path)
+ return
+ # Old files may predate the numbered storage directories.
+ for location in legacy_locations:
+ path = (root / location).resolve()
+ if path.is_relative_to(root) and not path.is_relative_to(folder):
+ try:
+ path.unlink(missing_ok=True)
+ except OSError:
+ _logger.exception('Unable to remove legacy file: %s', path)
+ try:
+ if folder.exists():
+ shutil.rmtree(folder)
+ except OSError:
+ _logger.exception('Unable to remove deleted space directory: %s', folder)
+ temporary_root = Path(settings.FILE_UPLOAD_TEMP_DIR).resolve()
+ for upload_id in temp_ids:
+ try:
+ (temporary_root / f'drive-{upload_id}.part').unlink(missing_ok=True)
+ except OSError:
+ _logger.exception('Unable to remove canceled upload %s', upload_id)
+
+
+def delete_shared_space(request, space_id):
+ """Permanently delete a manually managed space and its independent descendants.
+
+ Never delete root, assigned account spaces, or ANY private space, including a
+ private descendant. All cleanup is committed before disk deletion starts.
+ """
+ confirm_name = request.data.get('confirm_name')
+ if not isinstance(confirm_name, str):
+ return error('Confirme la suppression en saisissant le nom exact de l’espace.')
+ with transaction.atomic():
+ try:
+ space = DriveSpace.objects.select_for_update().select_related('parent').get(pk=space_id)
+ except DriveSpace.DoesNotExist:
+ return error('Espace introuvable.', 404)
+ if not space.parent_id or space.user_id or space.private_owner_id or not space.managed_by_id:
+ return error('Les espaces personnels, attribués et racine ne peuvent pas être supprimés.', 403)
+ if not manager(request.user, space):
+ return error('Suppression interdite.', 403)
+ if confirm_name != space.display_name:
+ return error('Le nom de confirmation ne correspond pas au nom de l’espace.')
+ # Also lock the parent: creation and deletion of siblings are serialized.
+ list(DriveSpace.objects.select_for_update().filter(pk=space.parent_id))
+ descendants = list(DriveSpace.objects.select_for_update().filter(
+ Q(relative_path=space.relative_path) |
+ Q(relative_path__startswith=space.relative_path + '/')
+ ).order_by('-relative_path'))
+ if any(child.user_id or child.private_owner_id or not child.managed_by_id for child in descendants):
+ return error('Cet espace contient un espace attribué ou privé. Réorganise les sous-utilisateurs avant de le supprimer.', 409)
+ ids = [child.pk for child in descendants]
+ files = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list(
+ 'storage_location', 'storage_key'))
+ legacy_locations = [location or f'{key[:2]}/{key}'
+ for location, key in files if key and not location]
+ upload_ids = list(UploadSession.objects.select_for_update().filter(
+ space_id__in=ids).values_list('pk', flat=True))
+ # Children of DriveItem use CASCADE; item ownership and other accounts
+ # are left intact, only metadata from these owned spaces is deleted.
+ DriveItem.objects.filter(space_id__in=ids).delete()
+ for child in descendants:
+ child.delete() # bottom-up, as DriveSpace.parent uses PROTECT
+ transaction.on_commit(lambda: _remove_deleted_space_files(
+ space.relative_path, legacy_locations, upload_ids))
+ return Response({'deleted_space_ids': ids})
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 63e8add..10a5eb4 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -89,6 +89,7 @@ export const drive={
spaces:()=>api.get('/drive/spaces/').then(r=>r.data),
createSpace:data=>api.post('/drive/spaces/create/',data).then(r=>r.data),
editSpace:(id,data)=>api.patch(`/drive/spaces/${id}/`,data).then(r=>r.data),
+ deleteSpace:(id,confirm_name)=>api.delete(`/drive/spaces/${id}/`,{data:{confirm_name}}).then(r=>r.data),
renameSpace:display_name=>api.patch('/drive/spaces/me/',{display_name}).then(r=>r.data),
searchUsers:(q,parent_space)=>api.get('/drive/spaces/users/search/',{params:{q,parent_space}}).then(r=>r.data.users),
addUser:(user_id,parent_space,can_write=false,can_invite=false)=>api.post('/drive/spaces/users/',{user_id,can_write,can_invite},{params:{parent_space}}).then(r=>r.data),
diff --git a/frontend/src/components/drive/ContextActions.jsx b/frontend/src/components/drive/ContextActions.jsx
index df6d125..c5aaa6b 100644
--- a/frontend/src/components/drive/ContextActions.jsx
+++ b/frontend/src/components/drive/ContextActions.jsx
@@ -22,6 +22,7 @@ export const fileOptions = (item, trash) => {
export const spaceOptions = (space) => space?.can_invite ? [
{id:'manage',title:'Paramètres de l’espace',icon:Settings2},
{id:'members',title:'Accès et invitations',icon:Users},
+ ...(space.has_account || space.is_private || !space.parent_id ? [] : [{id:'delete',title:'Supprimer l’espace',icon:Trash2,danger:true}]),
] : []
/** Keyboard-accessible viewport-clamped menu used on desktop right-click and mobile ···. */
diff --git a/frontend/src/components/drive/DriveDialogs.jsx b/frontend/src/components/drive/DriveDialogs.jsx
index e120947..5a0ba64 100644
--- a/frontend/src/components/drive/DriveDialogs.jsx
+++ b/frontend/src/components/drive/DriveDialogs.jsx
@@ -4,6 +4,7 @@ import DeleteDialog from './dialogs/DeleteDialog'
import PreviewDialog from './dialogs/PreviewDialog'
import SpaceSettingsDialog from './dialogs/SpaceSettingsDialog'
import MembersDialog from './dialogs/MembersDialog'
+import DeleteSpaceDialog from './dialogs/DeleteSpaceDialog'
/** Groups the Drive's existing dialogs without coupling them to the page markup. */
export default function DriveDialogs({ controller, user }) {
@@ -13,6 +14,7 @@ export default function DriveDialogs({ controller, user }) {
+
>
}
diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx
index 88fb3ba..2f902b2 100644
--- a/frontend/src/components/drive/DriveSidebar.jsx
+++ b/frontend/src/components/drive/DriveSidebar.jsx
@@ -23,6 +23,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
const space=spaceMenu?.space
if(!space)return
if(command==='manage')setSpaceDialog({mode:'manage',spaceId:space.id})
+ if(command==='delete')setSpaceDialog({mode:'delete',spaceId:space.id})
if(command==='members'){
setManagedSpaceId(space.id)
diff --git a/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx b/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx
new file mode 100644
index 0000000..fe0990a
--- /dev/null
+++ b/frontend/src/components/drive/dialogs/DeleteSpaceDialog.jsx
@@ -0,0 +1,72 @@
+import { useEffect, useState } from 'react'
+import { AlertTriangle, Trash2 } from 'lucide-react'
+import { drive, message } from '@/api/client'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+
+/** Destructive operation: never allow deletion without typing the virtual name. */
+export default function DeleteSpaceDialog({controller: c}) {
+ const {spaceDialog, setSpaceDialog, spaces, activeSpaceId, switchSpace, loadSpaces, setError, setNote} = c
+ const isOpen = spaceDialog?.mode === 'delete'
+ const space = spaces.find(s => s.id === spaceDialog?.spaceId)
+ const [confirmation, setConfirmation] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [localError, setLocalError] = useState('')
+ useEffect(() => {
+ setConfirmation('')
+ setLocalError('')
+ }, [spaceDialog?.spaceId, isOpen])
+
+ async function remove(event) {
+ event.preventDefault()
+ if (!space || confirmation !== space.display_name || busy) return
+ setBusy(true)
+ setLocalError('')
+ try {
+ const response = await drive.deleteSpace(space.id, confirmation)
+ const deleted = response.deleted_space_ids || []
+ if (deleted.includes(activeSpaceId)) {
+ const mine = spaces.find(s => s.is_private)
+ if (mine) switchSpace(mine.id)
+ }
+ setSpaceDialog(null)
+ await loadSpaces()
+ setNote('Espace supprimé définitivement.')
+ } catch (exception) {
+ const detail = message(exception)
+ setLocalError(detail)
+ setError(detail)
+ } finally { setBusy(false) }
+ }
+
+ return
+}
diff --git a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx
index a17d364..40e1e39 100644
--- a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx
+++ b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx
@@ -48,7 +48,7 @@ export default function SpaceSettingsDialog({controller: c}) {
setSpaceDialog(null)
}
- return