diff --git a/backend/drive/test_private_layout.py b/backend/drive/test_private_layout.py deleted file mode 100644 index fe8920a..0000000 --- a/backend/drive/test_private_layout.py +++ /dev/null @@ -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') diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py index 671062b..ca69f52 100644 --- a/backend/drive/views_spaces.py +++ b/backend/drive/views_spaces.py @@ -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']) diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index d6e4654..b80fc0d 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -140,7 +140,6 @@ export default function DriveSidebar({ controller, user, onLogout }) {

Fichiers actifs : {fmtSize(activeBytes)}

{trashBytes > 0 &&

Corbeille : {fmtSize(trashBytes)} (vider pour libérer)

} {reservedBytes > 0 &&

Transferts réservés : {fmtSize(reservedBytes)} (annuler pour libérer)

} -

Sous-espaces inclus. Le compteur correspond à l’espace réellement occupé.

diff --git a/frontend/src/components/drive/QuotaField.jsx b/frontend/src/components/drive/QuotaField.jsx index 9901bcb..ed51b28 100644 --- a/frontend/src/components/drive/QuotaField.jsx +++ b/frontend/src/components/drive/QuotaField.jsx @@ -13,6 +13,5 @@ export default function QuotaField({ value, onChange, disabled = false, id = 'qu Sans limite dédiée -

1 Go = 1 073 741 824 octets. Les limites des espaces parents restent applicables.

} diff --git a/frontend/src/components/drive/dialogs/ItemFormDialog.jsx b/frontend/src/components/drive/dialogs/ItemFormDialog.jsx index 40c9ff3..85e240c 100644 --- a/frontend/src/components/drive/dialogs/ItemFormDialog.jsx +++ b/frontend/src/components/drive/dialogs/ItemFormDialog.jsx @@ -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 ( - {if(!v)setModal(null)}}>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}{modal==='move'?'Choisis le dossier de destination.':'Les modifications seront sauvegardées immédiatement.'}
{modal==='move'?
{moveTrail.map((x,i)=>
)}

Destination : {moveTrail.at(-1)?.name||'Racine'}

{moveItems.filter(x=>x.id!==action?.id && x.can_write).map(x=>)}
:setValue(e.target.value)} placeholder="Nom"/>}
+ {if(!v)setModal(null)}}>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : 'Nouveau nom'}
{modal==='move'?
{moveTrail.map((x,i)=>
)}

Destination : {moveTrail.at(-1)?.name||'Racine'}

{moveItems.filter(x=>x.id!==action?.id && x.can_write).map(x=>)}
:setValue(e.target.value)} placeholder="Nom"/>}
) } diff --git a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx index a3b9ba3..e63450c 100644 --- a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx +++ b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx @@ -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}) { {manage ? `Gérer l’espace « ${current?.display_name || 'introuvable'} »` : 'Créer un espace partagé'} - {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.'} + {manage ? 'Paramètres de cet espace' : 'Paramètres du nouvel espace'}
{!manage &&
@@ -66,11 +59,6 @@ export default function SpaceSettingsDialog({controller: c}) {
setName(e.target.value)} maxLength={120} required placeholder="Ex. Équipe projet" />
-

- {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.} -

{localError &&

{localError}

} {manage && current?.can_invite && } diff --git a/frontend/src/pages/InvitationPage.jsx b/frontend/src/pages/InvitationPage.jsx index 55bad7f..fa5fecc 100644 --- a/frontend/src/pages/InvitationPage.jsx +++ b/frontend/src/pages/InvitationPage.jsx @@ -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" />

GoodDrive

Un espace pour ceux qui en ont besoin.

{done ?

Bienvenue sur GoodDrive !

Ton espace personnel et ton espace partagé sont prêts. Tu peux maintenant te connecter.

- : info ? <>

Créer mon espace

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é.

{info.quota_bytes != null &&

Espace attribué : {fmtSize(info.quota_bytes)}

}
+ : info ? <>

Créer mon espace

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é.

diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index aa423f7..ece313f 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -84,9 +84,6 @@ export default function LoginPage({ onSigned }) { {!loading && } -

- Accès réservé aux comptes autorisés par l'administrateur. -

diff --git a/frontend/src/pages/MembersPage.jsx b/frontend/src/pages/MembersPage.jsx index 1d5f89b..91268bb 100644 --- a/frontend/src/pages/MembersPage.jsx +++ b/frontend/src/pages/MembersPage.jsx @@ -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 &&
{info}
} {embedded && selectedManagedSpace &&

Gestion de « {selectedManagedSpace.display_name} »

-

Les accès et invitations ci-dessous concernent uniquement cet espace.

} {!embedded &&

Nom de mon espace

setTitle(e.target.value)} maxLength={120}/>
-

Le nom visible peut être modifié sans changer le dossier enregistré sur le serveur.

} {!embedded && user.is_admin &&

Quota de mon espace racine

-

Utilisé avec tous les sous-espaces : {fmtSize(mine?.used_bytes || 0)}. La corbeille compte aussi.

} {managed.length > 0 ? <> @@ -189,19 +185,18 @@ export default function MembersPage({ controller, user, embedded = false }) {
Créer une invitation
- - {url &&

Lien privé, à transmettre à la personne invitée

} + {url &&
}

Sous-utilisateurs et sous-espaces

{!selectedRecord?.children?.length &&

Aucun sous-utilisateur dans cet espace.

} {selectedRecord?.children?.map(child=>change(child.id,data)} onManage={() => manageSubspace(child)} onInvalid={setError}/>)}

Invitations

-
{selectedRecord?.invitations?.map(inv=>
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){!inv.used&&!inv.revoked&&}
)}
+
{selectedRecord?.invitations?.map(inv=>
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){!inv.used&&!inv.revoked&&}
)}
:

La création de sous-utilisateurs n’est pas autorisée pour ton compte.

}