nice
This commit is contained in:
parent
7da4c7cd67
commit
8ec0397288
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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,22 +655,62 @@ 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()
|
||||||
unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk', flat=True))
|
# 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))
|
||||||
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 = ''
|
||||||
target.last_name = ''
|
target.last_name = ''
|
||||||
target.email = ''
|
target.email = ''
|
||||||
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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}/`
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user