nice
This commit is contained in:
parent
7da4c7cd67
commit
8ec0397288
@ -125,6 +125,6 @@ class PublicItemLink(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')
|
||||
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(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):
|
||||
alice=self.invite_user('alice-admin-list')
|
||||
self.client.force_authenticate(user=alice)
|
||||
|
||||
@ -96,22 +96,31 @@ class ProtectedDeletionTests(TestCase):
|
||||
self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists())
|
||||
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()
|
||||
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/',
|
||||
{'confirm_username':self.admin.username}, format='json').status_code, 403)
|
||||
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
|
||||
{'confirm_username':'wrong'}, format='json').status_code, 400)
|
||||
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
|
||||
{'confirm_username':self.alice.username}, format='json').status_code, 204)
|
||||
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}/',
|
||||
{'confirm_username':self.alice.username}, format='json').status_code, 204)
|
||||
self.alice.refresh_from_db()
|
||||
self.assertFalse(self.alice.is_active)
|
||||
self.assertFalse(self.alice.has_usable_password())
|
||||
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.assertTrue(path.exists())
|
||||
self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk, private_owner=self.alice).exists())
|
||||
self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists())
|
||||
self.assertFalse(path.exists())
|
||||
self.assertFalse((self.storage / self.private.relative_path).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}/',
|
||||
{'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.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/',
|
||||
{'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('admin/users/',views_spaces.admin_users),
|
||||
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('items/<uuid:item_id>/shares/',views_shares.manage_item_shares),
|
||||
path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share),
|
||||
|
||||
@ -548,41 +548,169 @@ def admin_users(request):
|
||||
return Response({'users':result[:200], 'limited':len(result)>200})
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
def admin_delete_user(request, user_id):
|
||||
"""Remove account access without deleting the non-deletable private space.
|
||||
@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})
|
||||
|
||||
The user row remains as an anonymized, inactive data owner so PROTECT
|
||||
relations on private files and the private space never cascade away.
|
||||
|
||||
@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 target.pk == request.user.pk or is_admin(target):
|
||||
return error('Impossible de supprimer un administrateur.', 403)
|
||||
if request.data.get('confirm_username') != target.username:
|
||||
return error('Saisis le nom d’utilisateur pour confirmer.')
|
||||
now = timezone.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),
|
||||
revoked_at__isnull=True).update(revoked_at=now)
|
||||
ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete()
|
||||
SpaceGrant.objects.filter(user=target).delete()
|
||||
unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk', flat=True))
|
||||
UploadSession.objects.filter(owner=target).delete()
|
||||
if unfinished:
|
||||
transaction.on_commit(lambda: _remove_upload_parts(unfinished))
|
||||
target.is_active = False
|
||||
target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}'
|
||||
target.first_name = ''
|
||||
target.last_name = ''
|
||||
target.email = ''
|
||||
target.set_unusable_password()
|
||||
target.save(update_fields=['is_active', 'username', 'first_name', 'last_name', 'email', 'password'])
|
||||
ArchivedDriveAccount.objects.create(user=target)
|
||||
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'])
|
||||
def admin_delete_user(request, user_id):
|
||||
"""Deactivate an account and PERMANENTLY remove its private files and space.
|
||||
|
||||
Historical shared files keep the archived owner row because their owner
|
||||
FK is PROTECT; they are not removed by account deletion.
|
||||
"""
|
||||
if not is_admin(request.user):
|
||||
return error('Accès réservé aux administrateurs.', 403)
|
||||
User = get_user_model()
|
||||
staged = None
|
||||
original = None
|
||||
try:
|
||||
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 target.pk == request.user.pk or is_admin(target):
|
||||
return error('Impossible de supprimer un administrateur.', 403)
|
||||
if request.data.get('confirm_username') != target.username:
|
||||
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()
|
||||
SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now)
|
||||
PublicItemLink.objects.filter(created_by=target, revoked_at__isnull=True).update(revoked_at=now)
|
||||
ItemGrant.objects.filter(Q(user=target) | Q(created_by=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))
|
||||
UploadSession.objects.filter(owner=target).delete()
|
||||
target.is_active = False
|
||||
target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}'
|
||||
target.first_name = ''
|
||||
target.last_name = ''
|
||||
target.email = ''
|
||||
target.set_unusable_password()
|
||||
target.save(update_fields=['is_active','username','first_name','last_name','email','password'])
|
||||
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)
|
||||
|
||||
@ -4,9 +4,11 @@ import { api, logoutTokens, savedToken } from '@/api/client'
|
||||
import DrivePage from '@/pages/DrivePage'
|
||||
import LoginPage from '@/pages/LoginPage'
|
||||
import InvitationPage from '@/pages/InvitationPage'
|
||||
import PublicSharePage from '@/pages/PublicSharePage'
|
||||
|
||||
/** Application entry point: only restores the session and selects the screen. */
|
||||
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 [user,setUser] = useState(null)
|
||||
const [checking,setChecking] = useState(true)
|
||||
@ -14,6 +16,10 @@ export default function App() {
|
||||
const [attempt,setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (publicToken || invitationToken) {
|
||||
setChecking(false)
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
const probe = async () => {
|
||||
if(!savedToken()) {setChecking(false); return}
|
||||
@ -34,8 +40,9 @@ export default function App() {
|
||||
const expired = () => {setUser(null);setChecking(false)}
|
||||
window.addEventListener('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(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>
|
||||
|
||||
@ -6,6 +6,12 @@ export const api = axios.create({
|
||||
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 storeTokens = data => {
|
||||
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}/`),
|
||||
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}/`),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
@ -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 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 { Users, LoaderCircle, Trash2 } from 'lucide-react'
|
||||
import { Users, LoaderCircle, Trash2, KeyRound } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 [error,setError] = useState('')
|
||||
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 [deleting,setDeleting] = useState(false)
|
||||
const [deleteError,setDeleteError] = useState('')
|
||||
@ -27,6 +31,21 @@ export default function AdminUsersPage(){
|
||||
return ()=>{active=false;clearTimeout(timer)}
|
||||
},[query,revision])
|
||||
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)=>{
|
||||
event.preventDefault()
|
||||
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">@{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-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>
|
||||
{!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>}
|
||||
</div>}
|
||||
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
|
||||
</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()}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<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>
|
||||
<form className="space-y-4" onSubmit={remove}>
|
||||
<label className="block space-y-2 text-sm">Tape « {selected?.username} » pour confirmer
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user