This commit is contained in:
Louis Mazin 2026-10-14 16:54:05 +02:00
parent 7da4c7cd67
commit 8ec0397288
8 changed files with 281 additions and 40 deletions

View File

@ -125,6 +125,6 @@ class PublicItemLink(models.Model):
class ArchivedDriveAccount(models.Model): class ArchivedDriveAccount(models.Model):
"""Account removed from the app while its immutable private space survives.""" """Inactive, anonymized historical owner of shared files after account removal."""
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_archive') user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_archive')
deleted_at = models.DateTimeField(auto_now_add=True) deleted_at = models.DateTimeField(auto_now_add=True)

View File

@ -109,6 +109,17 @@ class SharesTests(TestCase):
self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/public-links/{links[0]["id"]}/').status_code,204) self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/public-links/{links[0]["id"]}/').status_code,204)
self.assertEqual(anonymous.get(f'/api/public/{token}/').status_code,404) self.assertEqual(anonymous.get(f'/api/public/{token}/').status_code,404)
# A directly shared file must download without either a login or JWT.
direct=self.client.post(f'/api/drive/items/{image.pk}/public-links/',{},format='json')
self.assertEqual(direct.status_code,201,direct.data)
direct_token=direct.data['token']
direct_preview=anonymous.get(f'/api/public/{direct_token}/')
self.assertEqual(direct_preview.status_code,200,direct_preview.data)
self.assertFalse(direct_preview.data['root']['is_folder'])
direct_file=anonymous.get(f'/api/public/{direct_token}/files/{image.pk}/')
self.assertEqual(direct_file.status_code,200)
self.assertEqual(b''.join(direct_file.streaming_content),b'PNG')
def test_admin_user_list_is_private_to_admin(self): def test_admin_user_list_is_private_to_admin(self):
alice=self.invite_user('alice-admin-list') alice=self.invite_user('alice-admin-list')
self.client.force_authenticate(user=alice) self.client.force_authenticate(user=alice)

View File

