nice
This commit is contained in:
parent
d689a2253d
commit
8f99ce8961
@ -1,226 +0,0 @@
|
|||||||
"""Private disk layout, 50-Go private cap, and real-byte pooled accounting.
|
|
||||||
|
|
||||||
Run with: USE_SQLITE=true python manage.py test drive.test_private_layout
|
|
||||||
"""
|
|
||||||
import tempfile
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
||||||
from django.core.management import call_command
|
|
||||||
from django.core.management.base import CommandError
|
|
||||||
from django.db import transaction
|
|
||||||
from django.test import TestCase, override_settings
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from .models import DriveItem, DriveSpace
|
|
||||||
from .quotas import (PRIVATE_SPACE_QUOTA_BYTES, QuotaExceeded, assert_space_capacity,
|
|
||||||
assert_updated_quota, locked_quota_tree, occupancy, quota_state)
|
|
||||||
from .spaces import ensure_private_space, ensure_space, create_space, storage_path
|
|
||||||
|
|
||||||
|
|
||||||
class PrivateStorageTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(self.temp.cleanup)
|
|
||||||
self.rootdir = Path(self.temp.name)
|
|
||||||
overrides = override_settings(
|
|
||||||
DRIVE_STORAGE_ROOT=self.rootdir,
|
|
||||||
FILE_UPLOAD_TEMP_DIR=str(self.rootdir / '.uploads'),
|
|
||||||
)
|
|
||||||
overrides.enable()
|
|
||||||
self.addCleanup(overrides.disable)
|
|
||||||
User = get_user_model()
|
|
||||||
self.admin = User.objects.create_superuser('root-admin', 'admin@example.org', 'TestPassword123!')
|
|
||||||
self.shared = ensure_space(self.admin)
|
|
||||||
self.private = ensure_private_space(self.admin)
|
|
||||||
self.client = APIClient()
|
|
||||||
self.client.force_authenticate(user=self.admin)
|
|
||||||
|
|
||||||
def test_private_directories_are_separate_from_shared_spaces(self):
|
|
||||||
self.assertEqual(self.private.parent_id, self.shared.pk)
|
|
||||||
self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}')
|
|
||||||
self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES)
|
|
||||||
self.assertTrue((self.rootdir / self.private.relative_path).is_dir())
|
|
||||||
person = get_user_model().objects.create_user('member', password='TestPassword123!')
|
|
||||||
shared = create_space(person, self.shared, 'Compte membre')
|
|
||||||
private = ensure_private_space(person)
|
|
||||||
self.assertTrue(shared.relative_path.startswith(f'{shared.directory_name}'))
|
|
||||||
self.assertEqual(private.relative_path, f'privates/{private.directory_name}')
|
|
||||||
self.assertEqual(private.parent_id, shared.pk)
|
|
||||||
self.assertNotEqual(shared.directory_name, private.directory_name)
|
|
||||||
|
|
||||||
def test_private_usage_counts_toward_assigned_space_without_reserving(self):
|
|
||||||
self.shared.quota_bytes = 100
|
|
||||||
self.shared.save(update_fields=['quota_bytes'])
|
|
||||||
# Private can be 50 Go even when the assigned budget is just 100 bytes.
|
|
||||||
self.assertEqual(quota_state(self.shared)['available_bytes'], 100)
|
|
||||||
self.assertEqual(quota_state(self.private)['available_bytes'], 100)
|
|
||||||
created = self.client.post('/api/drive/uploads/', {
|
|
||||||
'files': [SimpleUploadedFile('photo.jpg', b'abcdef')]
|
|
||||||
}, format='multipart')
|
|
||||||
self.assertEqual(created.status_code, 201, created.data)
|
|
||||||
node = DriveItem.objects.get(pk=created.data['items'][0]['id'])
|
|
||||||
self.assertEqual(node.storage_location, f'{self.private.relative_path}/photo.jpg')
|
|
||||||
self.assertEqual(storage_path(node).read_bytes(), b'abcdef')
|
|
||||||
self.assertEqual(occupancy(self.shared)[0], 6)
|
|
||||||
self.assertEqual(quota_state(self.shared)['available_bytes'], 94)
|
|
||||||
self.assertEqual(quota_state(self.private)['available_bytes'], 94)
|
|
||||||
self.assertEqual(quota_state(self.private)['quota_bytes'], PRIVATE_SPACE_QUOTA_BYTES)
|
|
||||||
self.assertEqual(quota_state(self.private)['used_bytes'], 6)
|
|
||||||
rejected = self.client.patch(f'/api/drive/spaces/{self.private.pk}/quota/', {
|
|
||||||
'quota_bytes': None
|
|
||||||
}, format='json')
|
|
||||||
self.assertEqual(rejected.status_code, 403)
|
|
||||||
|
|
||||||
def test_actual_private_use_is_pooled_in_100_go_assigned_space(self):
|
|
||||||
person = get_user_model().objects.create_user('quota-user', password='TestPassword123!')
|
|
||||||
assigned = create_space(person, self.shared, '100 Go', quota_bytes=100 * 1024 ** 3)
|
|
||||||
private = ensure_private_space(person)
|
|
||||||
gib = 1024 ** 3
|
|
||||||
self.assertEqual(private.parent_id, assigned.pk)
|
|
||||||
self.assertEqual(private.relative_path, f'privates/{private.directory_name}')
|
|
||||||
self.assertEqual(quota_state(assigned)['available_bytes'], 100 * gib)
|
|
||||||
self.assertEqual(quota_state(private)['available_bytes'], 50 * gib)
|
|
||||||
# Existing 20 Go in shared space. This leaves both limits at their max.
|
|
||||||
DriveItem.objects.create(
|
|
||||||
owner=person, space=assigned, name='shared.bin',
|
|
||||||
storage_key=str(uuid.uuid4()), storage_location=f'{assigned.relative_path}/shared.bin',
|
|
||||||
size=20 * gib,
|
|
||||||
)
|
|
||||||
self.assertEqual(quota_state(assigned)['used_bytes'], 20 * gib)
|
|
||||||
self.assertEqual(quota_state(private)['used_bytes'], 0)
|
|
||||||
DriveItem.objects.create(
|
|
||||||
owner=person, space=private, name='private.bin',
|
|
||||||
storage_key=str(uuid.uuid4()), storage_location=f'{private.relative_path}/private.bin',
|
|
||||||
size=20 * gib,
|
|
||||||
)
|
|
||||||
self.assertEqual(occupancy(assigned)[0], 40 * gib)
|
|
||||||
self.assertEqual(quota_state(assigned)['used_bytes'], 40 * gib)
|
|
||||||
self.assertEqual(quota_state(assigned)['available_bytes'], 60 * gib)
|
|
||||||
self.assertEqual(quota_state(private)['used_bytes'], 20 * gib)
|
|
||||||
self.assertEqual(quota_state(private)['available_bytes'], 30 * gib)
|
|
||||||
with transaction.atomic():
|
|
||||||
with locked_quota_tree(assigned):
|
|
||||||
assert_space_capacity(assigned, 60 * gib)
|
|
||||||
with self.assertRaises(QuotaExceeded):
|
|
||||||
assert_space_capacity(assigned, 60 * gib + 1)
|
|
||||||
assert_updated_quota(assigned, 40 * gib)
|
|
||||||
with self.assertRaisesRegex(ValueError, 'fichiers'):
|
|
||||||
assert_updated_quota(assigned, 39 * gib)
|
|
||||||
|
|
||||||
def test_parent_limit_blocks_private_even_before_50_go(self):
|
|
||||||
self.shared.quota_bytes = 20
|
|
||||||
self.shared.save(update_fields=['quota_bytes'])
|
|
||||||
DriveItem.objects.create(
|
|
||||||
owner=self.admin, space=self.private, name='existing.bin',
|
|
||||||
storage_key=str(uuid.uuid4()), storage_location=f'{self.private.relative_path}/existing.bin', size=19,
|
|
||||||
)
|
|
||||||
self.assertEqual(quota_state(self.private)['available_bytes'], 1)
|
|
||||||
with transaction.atomic():
|
|
||||||
with locked_quota_tree(self.private):
|
|
||||||
assert_space_capacity(self.private, 1)
|
|
||||||
with self.assertRaises(QuotaExceeded):
|
|
||||||
assert_space_capacity(self.private, 2)
|
|
||||||
|
|
||||||
def test_invitations_allow_less_than_50_go_no_reservation(self):
|
|
||||||
self.shared.quota_bytes = 15
|
|
||||||
self.shared.save(update_fields=['quota_bytes'])
|
|
||||||
res = self.client.post('/api/drive/invitations/', {'quota_bytes': 10}, format='json')
|
|
||||||
self.assertEqual(res.status_code, 201, res.data)
|
|
||||||
self.assertEqual(quota_state(self.shared)['available_bytes'], 15)
|
|
||||||
member = get_user_model().objects.create_user('tiny-private', password='TestPassword123!')
|
|
||||||
assigned = create_space(member, self.shared, '10 octets', quota_bytes=10)
|
|
||||||
private = ensure_private_space(member)
|
|
||||||
self.assertEqual(private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES)
|
|
||||||
self.assertEqual(quota_state(assigned)['available_bytes'], 10)
|
|
||||||
self.assertEqual(quota_state(private)['available_bytes'], 10)
|
|
||||||
|
|
||||||
def test_private_pending_uploads_count_in_parent(self):
|
|
||||||
from .models import UploadSession
|
|
||||||
UploadSession.objects.create(
|
|
||||||
owner=self.admin, space=self.private, name='movie.bin', total_size=1234
|
|
||||||
)
|
|
||||||
self.assertEqual(quota_state(self.shared)['reserved_bytes'], 1234)
|
|
||||||
self.assertEqual(quota_state(self.shared)['used_bytes'], 0)
|
|
||||||
self.assertEqual(quota_state(self.private)['available_bytes'], PRIVATE_SPACE_QUOTA_BYTES - 1234)
|
|
||||||
|
|
||||||
def test_private_50_go_upload_limit_is_enforced(self):
|
|
||||||
DriveItem.objects.create(
|
|
||||||
owner=self.admin, space=self.private, name='large.bin',
|
|
||||||
storage_key=str(uuid.uuid4()),
|
|
||||||
storage_location=f'{self.private.relative_path}/large.bin',
|
|
||||||
size=PRIVATE_SPACE_QUOTA_BYTES - 1,
|
|
||||||
)
|
|
||||||
with transaction.atomic():
|
|
||||||
with locked_quota_tree(self.private):
|
|
||||||
with self.assertRaises(QuotaExceeded):
|
|
||||||
assert_space_capacity(self.private, 2)
|
|
||||||
assert_space_capacity(self.private, 1)
|
|
||||||
|
|
||||||
def simulate_legacy_private(self):
|
|
||||||
"""Relocate the empty modern directory to the historical nested location."""
|
|
||||||
new = self.rootdir / self.private.relative_path
|
|
||||||
new.rmdir()
|
|
||||||
old_name = self.private.directory_name
|
|
||||||
old = f'{old_name}'
|
|
||||||
src = self.rootdir / old
|
|
||||||
(src / 'Documents').mkdir(parents=True)
|
|
||||||
(src / 'Documents' / 'facture.pdf').write_bytes(b'PDF CONTENT')
|
|
||||||
self.private.parent = self.shared
|
|
||||||
self.private.relative_path = old
|
|
||||||
self.private.quota_bytes = None
|
|
||||||
self.private.save(update_fields=['parent', 'relative_path', 'quota_bytes'])
|
|
||||||
folder = DriveItem.objects.create(
|
|
||||||
owner=self.admin, space=self.private, name='Documents', is_folder=True,
|
|
||||||
)
|
|
||||||
item = DriveItem.objects.create(
|
|
||||||
owner=self.admin, space=self.private, parent=folder, name='facture.pdf',
|
|
||||||
storage_key=str(uuid.uuid4()), storage_location=f'{old}/Documents/facture.pdf',
|
|
||||||
size=11,
|
|
||||||
)
|
|
||||||
return old, item
|
|
||||||
|
|
||||||
def test_migrate_private_storage_dry_run_and_apply(self):
|
|
||||||
old, item = self.simulate_legacy_private()
|
|
||||||
call_command('move_private_storage')
|
|
||||||
self.private.refresh_from_db()
|
|
||||||
self.assertEqual(self.private.relative_path, old)
|
|
||||||
self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists())
|
|
||||||
call_command('move_private_storage', '--apply')
|
|
||||||
self.private.refresh_from_db()
|
|
||||||
item.refresh_from_db()
|
|
||||||
self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}')
|
|
||||||
self.assertEqual(self.private.parent_id, self.shared.pk)
|
|
||||||
self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES)
|
|
||||||
self.assertEqual(item.storage_location, f'{self.private.relative_path}/Documents/facture.pdf')
|
|
||||||
self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT')
|
|
||||||
self.assertFalse((self.rootdir / old).exists())
|
|
||||||
self.assertFalse((self.rootdir / '.gooddrive-move-privates.json').exists())
|
|
||||||
call_command('move_private_storage', '--apply') # Idempotent.
|
|
||||||
|
|
||||||
def test_interrupted_migration_can_resume_after_directory_move(self):
|
|
||||||
from .management.commands.move_private_storage import Command, write_journal
|
|
||||||
old, item = self.simulate_legacy_private()
|
|
||||||
plan = Command().plan()
|
|
||||||
journal = self.rootdir / '.gooddrive-move-privates.json'
|
|
||||||
write_journal(journal, plan)
|
|
||||||
new = self.rootdir / 'privates' / self.private.directory_name
|
|
||||||
(self.rootdir / old).rename(new) # Simulate a crash before SQL update.
|
|
||||||
call_command('move_private_storage', '--apply')
|
|
||||||
item.refresh_from_db()
|
|
||||||
self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT')
|
|
||||||
self.assertFalse(journal.exists())
|
|
||||||
|
|
||||||
def test_migration_never_overwrites_existing_destination(self):
|
|
||||||
old, item = self.simulate_legacy_private()
|
|
||||||
target = self.rootdir / 'privates' / self.private.directory_name
|
|
||||||
target.mkdir(parents=True)
|
|
||||||
(target / 'unrelated.txt').write_text('KEEP ME')
|
|
||||||
with self.assertRaises(CommandError):
|
|
||||||
call_command('move_private_storage', '--apply')
|
|
||||||
self.assertEqual((target / 'unrelated.txt').read_text(), 'KEEP ME')
|
|
||||||
self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists())
|
|
||||||
item.refresh_from_db()
|
|
||||||
self.assertEqual(item.storage_location, f'{old}/Documents/facture.pdf')
|
|
||||||
@ -36,7 +36,7 @@ def invitation_info(inv):
|
|||||||
'expires_at':inv.expires_at.isoformat(), 'used':bool(inv.used_at),
|
'expires_at':inv.expires_at.isoformat(), 'used':bool(inv.used_at),
|
||||||
'revoked':bool(inv.revoked_at),
|
'revoked':bool(inv.revoked_at),
|
||||||
'can_view_parent':inv.can_view_parent,'can_write_parent':inv.can_write_parent,
|
'can_view_parent':inv.can_view_parent,'can_write_parent':inv.can_write_parent,
|
||||||
'can_invite':inv.can_invite,'quota_bytes':inv.quota_bytes}
|
'can_invite':inv.can_invite}
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@ -154,15 +154,11 @@ def create_invitation(request):
|
|||||||
try:days=int(request.data.get('expires_days',7))
|
try:days=int(request.data.get('expires_days',7))
|
||||||
except (ValueError,TypeError):return error('Durée invalide.')
|
except (ValueError,TypeError):return error('Durée invalide.')
|
||||||
if not 1<=days<=30:return error('La durée doit être comprise entre 1 et 30 jours.')
|
if not 1<=days<=30:return error('La durée doit être comprise entre 1 et 30 jours.')
|
||||||
try:quota=parse_quota(request.data.get('quota_bytes'))
|
# An invitation grants access. Storage limits are configured on spaces,
|
||||||
except ValueError as ex:return error(str(ex))
|
# not on invitation links (the private space always has its 50-Go cap).
|
||||||
with transaction.atomic():
|
|
||||||
with locked_quota_tree(parent):
|
|
||||||
try:assert_new_child_quota(parent,quota)
|
|
||||||
except ValueError as exc:return error(str(exc))
|
|
||||||
token=secrets.token_urlsafe(32)
|
token=secrets.token_urlsafe(32)
|
||||||
inv=SpaceInvitation.objects.create(parent_space=parent,created_by=request.user,
|
inv=SpaceInvitation.objects.create(parent_space=parent,created_by=request.user,
|
||||||
token_hash=hash_token(token),expires_at=timezone.now()+timedelta(days=days),quota_bytes=quota,**fields)
|
token_hash=hash_token(token),expires_at=timezone.now()+timedelta(days=days),**fields)
|
||||||
# Returned exactly once; DB stores only SHA-256.
|
# Returned exactly once; DB stores only SHA-256.
|
||||||
return Response({**invitation_info(inv),'token':token},status=201)
|
return Response({**invitation_info(inv),'token':token},status=201)
|
||||||
|
|
||||||
@ -198,7 +194,6 @@ def invitation_preview(request,token):
|
|||||||
'can_view_parent':inv.can_view_parent,
|
'can_view_parent':inv.can_view_parent,
|
||||||
'can_write_parent':inv.can_write_parent,
|
'can_write_parent':inv.can_write_parent,
|
||||||
'can_invite':inv.can_invite,
|
'can_invite':inv.can_invite,
|
||||||
'quota_bytes':inv.quota_bytes,
|
|
||||||
'expires_at':inv.expires_at.isoformat()})
|
'expires_at':inv.expires_at.isoformat()})
|
||||||
|
|
||||||
|
|
||||||
@ -229,12 +224,10 @@ def accept_invitation(request,token):
|
|||||||
inv=get_valid_invitation(token,lock=True)
|
inv=get_valid_invitation(token,lock=True)
|
||||||
if inv is None:return error('Invitation invalide ou déjà utilisée.',404)
|
if inv is None:return error('Invitation invalide ou déjà utilisée.',404)
|
||||||
with locked_quota_tree(inv.parent_space):
|
with locked_quota_tree(inv.parent_space):
|
||||||
try:assert_new_child_quota(inv.parent_space,inv.quota_bytes)
|
|
||||||
except ValueError as exc:return error(str(exc))
|
|
||||||
user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name)
|
user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name)
|
||||||
space=create_space(user,inv.parent_space,display_name,
|
space=create_space(user,inv.parent_space,display_name,
|
||||||
can_view_parent=inv.can_view_parent,can_write_parent=inv.can_write_parent,
|
can_view_parent=inv.can_view_parent,can_write_parent=inv.can_write_parent,
|
||||||
can_invite=inv.can_invite,quota_bytes=inv.quota_bytes)
|
can_invite=inv.can_invite)
|
||||||
ensure_private_space(user)
|
ensure_private_space(user)
|
||||||
inv.used_by=user;inv.used_at=timezone.now()
|
inv.used_by=user;inv.used_at=timezone.now()
|
||||||
inv.save(update_fields=['used_by','used_at'])
|
inv.save(update_fields=['used_by','used_at'])
|
||||||
|
|||||||
@ -140,7 +140,6 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
|||||||
<p>Fichiers actifs : {fmtSize(activeBytes)}</p>
|
<p>Fichiers actifs : {fmtSize(activeBytes)}</p>
|
||||||
{trashBytes > 0 && <p>Corbeille : {fmtSize(trashBytes)} (vider pour libérer)</p>}
|
{trashBytes > 0 && <p>Corbeille : {fmtSize(trashBytes)} (vider pour libérer)</p>}
|
||||||
{reservedBytes > 0 && <p>Transferts réservés : {fmtSize(reservedBytes)} (annuler pour libérer)</p>}
|
{reservedBytes > 0 && <p>Transferts réservés : {fmtSize(reservedBytes)} (annuler pour libérer)</p>}
|
||||||
<p className="text-[10px]">Sous-espaces inclus. Le compteur correspond à l’espace réellement occupé.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,5 @@ export default function QuotaField({ value, onChange, disabled = false, id = 'qu
|
|||||||
Sans limite dédiée
|
Sans limite dédiée
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">1 Go = 1 073 741 824 octets. Les limites des espaces parents restent applicables.</p>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,6 @@ import { HardDrive, ChevronRight, Folder } from 'lucide-react'
|
|||||||
export default function ItemFormDialog({controller: c}) {
|
export default function ItemFormDialog({controller: c}) {
|
||||||
const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c
|
const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c
|
||||||
return (
|
return (
|
||||||
<Dialog open={modal==='folder'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription>{modal==='move'?'Choisis le dossier de destination.':'Les modifications seront sauvegardées immédiatement.'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.can_write).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
|
<Dialog open={modal==='folder'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription className={modal==='move' ? undefined : 'sr-only'}>{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : 'Nouveau nom'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.can_write).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import QuotaField from '@/components/drive/QuotaField'
|
import QuotaField from '@/components/drive/QuotaField'
|
||||||
import {fromQuota,toQuota} from '@/lib/quota-utils'
|
import {fromQuota,toQuota} from '@/lib/quota-utils'
|
||||||
import {fmtSize} from '@/lib/drive-format'
|
|
||||||
|
|
||||||
export default function SpaceSettingsDialog({controller: c}) {
|
export default function SpaceSettingsDialog({controller: c}) {
|
||||||
const {spaceDialog, setSpaceDialog, spaces, activeSpaceId, loadSpaces, switchSpace, setError, setNote, setManagedSpaceId} = c
|
const {spaceDialog, setSpaceDialog, spaces, activeSpaceId, loadSpaces, switchSpace, setError, setNote, setManagedSpaceId} = c
|
||||||
@ -18,10 +17,6 @@ export default function SpaceSettingsDialog({controller: c}) {
|
|||||||
const [parent,setParent] = useState('')
|
const [parent,setParent] = useState('')
|
||||||
const [busy,setBusy] = useState(false)
|
const [busy,setBusy] = useState(false)
|
||||||
const [localError,setLocalError] = useState('')
|
const [localError,setLocalError] = useState('')
|
||||||
// En mode gestion, prendre le parent de l'espace édité ; en création,
|
|
||||||
// prendre le parent sélectionné. Le state doit être déclaré avant usage.
|
|
||||||
const parentId = manage ? current?.parent_id : parent
|
|
||||||
const parentSpace = spaces.find(space => space.id === Number(parentId))
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(manage ? current?.display_name || '' : '')
|
setName(manage ? current?.display_name || '' : '')
|
||||||
setQuota(manage ? fromQuota(current?.quota_bytes) : '')
|
setQuota(manage ? fromQuota(current?.quota_bytes) : '')
|
||||||
@ -53,9 +48,7 @@ export default function SpaceSettingsDialog({controller: c}) {
|
|||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{manage ? `Gérer l’espace « ${current?.display_name || 'introuvable'} »` : 'Créer un espace partagé'}</DialogTitle>
|
<DialogTitle>{manage ? `Gérer l’espace « ${current?.display_name || 'introuvable'} »` : 'Créer un espace partagé'}</DialogTitle>
|
||||||
<DialogDescription>{manage && current?.parent_id
|
<DialogDescription className="sr-only">{manage ? 'Paramètres de cet espace' : 'Paramètres du nouvel espace'}</DialogDescription>
|
||||||
? `Sous-espace de « ${parentSpace?.display_name || 'Espace parent'} ». Les modifications ci-dessous concernent uniquement « ${current.display_name} ».`
|
|
||||||
: 'Le nom affiché est indépendant du dossier numéroté sur le serveur.'}</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={save} className="space-y-4">
|
<form onSubmit={save} className="space-y-4">
|
||||||
{!manage && <div className="space-y-2"><Label htmlFor="space-parent">Espace parent</Label>
|
{!manage && <div className="space-y-2"><Label htmlFor="space-parent">Espace parent</Label>
|
||||||
@ -66,11 +59,6 @@ export default function SpaceSettingsDialog({controller: c}) {
|
|||||||
<div className="space-y-2"><Label htmlFor="space-name">Nom affiché</Label>
|
<div className="space-y-2"><Label htmlFor="space-name">Nom affiché</Label>
|
||||||
<Input id="space-name" value={name} onChange={e=>setName(e.target.value)} maxLength={120} required placeholder="Ex. Équipe projet" /></div>
|
<Input id="space-name" value={name} onChange={e=>setName(e.target.value)} maxLength={120} required placeholder="Ex. Équipe projet" /></div>
|
||||||
<QuotaField id="space-quota" value={quota} onChange={setQuota} disabled={busy} />
|
<QuotaField id="space-quota" value={quota} onChange={setQuota} disabled={busy} />
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{manage
|
|
||||||
? <>Capacité supplémentaire du parent : {parentSpace?.available_bytes == null ? 'illimitée' : fmtSize(parentSpace.available_bytes)}. Une modification conserve la réservation déjà attribuée à cet espace.</>
|
|
||||||
: <>Disponible dans l’espace parent : {parentSpace?.available_bytes == null ? 'illimité' : fmtSize(parentSpace.available_bytes)}. Les quotas des sous-espaces sont réservés et inclus dans le calcul.</>}
|
|
||||||
</p>
|
|
||||||
{localError && <p role="alert" className="text-sm text-destructive">{localError}</p>}
|
{localError && <p role="alert" className="text-sm text-destructive">{localError}</p>}
|
||||||
<DialogFooter className="gap-2 sm:justify-between">
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
{manage && current?.can_invite && <Button type="button" variant="outline" onClick={members}>Gérer les accès</Button>}
|
{manage && current?.can_invite && <Button type="button" variant="outline" onClick={members}>Gérer les accès</Button>}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { ArrowRight, LoaderCircle } from 'lucide-react'
|
|||||||
import { drive, message } from '@/api/client'
|
import { drive, message } from '@/api/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {fmtSize} from '@/lib/drive-format'
|
|
||||||
|
|
||||||
export default function InvitationPage({ token }) {
|
export default function InvitationPage({ token }) {
|
||||||
const [info, setInfo] = useState(null)
|
const [info, setInfo] = useState(null)
|
||||||
@ -41,7 +40,7 @@ export default function InvitationPage({ token }) {
|
|||||||
className="block size-full object-contain"
|
className="block size-full object-contain"
|
||||||
/></div><div><h1 className="text-xl font-bold">GoodDrive</h1><p className="text-xs text-muted-foreground">Un espace pour ceux qui en ont besoin.</p></div></div>
|
/></div><div><h1 className="text-xl font-bold">GoodDrive</h1><p className="text-xs text-muted-foreground">Un espace pour ceux qui en ont besoin.</p></div></div>
|
||||||
{done ? <div className="space-y-4"><h2 className="text-2xl font-semibold">Bienvenue sur GoodDrive !</h2><p>Ton espace personnel et ton espace partagé sont prêts. Tu peux maintenant te connecter.</p><Button onClick={() => window.location.assign('/')} className="w-full">Se connecter <ArrowRight /></Button></div>
|
{done ? <div className="space-y-4"><h2 className="text-2xl font-semibold">Bienvenue sur GoodDrive !</h2><p>Ton espace personnel et ton espace partagé sont prêts. Tu peux maintenant te connecter.</p><Button onClick={() => window.location.assign('/')} className="w-full">Se connecter <ArrowRight /></Button></div>
|
||||||
: info ? <><div><h2 className="text-2xl font-semibold">Créer mon espace</h2><p className="mt-2 text-sm text-muted-foreground">Tu es invité dans l'arborescence de « {info.parent_name} ». Tu disposeras de « Mes fichiers », privé, et d’un accès à l’espace partagé attribué.</p>{info.quota_bytes != null && <p className="mt-2 text-sm font-medium">Espace attribué : {fmtSize(info.quota_bytes)}</p>}</div>
|
: info ? <><div><h2 className="text-2xl font-semibold">Créer mon espace</h2><p className="mt-2 text-sm text-muted-foreground">Tu es invité dans l'arborescence de « {info.parent_name} ». Tu disposeras de « Mes fichiers », privé, et d’un accès à l’espace partagé attribué.</p></div>
|
||||||
<form onSubmit={submit} className="space-y-3">
|
<form onSubmit={submit} className="space-y-3">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="block text-sm font-medium">Prénom<Input required maxLength={150} autoComplete="given-name" value={firstName} onChange={e=>setFirstName(e.target.value)}/></label><label className="block text-sm font-medium">Nom<Input required maxLength={150} autoComplete="family-name" value={lastName} onChange={e=>setLastName(e.target.value)}/></label></div>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="block text-sm font-medium">Prénom<Input required maxLength={150} autoComplete="given-name" value={firstName} onChange={e=>setFirstName(e.target.value)}/></label><label className="block text-sm font-medium">Nom<Input required maxLength={150} autoComplete="family-name" value={lastName} onChange={e=>setLastName(e.target.value)}/></label></div>
|
||||||
<label className="block text-sm font-medium">Identifiant<Input required maxLength={150} autoComplete="username" value={username} onChange={e=>setUsername(e.target.value)} /></label>
|
<label className="block text-sm font-medium">Identifiant<Input required maxLength={150} autoComplete="username" value={username} onChange={e=>setUsername(e.target.value)} /></label>
|
||||||
|
|||||||
@ -84,9 +84,6 @@ export default function LoginPage({ onSigned }) {
|
|||||||
{!loading && <ArrowUpRight />}
|
{!loading && <ArrowUpRight />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<p className="mt-9 text-center text-xs text-muted-foreground">
|
|
||||||
Accès réservé aux comptes autorisés par l'administrateur.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -31,7 +31,6 @@ export default function MembersPage({ controller, user, embedded = false }) {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [info, setInfo] = useState('')
|
const [info, setInfo] = useState('')
|
||||||
const [expires, setExpires] = useState('7')
|
const [expires, setExpires] = useState('7')
|
||||||
const [invitationQuota, setInvitationQuota] = useState('')
|
|
||||||
const [rootQuota, setRootQuota] = useState('')
|
const [rootQuota, setRootQuota] = useState('')
|
||||||
const [userQuery, setUserQuery] = useState('')
|
const [userQuery, setUserQuery] = useState('')
|
||||||
const [userResults, setUserResults] = useState([])
|
const [userResults, setUserResults] = useState([])
|
||||||
@ -70,7 +69,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
|
|||||||
const create = async () => {
|
const create = async () => {
|
||||||
setBusy(true);setError('');setUrl('')
|
setBusy(true);setError('');setUrl('')
|
||||||
try {
|
try {
|
||||||
const inv = await drive.invite({ ...rights, expires_days:Number(expires), parent_space:parentId, quota_bytes:toQuota(invitationQuota) })
|
const inv = await drive.invite({ ...rights, expires_days:Number(expires), parent_space:parentId })
|
||||||
setUrl(`${window.location.origin}/invite/${inv.token}`)
|
setUrl(`${window.location.origin}/invite/${inv.token}`)
|
||||||
setInfo('Invitation créée ! Le lien n’est affiché qu’une seule fois : copie-le maintenant.')
|
setInfo('Invitation créée ! Le lien n’est affiché qu’une seule fois : copie-le maintenant.')
|
||||||
await reload()
|
await reload()
|
||||||
@ -145,16 +144,13 @@ export default function MembersPage({ controller, user, embedded = false }) {
|
|||||||
{info && <div className="rounded-xl border bg-muted p-3 text-sm">{info}</div>}
|
{info && <div className="rounded-xl border bg-muted p-3 text-sm">{info}</div>}
|
||||||
{embedded && selectedManagedSpace && <div className="rounded-2xl border bg-card p-4 text-sm">
|
{embedded && selectedManagedSpace && <div className="rounded-2xl border bg-card p-4 text-sm">
|
||||||
<p className="font-semibold">Gestion de « {selectedManagedSpace.display_name} »</p>
|
<p className="font-semibold">Gestion de « {selectedManagedSpace.display_name} »</p>
|
||||||
<p className="mt-1 text-muted-foreground">Les accès et invitations ci-dessous concernent uniquement cet espace.</p>
|
|
||||||
</div>}
|
</div>}
|
||||||
{!embedded && <div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 font-semibold">Nom de mon espace</h2>
|
{!embedded && <div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 font-semibold">Nom de mon espace</h2>
|
||||||
<div className="flex flex-wrap gap-2"><Input className="max-w-sm" value={title} onChange={e=>setTitle(e.target.value)} maxLength={120}/><Button disabled={busy||!title.trim()} onClick={rename}>Enregistrer</Button></div>
|
<div className="flex flex-wrap gap-2"><Input className="max-w-sm" value={title} onChange={e=>setTitle(e.target.value)} maxLength={120}/><Button disabled={busy||!title.trim()} onClick={rename}>Enregistrer</Button></div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">Le nom visible peut être modifié sans changer le dossier enregistré sur le serveur.</p>
|
|
||||||
</div>}
|
</div>}
|
||||||
{!embedded && user.is_admin && <div className="rounded-2xl border bg-card p-5 space-y-4">
|
{!embedded && user.is_admin && <div className="rounded-2xl border bg-card p-5 space-y-4">
|
||||||
<h2 className="font-semibold">Quota de mon espace racine</h2>
|
<h2 className="font-semibold">Quota de mon espace racine</h2>
|
||||||
<QuotaField id="root-quota" value={rootQuota} onChange={setRootQuota} disabled={busy}/>
|
<QuotaField id="root-quota" value={rootQuota} onChange={setRootQuota} disabled={busy}/>
|
||||||
<p className="text-xs text-muted-foreground">Utilisé avec tous les sous-espaces : {fmtSize(mine?.used_bytes || 0)}. La corbeille compte aussi.</p>
|
|
||||||
<Button variant="outline" disabled={busy} onClick={saveRootQuota}>Enregistrer la limite</Button>
|
<Button variant="outline" disabled={busy} onClick={saveRootQuota}>Enregistrer la limite</Button>
|
||||||
</div>}
|
</div>}
|
||||||
{managed.length > 0 ? <>
|
{managed.length > 0 ? <>
|
||||||
@ -189,19 +185,18 @@ export default function MembersPage({ controller, user, embedded = false }) {
|
|||||||
<div className="rounded-2xl border bg-card p-5 space-y-4">
|
<div className="rounded-2xl border bg-card p-5 space-y-4">
|
||||||
<div className="flex items-center gap-2 font-semibold"><KeyRound className="size-5 text-primary"/> Créer une invitation</div>
|
<div className="flex items-center gap-2 font-semibold"><KeyRound className="size-5 text-primary"/> Créer une invitation</div>
|
||||||
<Permissions value={rights} onChange={setRights}/>
|
<Permissions value={rights} onChange={setRights}/>
|
||||||
<QuotaField id="invitation-quota" value={invitationQuota} onChange={setInvitationQuota} disabled={busy}/>
|
|
||||||
<label className="block text-sm">Expiration du lien
|
<label className="block text-sm">Expiration du lien
|
||||||
<select value={expires} onChange={e=>setExpires(e.target.value)} className="ml-3 rounded-lg border bg-background px-2 py-1"><option value="1">1 jour</option><option value="7">7 jours</option><option value="14">14 jours</option><option value="30">30 jours</option></select>
|
<select value={expires} onChange={e=>setExpires(e.target.value)} className="ml-3 rounded-lg border bg-background px-2 py-1"><option value="1">1 jour</option><option value="7">7 jours</option><option value="14">14 jours</option><option value="30">30 jours</option></select>
|
||||||
</label>
|
</label>
|
||||||
<Button onClick={create} disabled={busy}><Link2/> Générer une invitation</Button>
|
<Button onClick={create} disabled={busy}><Link2/> Générer une invitation</Button>
|
||||||
{url && <div className="rounded-xl border bg-muted/50 p-3"><p className="mb-2 text-xs font-medium">Lien privé, à transmettre à la personne invitée</p><div className="flex gap-2"><Input readOnly value={url}/><Button variant="outline" onClick={async()=>{try{await navigator.clipboard.writeText(url);setInfo('Lien copié.')}catch{setInfo('Sélectionne et copie le lien affiché.')}}}><Copy/></Button></div></div>}
|
{url && <div className="rounded-xl border bg-muted/50 p-3"><div className="flex gap-2"><Input readOnly value={url}/><Button variant="outline" onClick={async()=>{try{await navigator.clipboard.writeText(url);setInfo('Lien copié.')}catch{setInfo('Sélectionne et copie le lien affiché.')}}}><Copy/></Button></div></div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border bg-card p-5 space-y-4"><h2 className="flex items-center gap-2 font-semibold"><Users className="size-5 text-primary"/> Sous-utilisateurs et sous-espaces</h2>
|
<div className="rounded-2xl border bg-card p-5 space-y-4"><h2 className="flex items-center gap-2 font-semibold"><Users className="size-5 text-primary"/> Sous-utilisateurs et sous-espaces</h2>
|
||||||
{!selectedRecord?.children?.length && <p className="text-sm text-muted-foreground">Aucun sous-utilisateur dans cet espace.</p>}
|
{!selectedRecord?.children?.length && <p className="text-sm text-muted-foreground">Aucun sous-utilisateur dans cet espace.</p>}
|
||||||
{selectedRecord?.children?.map(child=><MemberRow key={child.id} child={child} busy={busy} save={data=>change(child.id,data)} onManage={() => manageSubspace(child)} onInvalid={setError}/>)}
|
{selectedRecord?.children?.map(child=><MemberRow key={child.id} child={child} busy={busy} save={data=>change(child.id,data)} onManage={() => manageSubspace(child)} onInvalid={setError}/>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 flex items-center gap-2 font-semibold"><ShieldCheck className="size-5 text-primary"/> Invitations</h2>
|
<div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 flex items-center gap-2 font-semibold"><ShieldCheck className="size-5 text-primary"/> Invitations</h2>
|
||||||
<div className="space-y-2">{selectedRecord?.invitations?.map(inv=><div key={inv.id} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm"><span>Créée le {new Date(inv.created_at).toLocaleDateString('fr-FR')} · {inv.quota_bytes == null ? 'Sans limite dédiée' : fmtSize(inv.quota_bytes)} · {inv.used?'Utilisée':inv.revoked?'Révoquée':new Date(inv.expires_at)<new Date()?'Expirée':'En attente'}</span>{!inv.used&&!inv.revoked&&<Button size="icon-sm" variant="ghost" title="Révoquer" disabled={busy} onClick={()=>revoke(inv.id)}><Trash2 className="size-4"/></Button>}</div>)}</div>
|
<div className="space-y-2">{selectedRecord?.invitations?.map(inv=><div key={inv.id} className="flex items-center justify-between gap-2 rounded-lg border p-3 text-sm"><span>Créée le {new Date(inv.created_at).toLocaleDateString('fr-FR')} · {inv.used?'Utilisée':inv.revoked?'Révoquée':new Date(inv.expires_at)<new Date()?'Expirée':'En attente'}</span>{!inv.used&&!inv.revoked&&<Button size="icon-sm" variant="ghost" title="Révoquer" disabled={busy} onClick={()=>revoke(inv.id)}><Trash2 className="size-4"/></Button>}</div>)}</div>
|
||||||
</div>
|
</div>
|
||||||
</> : <p className="rounded-2xl border bg-card p-5 text-sm text-muted-foreground">La création de sous-utilisateurs n’est pas autorisée pour ton compte.</p>}
|
</> : <p className="rounded-2xl border bg-card p-5 text-sm text-muted-foreground">La création de sous-utilisateurs n’est pas autorisée pour ton compte.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user