This commit is contained in:
Louis Mazin 2026-10-14 02:46:51 +02:00
parent d689a2253d
commit 8f99ce8961
9 changed files with 11 additions and 267 deletions

View File

@ -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')

View File

@ -36,7 +36,7 @@ def invitation_info(inv):
'expires_at':inv.expires_at.isoformat(), 'used':bool(inv.used_at),
'revoked':bool(inv.revoked_at),
'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'])
@ -154,15 +154,11 @@ def create_invitation(request):
try:days=int(request.data.get('expires_days',7))
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.')
try:quota=parse_quota(request.data.get('quota_bytes'))
except ValueError as ex:return error(str(ex))
with transaction.atomic():
with locked_quota_tree(parent):
try:assert_new_child_quota(parent,quota)
except ValueError as exc:return error(str(exc))
# An invitation grants access. Storage limits are configured on spaces,
# not on invitation links (the private space always has its 50-Go cap).
token=secrets.token_urlsafe(32)
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.
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_write_parent':inv.can_write_parent,
'can_invite':inv.can_invite,
'quota_bytes':inv.quota_bytes,
'expires_at':inv.expires_at.isoformat()})
@ -229,12 +224,10 @@ def accept_invitation(request,token):
inv=get_valid_invitation(token,lock=True)
if inv is None:return error('Invitation invalide ou déjà utilisée.',404)
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)
space=create_space(user,inv.parent_space,display_name,
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)
inv.used_by=user;inv.used_at=timezone.now()
inv.save(update_fields=['used_by','used_at'])

View File

@ -140,7 +140,6 @@ export default function DriveSidebar({ controller, user, onLogout }) {
<p>Fichiers actifs : {fmtSize(activeBytes)}</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>}
<p className="text-[10px]">Sous-espaces inclus. Le compteur correspond à l’espace réellement occupé.</p>
</div>
</div>

View File

@ -13,6 +13,5 @@ export default function QuotaField({ value, onChange, disabled = false, id = 'qu
Sans limite dédiée
</label>
</div>
<p className="text-xs text-muted-foreground">1 Go = 1 073 741 824 octets. Les limites des espaces parents restent applicables.</p>
</div>
}

View File

@ -6,6 +6,6 @@ import { HardDrive, ChevronRight, Folder } from 'lucide-react'
export default function ItemFormDialog({controller: c}) {
const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c
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>
)
}

View File

@ -6,7 +6,6 @@ import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import QuotaField from '@/components/drive/QuotaField'
import {fromQuota,toQuota} from '@/lib/quota-utils'
import {fmtSize} from '@/lib/drive-format'
export default function SpaceSettingsDialog({controller: 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 [busy,setBusy] = useState(false)
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(() => {
setName(manage ? current?.display_name || '' : '')
setQuota(manage ? fromQuota(current?.quota_bytes) : '')
@ -53,9 +48,7 @@ export default function SpaceSettingsDialog({controller: c}) {
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{manage ? `Gérer l’espace « ${current?.display_name || 'introuvable'} »` : 'Créer un espace partagé'}</DialogTitle>
<DialogDescription>{manage && current?.parent_id
? `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>
<DialogDescription className="sr-only">{manage ? 'Paramètres de cet espace' : 'Paramètres du nouvel espace'}</DialogDescription>
</DialogHeader>
<form onSubmit={save} className="space-y-4">
{!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>
<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} />
<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>}
<DialogFooter className="gap-2 sm:justify-between">
{manage && current?.can_invite && <Button type="button" variant="outline" onClick={members}>Gérer les accès</Button>}

View File

@ -3,7 +3,6 @@ import { ArrowRight, LoaderCircle } from 'lucide-react'
import { drive, message } from '@/api/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {fmtSize} from '@/lib/drive-format'
export default function InvitationPage({ token }) {
const [info, setInfo] = useState(null)
@ -41,7 +40,7 @@ export default function InvitationPage({ token }) {
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>
{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">
<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>

View File

@ -84,9 +84,6 @@ export default function LoginPage({ onSigned }) {
{!loading && <ArrowUpRight />}
</Button>
</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>

View File

@ -31,7 +31,6 @@ export default function MembersPage({ controller, user, embedded = false }) {
const [error, setError] = useState('')
const [info, setInfo] = useState('')
const [expires, setExpires] = useState('7')
const [invitationQuota, setInvitationQuota] = useState('')
const [rootQuota, setRootQuota] = useState('')
const [userQuery, setUserQuery] = useState('')
const [userResults, setUserResults] = useState([])
@ -70,7 +69,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
const create = async () => {
setBusy(true);setError('');setUrl('')
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}`)
setInfo('Invitation créée ! Le lien n’est affiché qu’une seule fois : copie-le maintenant.')
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>}
{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="mt-1 text-muted-foreground">Les accès et invitations ci-dessous concernent uniquement cet espace.</p>
</div>}
{!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>
<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>}
{!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>
<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>
</div>}
{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="flex items-center gap-2 font-semibold"><KeyRound className="size-5 text-primary"/> Créer une invitation</div>
<Permissions value={rights} onChange={setRights}/>
<QuotaField id="invitation-quota" value={invitationQuota} onChange={setInvitationQuota} disabled={busy}/>
<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>
</label>
<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 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?.map(child=><MemberRow key={child.id} child={child} busy={busy} save={data=>change(child.id,data)} onManage={() => manageSubspace(child)} onInvalid={setError}/>)}
</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="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>
</> : <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>