@ -96,12 +96,18 @@ class ProtectedDeletionTests(TestCase):
self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists()) self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists())
self.assertTrue((self.storage/'privates').is_dir()) self.assertTrue((self.storage/'privates').is_dir())
def test_user_deletion_anonymizes_account_but_keeps_private(self): def test_user_deletion_removes_private_directory_and_keeps_shared_files(self):
item, path = self.private_file() item, path = self.private_file()
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/', self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/',
{'confirm_username':self.admin.username}, format='json').status_code, 403) {'confirm_username':self.admin.username}, format='json').status_code, 403)
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':'wrong'}, format='json').status_code, 400) {'confirm_username':'wrong'}, format='json').status_code, 400)
shared = DriveItem.objects.create(owner=self.alice, space=self.root,
name='communal.txt', storage_key=str(uuid.uuid4()),
storage_location='communal.txt', size=4)
shared_path = self.storage / 'communal.txt'
shared_path.write_bytes(b'team')
with self.captureOnCommitCallbacks(execute=True):
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':self.alice.username}, format='json').status_code, 204) {'confirm_username':self.alice.username}, format='json').status_code, 204)
self.alice.refresh_from_db() self.alice.refresh_from_db()
@ -109,9 +115,12 @@ class ProtectedDeletionTests(TestCase):
self.assertFalse(self.alice.has_usable_password()) self.assertFalse(self.alice.has_usable_password())
self.assertTrue(ArchivedDriveAccount.objects.filter(user=self.alice).exists()) self.assertTrue(ArchivedDriveAccount.objects.filter(user=self.alice).exists())
self.assertNotIn(self.alice.pk, [u['id'] for u in self.client.get('/api/drive/admin/users/').data['users']]) self.assertNotIn(self.alice.pk, [u['id'] for u in self.client.get('/api/drive/admin/users/').data['users']])
self.assertTrue(path.exists()) self.assertFalse(path.exists())
self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk, private_owner=self.alice).exists()) self.assertFalse((self.storage / self.private.relative_path).exists())
self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists()) self.assertFalse(DriveSpace.objects.filter(pk=self.private.pk).exists())
self.assertFalse(DriveItem.objects.filter(pk=item.pk).exists())
self.assertTrue(shared_path.exists())
self.assertTrue(DriveItem.objects.filter(pk=shared.pk).exists())
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/', self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':self.alice.username}, format='json').status_code, 404) {'confirm_username':self.alice.username}, format='json').status_code, 404)
@ -119,3 +128,41 @@ class ProtectedDeletionTests(TestCase):
self.client.force_authenticate(user=self.alice) self.client.force_authenticate(user=self.alice)
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/', self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/',
{'confirm_username':self.admin.username}, format='json').status_code, 403) {'confirm_username':self.admin.username}, format='json').status_code, 403)
def test_admin_can_revoke_space_grants_without_removing_private_storage(self):
from .models import SpaceGrant
# Access granted explicitly by invitation/existing-account grant.
grant = SpaceGrant.objects.create(user=self.alice, space=self.root,
can_write=True, can_invite=True)
rsp = self.client.get(f'/api/drive/admin/users/{self.alice.pk}/access/')
self.assertEqual(rsp.status_code, 200, rsp.data)
self.assertTrue(any(a['kind'] == 'grant' and a['space_id'] == self.root.pk
for a in rsp.data['accesses']))
self.assertNotIn(self.private.pk, [a['space_id'] for a in rsp.data['accesses']])
rsp = self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/access/grant/{self.root.pk}/')
self.assertEqual(rsp.status_code, 204)
self.assertFalse(SpaceGrant.objects.filter(pk=grant.pk).exists())
self.assertTrue(DriveSpace.objects.filter(pk=self.root.pk).exists())
self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk).exists())
# Older accounts are linked directly to their assigned shared space.
rsp = self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/access/owner/{self.assigned.pk}/')
self.assertEqual(rsp.status_code, 204)
self.assigned.refresh_from_db()
self.assertIsNone(self.assigned.user_id)
self.assertIsNone(ensure_space(self.alice))
self.client.force_authenticate(user=self.alice)
self.assertEqual(self.client.get('/api/drive/items/', {'space':self.root.pk}).status_code,403)
self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code,200)
self.assertEqual(self.client.get(f'/api/drive/admin/users/{self.alice.pk}/access/').status_code,403)
def test_account_delete_rejects_private_folder_outside_privates(self):
self.private.relative_path = f'{self.assigned.relative_path}/legacy'
self.private.save(update_fields=['relative_path'])
original = self.storage / self.private.relative_path
original.mkdir(parents=True, exist_ok=True)
rsp=self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':self.alice.username},format='json')
self.assertEqual(rsp.status_code,409)
self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk).exists())
self.assertTrue(original.exists())

View File

@ -5,6 +5,8 @@ urlpatterns=[
path('spaces/',views_spaces.spaces), path('spaces/',views_spaces.spaces),
path('admin/users/',views_spaces.admin_users), path('admin/users/',views_spaces.admin_users),
path('admin/users/<int:user_id>/',views_spaces.admin_delete_user), path('admin/users/<int:user_id>/',views_spaces.admin_delete_user),
path('admin/users/<int:user_id>/access/',views_spaces.admin_user_access),
path('admin/users/<int:user_id>/access/<str:kind>/<int:space_id>/',views_spaces.admin_revoke_user_access),
path('shares/users/search/',views_shares.search_share_users), path('shares/users/search/',views_shares.search_share_users),
path('items/<uuid:item_id>/shares/',views_shares.manage_item_shares), path('items/<uuid:item_id>/shares/',views_shares.manage_item_shares),
path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share), path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share),

View File

@ -548,16 +548,104 @@ def admin_users(request):
return Response({'users':result[:200], 'limited':len(result)>200}) return Response({'users':result[:200], 'limited':len(result)>200})
@api_view(['GET'])
def admin_user_access(request, user_id):
"""Show only shared-space access, never a user's private storage."""
if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403)
User = get_user_model()
try:
target = User.objects.get(pk=user_id, drive_archive__isnull=True)
except User.DoesNotExist:
return error('Utilisateur introuvable.', 404)
if is_admin(target):
return error('Impossible de modifier les accès d’un administrateur.', 403)
accesses = []
for grant in SpaceGrant.objects.filter(user=target, space__private_owner__isnull=True).select_related('space'):
accesses.append({'kind':'grant', 'space_id':grant.space_id, 'space_name':grant.space.display_name})
for space in DriveSpace.objects.filter(user=target, private_owner__isnull=True):
accesses.append({'kind':'owner', 'space_id':space.pk, 'space_name':space.display_name})
for space in DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True):
accesses.append({'kind':'manager', 'space_id':space.pk, 'space_name':space.display_name})
return Response({'accesses': accesses})
@api_view(['DELETE'])
def admin_revoke_user_access(request, user_id, kind, space_id):
"""Revoke a space membership, legacy assignment, or managed-space grant.
Never delete the shared space itself, its contents, or the user's private
space. Other independent grants remain intact.
"""
if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403)
if kind not in ('grant', 'owner', 'manager'):
return error('Type d’accès inconnu.', 404)
User = get_user_model()
with transaction.atomic():
try:
target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True)
except User.DoesNotExist:
return error('Utilisateur introuvable.', 404)
if is_admin(target):
return error('Impossible de modifier les accès d’un administrateur.', 403)
qs = DriveSpace.objects.select_for_update().filter(pk=space_id, private_owner__isnull=True)
if kind == 'grant':
deleted, _ = SpaceGrant.objects.filter(space__in=qs, user=target).delete()
if not deleted:return error('Accès introuvable.', 404)
elif kind == 'owner':
if not qs.filter(user=target).update(user=None):
return error('Accès introuvable.', 404)
else:
if not qs.filter(managed_by=target).update(managed_by=None):
return error('Accès introuvable.', 404)
return Response(status=204)
def _delete_staged_private(directory):
"""Physically remove a private directory after the SQL transaction commits."""
try:
if directory is not None and directory.exists():
shutil.rmtree(directory)
except OSError:
_logger.exception('Échec de suppression du dossier privé %s', directory)
def _delete_legacy_private_files(locations):
"""Remove only legacy UUID-key files stored directly at storage/ab/uuid."""
import uuid
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
for location in locations:
try:
parts = Path(location).parts
if len(parts) != 2 or len(parts[0]) != 2 or not all(c in '0123456789abcdef' for c in parts[0]):
continue
if str(uuid.UUID(parts[1])) != parts[1] or parts[0] != parts[1][:2]:
continue
# Never remove a legacy file still referenced by a shared item.
if DriveItem.objects.filter(storage_key=parts[1],storage_location='').exists():
continue
path = root.joinpath(*parts)
if path.is_symlink() or not path.resolve().is_relative_to(root):
continue
path.unlink(missing_ok=True)
except (ValueError, OSError):
_logger.exception('Impossible de supprimer le fichier privé historique : %s', location)
@api_view(['DELETE']) @api_view(['DELETE'])
def admin_delete_user(request, user_id): def admin_delete_user(request, user_id):
"""Remove account access without deleting the non-deletable private space. """Deactivate an account and PERMANENTLY remove its private files and space.
The user row remains as an anonymized, inactive data owner so PROTECT Historical shared files keep the archived owner row because their owner
relations on private files and the private space never cascade away. FK is PROTECT; they are not removed by account deletion.
""" """
if not is_admin(request.user): if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403) return error('Accès réservé aux administrateurs.', 403)
User = get_user_model() User = get_user_model()
staged = None
original = None
try:
with transaction.atomic(): with transaction.atomic():
try: try:
target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True) target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True)
@ -567,16 +655,45 @@ def admin_delete_user(request, user_id):
return error('Impossible de supprimer un administrateur.', 403) return error('Impossible de supprimer un administrateur.', 403)
if request.data.get('confirm_username') != target.username: if request.data.get('confirm_username') != target.username:
return error('Saisis le nom d’utilisateur pour confirmer.') return error('Saisis le nom d’utilisateur pour confirmer.')
private = DriveSpace.objects.select_for_update().filter(private_owner=target).first()
legacy = []
if private:
# Never risk deleting an old shared directory when a private
# space has not yet been migrated to storage/privates/.
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
private_root = root / 'privates'
original = root / private.relative_path
if (not private.relative_path.startswith('privates/') or
original.parent != private_root or original.is_symlink() or
private_root.is_symlink() or not original.resolve().is_relative_to(private_root.resolve())):
return error('Déplace d’abord le stockage privé vers storage/privates.', 409)
if DriveSpace.objects.filter(parent=private).exists():
return error('Cet espace privé possède encore des sous-espaces.', 409)
# Legacy /ab/UUID paths are outside the numbered private folder.
for loc, key in DriveItem.objects.filter(space=private, is_folder=False).values_list('storage_location','storage_key'):
if key and not loc:
legacy.append(f'{key[:2]}/{key}')
if original.exists():
# Atomic rename to a location that is never browsable by
# another account. Roll back the rename on DB failure.
staged = private_root / f'.deleting-{private.pk}-{secrets.token_hex(12)}'
original.rename(staged)
# Deleting DriveItems removes their grants and public links.
DriveItem.objects.filter(space=private).delete()
UploadSession.objects.filter(space=private).delete()
private.delete()
now = timezone.now() now = timezone.now()
SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now) SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now)
PublicItemLink.objects.filter(Q(created_by=target) | Q(item__space__private_owner=target), PublicItemLink.objects.filter(created_by=target, revoked_at__isnull=True).update(revoked_at=now)
revoked_at__isnull=True).update(revoked_at=now)
ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete() ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete()
SpaceGrant.objects.filter(user=target).delete() SpaceGrant.objects.filter(user=target).delete()
# Shared spaces survive, but their former owner loses access.
DriveSpace.objects.filter(user=target, private_owner__isnull=True).update(user=None)
DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True).update(managed_by=None)
unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk',flat=True)) unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk',flat=True))
UploadSession.objects.filter(owner=target).delete() UploadSession.objects.filter(owner=target).delete()
if unfinished:
transaction.on_commit(lambda: _remove_upload_parts(unfinished))
target.is_active = False target.is_active = False
target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}' target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}'
target.first_name = '' target.first_name = ''
@ -585,4 +702,15 @@ def admin_delete_user(request, user_id):
target.set_unusable_password() target.set_unusable_password()
target.save(update_fields=['is_active','username','first_name','last_name','email','password']) target.save(update_fields=['is_active','username','first_name','last_name','email','password'])
ArchivedDriveAccount.objects.create(user=target) ArchivedDriveAccount.objects.create(user=target)
# Work only after a successful DB commit; TestCase can explicitly
# execute callbacks to validate the physical removal.
def complete_cleanup():
_delete_staged_private(staged)
_delete_legacy_private_files(legacy)
_remove_upload_parts(unfinished)
transaction.on_commit(complete_cleanup)
except Exception:
if staged is not None and staged.exists() and original is not None and not original.exists():
staged.rename(original)
raise
return Response(status=204) return Response(status=204)

View File

@ -4,9 +4,11 @@ import { api, logoutTokens, savedToken } from '@/api/client'
import DrivePage from '@/pages/DrivePage' import DrivePage from '@/pages/DrivePage'
import LoginPage from '@/pages/LoginPage' import LoginPage from '@/pages/LoginPage'
import InvitationPage from '@/pages/InvitationPage' import InvitationPage from '@/pages/InvitationPage'
import PublicSharePage from '@/pages/PublicSharePage'
/** Application entry point: only restores the session and selects the screen. */ /** Application entry point: only restores the session and selects the screen. */
export default function App() { export default function App() {
const publicToken = window.location.pathname.match(/^\/s\/([A-Za-z0-9_-]+)\/?$/)?.[1]
const invitationToken = window.location.pathname.match(/^\/invite\/([A-Za-z0-9_-]+)\/?$/)?.[1] const invitationToken = window.location.pathname.match(/^\/invite\/([A-Za-z0-9_-]+)\/?$/)?.[1]
const [user,setUser] = useState(null) const [user,setUser] = useState(null)
const [checking,setChecking] = useState(true) const [checking,setChecking] = useState(true)
@ -14,6 +16,10 @@ export default function App() {
const [attempt,setAttempt] = useState(0) const [attempt,setAttempt] = useState(0)
useEffect(() => { useEffect(() => {
if (publicToken || invitationToken) {
setChecking(false)
return
}
let active = true let active = true
const probe = async () => { const probe = async () => {
if(!savedToken()) {setChecking(false); return} if(!savedToken()) {setChecking(false); return}
@ -34,8 +40,9 @@ export default function App() {
const expired = () => {setUser(null);setChecking(false)} const expired = () => {setUser(null);setChecking(false)}
window.addEventListener('drive:logout', expired) window.addEventListener('drive:logout', expired)
return () => {active = false;window.removeEventListener('drive:logout', expired)} return () => {active = false;window.removeEventListener('drive:logout', expired)}
}, [attempt]) }, [attempt, publicToken, invitationToken])
if(publicToken) return <PublicSharePage token={publicToken} />
if(invitationToken) return <InvitationPage token={invitationToken} /> if(invitationToken) return <InvitationPage token={invitationToken} />
if(connectionError) return <div className="grid min-h-dvh place-items-center p-6 text-center"><div className="space-y-4"><p>GoodDrive est momentanément inaccessible. Ta session est conservée.</p><button type="button" className="rounded-lg bg-primary px-4 py-2 text-primary-foreground" onClick={() => {setConnectionError(false);setChecking(true);setAttempt(n=>n+1)}}>Réessayer</button></div></div> if(connectionError) return <div className="grid min-h-dvh place-items-center p-6 text-center"><div className="space-y-4"><p>GoodDrive est momentanément inaccessible. Ta session est conservée.</p><button type="button" className="rounded-lg bg-primary px-4 py-2 text-primary-foreground" onClick={() => {setConnectionError(false);setChecking(true);setAttempt(n=>n+1)}}>Réessayer</button></div></div>
if(checking) return <div className="grid h-dvh place-items-center"><LoaderCircle className="size-7 animate-spin text-primary" /></div> if(checking) return <div className="grid h-dvh place-items-center"><LoaderCircle className="size-7 animate-spin text-primary" /></div>

View File

@ -6,6 +6,12 @@ export const api = axios.create({
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
const raw = axios.create({baseURL:'/api', headers:{Accept:'application/json'}}) const raw = axios.create({baseURL:'/api', headers:{Accept:'application/json'}})
// Public links must remain accessible without a session, even if a browser
// has a previously expired access token stored locally.
const publicApi = axios.create({
baseURL: api.defaults.baseURL,
headers: { Accept: 'application/json' },
})
export const savedToken = () => sessionStorage.getItem(ACCESS) export const savedToken = () => sessionStorage.getItem(ACCESS)
export const storeTokens = data => { export const storeTokens = data => {
if (data.access) sessionStorage.setItem(ACCESS,data.access) if (data.access) sessionStorage.setItem(ACCESS,data.access)
@ -64,9 +70,11 @@ export const drive={
unshareItem:(id,grant_id)=>api.delete(`/drive/items/${id}/shares/${grant_id}/`), unshareItem:(id,grant_id)=>api.delete(`/drive/items/${id}/shares/${grant_id}/`),
publicItem:id=>api.post(`/drive/items/${id}/public-links/`).then(r=>r.data), publicItem:id=>api.post(`/drive/items/${id}/public-links/`).then(r=>r.data),
revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`), revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`),
publicBrowse:(token,parent)=>api.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data), publicBrowse:(token,parent)=>publicApi.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data),
adminUsers:q=>api.get('/drive/admin/users/',{params:{q}}).then(r=>r.data), adminUsers:q=>api.get('/drive/admin/users/',{params:{q}}).then(r=>r.data),
adminDeleteUser:(id,confirm_username)=>api.delete(`/drive/admin/users/${id}/`,{data:{confirm_username}}), adminDeleteUser:(id,confirm_username)=>api.delete(`/drive/admin/users/${id}/`,{data:{confirm_username}}),
adminUserAccess:id=>api.get(`/drive/admin/users/${id}/access/`).then(r=>r.data.accesses),
revokeUserAccess:(id,kind,spaceId)=>api.delete(`/drive/admin/users/${id}/access/${kind}/${spaceId}/`),
stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data), stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data),
folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data), folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data),
patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data), patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data),
@ -117,4 +125,4 @@ export const drive={
} }
export function message(error){let d=error?.response?.data;if(typeof d?.detail==='string')return d.detail;if(typeof d==='object' && d!==null)return Object.entries(d).map(([k,v])=>`${k}: ${Array.isArray(v)?v.join(', '):v}`).join(' · ');return 'Une erreur est survenue. Réessaie.'} export function message(error){let d=error?.response?.data;if(typeof d?.detail==='string')return d.detail;if(typeof d==='object' && d!==null)return Object.entries(d).map(([k,v])=>`${k}: ${Array.isArray(v)?v.join(', '):v}`).join(' · ');return 'Une erreur est survenue. Réessaie.'}
export const publicFileUrl = (token,id) => `${api.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/` export const publicFileUrl = (token,id) => `${publicApi.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/`

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Users, LoaderCircle, Trash2 } from 'lucide-react' import { Users, LoaderCircle, Trash2, KeyRound } from 'lucide-react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@ -11,6 +11,10 @@ export default function AdminUsersPage(){
const [busy,setBusy] = useState(true) const [busy,setBusy] = useState(true)
const [error,setError] = useState('') const [error,setError] = useState('')
const [selected,setSelected] = useState(null) const [selected,setSelected] = useState(null)
const [accessUser,setAccessUser] = useState(null)
const [accesses,setAccesses] = useState([])
const [accessBusy,setAccessBusy] = useState(false)
const [accessError,setAccessError] = useState('')
const [confirmation,setConfirmation] = useState('') const [confirmation,setConfirmation] = useState('')
const [deleting,setDeleting] = useState(false) const [deleting,setDeleting] = useState(false)
const [deleteError,setDeleteError] = useState('') const [deleteError,setDeleteError] = useState('')
@ -27,6 +31,21 @@ export default function AdminUsersPage(){
return ()=>{active=false;clearTimeout(timer)} return ()=>{active=false;clearTimeout(timer)}
},[query,revision]) },[query,revision])
const close=()=>{if(!deleting){setSelected(null);setConfirmation('');setDeleteError('')}} const close=()=>{if(!deleting){setSelected(null);setConfirmation('');setDeleteError('')}}
const openAccesses=async user=>{
setAccessUser(user);setAccesses([]);setAccessError('');setAccessBusy(true)
try {setAccesses(await drive.adminUserAccess(user.id))}
catch(e){setAccessError(message(e))}
finally{setAccessBusy(false)}
}
const revokeAccess=async access=>{
if(!accessUser || !window.confirm(`Retirer l’accès à « ${access.space_name} » pour @${accessUser.username} ?`))return
setAccessBusy(true);setAccessError('')
try{
await drive.revokeUserAccess(accessUser.id,access.kind,access.space_id)
setAccesses(await drive.adminUserAccess(accessUser.id));refresh()
}catch(e){setAccessError(message(e))}
finally{setAccessBusy(false)}
}
const remove=async(event)=>{ const remove=async(event)=>{
event.preventDefault() event.preventDefault()
if(!selected||deleting||confirmation!==selected.username)return if(!selected||deleting||confirmation!==selected.username)return
@ -46,16 +65,35 @@ export default function AdminUsersPage(){
<td className="p-3 font-medium">{[user.first_name,user.last_name].filter(Boolean).join(' ') || '—'}</td> <td className="p-3 font-medium">{[user.first_name,user.last_name].filter(Boolean).join(' ') || '—'}</td>
<td className="p-3">@{user.username}</td><td className="p-3">{user.email||'—'}</td><td className="p-3">{user.space_name||'—'}</td> <td className="p-3">@{user.username}</td><td className="p-3">{user.email||'—'}</td><td className="p-3">{user.space_name||'—'}</td>
<td className="p-3">{user.is_active?'Actif':'Désactivé'}{user.is_admin?' · Admin':''}</td> <td className="p-3">{user.is_active?'Actif':'Désactivé'}{user.is_admin?' · Admin':''}</td>
<td className="p-2 text-right">{!user.is_admin && <Button type="button" variant="ghost" size="icon-sm" className="text-destructive" aria-label={`Supprimer ${user.username}`} title="Supprimer l’utilisateur" onClick={()=>{setSelected(user);setConfirmation('');setDeleteError('')}}><Trash2 className="size-4"/></Button>}</td> <td className="p-2 text-right"><div className="flex justify-end gap-1">{!user.is_admin && <>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Gérer les accès de ${user.username}`} title="Gérer les accès" onClick={()=>openAccesses(user)}><KeyRound className="size-4"/></Button>
<Button type="button" variant="ghost" size="icon-sm" className="text-destructive" aria-label={`Supprimer ${user.username}`} title="Supprimer l’utilisateur" onClick={()=>{setSelected(user);setConfirmation('');setDeleteError('')}}><Trash2 className="size-4"/></Button>
</>}</div></td>
</tr>)}</tbody></table> </tr>)}</tbody></table>
{!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>} {!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>}
</div>} </div>}
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>} {record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
</div> </div>
<Dialog open={!!accessUser} onOpenChange={open=>{if(!open&&!accessBusy)setAccessUser(null)}}>
<DialogContent className="sm:max-w-lg">
<DialogHeader><DialogTitle>Accès de @{accessUser?.username}</DialogTitle>
<DialogDescription>Retirer l’accès à un espace partagé sans supprimer cet espace.</DialogDescription>
</DialogHeader>
{accessError && <p role="alert" className="text-sm text-destructive">{accessError}</p>}
{accessBusy && <LoaderCircle className="size-5 animate-spin"/>}
<div className="max-h-80 space-y-2 overflow-y-auto">
{accesses.map(access=><div key={`${access.kind}-${access.space_id}`} className="flex items-center justify-between gap-2 rounded-lg border p-3">
<div className="min-w-0"><div className="truncate text-sm font-medium">{access.space_name}</div><div className="text-xs text-muted-foreground">{access.kind==='grant'?'Accès attribué':access.kind==='owner'?'Espace attribué':'Gestionnaire'}</div></div>
<Button type="button" size="sm" variant="outline" className="shrink-0 text-destructive" disabled={accessBusy} onClick={()=>revokeAccess(access)}><Trash2 className="size-4"/> Retirer</Button>
</div>)}
{!accessBusy && !accesses.length && <p className="text-sm text-muted-foreground">Aucun accès à un espace partagé.</p>}
</div>
</DialogContent>
</Dialog>
<Dialog open={!!selected} onOpenChange={open=>{if(!open)close()}}> <Dialog open={!!selected} onOpenChange={open=>{if(!open)close()}}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>Supprimer l’utilisateur</DialogTitle> <DialogHeader><DialogTitle>Supprimer l’utilisateur</DialogTitle>
<DialogDescription>Le compte sera désactivé et retiré de la liste. Son espace privé sera conservé.</DialogDescription> <DialogDescription>Le compte sera désactivé. Son espace privé et tous ses fichiers seront définitivement supprimés.</DialogDescription>
</DialogHeader> </DialogHeader>
<form className="space-y-4" onSubmit={remove}> <form className="space-y-4" onSubmit={remove}>
<label className="block space-y-2 text-sm">Tape « {selected?.username} » pour confirmer <label className="block space-y-2 text-sm">Tape « {selected?.username} » pour confirmer