diff --git a/frontend/src/components/drive/ContextActions.jsx b/frontend/src/components/drive/ContextActions.jsx index c5aaa6b..3c64c36 100644 --- a/frontend/src/components/drive/ContextActions.jsx +++ b/frontend/src/components/drive/ContextActions.jsx @@ -53,7 +53,7 @@ export default function ContextActions({ menu, onClose, onChoose }) { style={{left:x,top:y}}> {menu.options.map(option => )} ,document.body) diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index 36cb6d6..d6e4654 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -19,8 +19,9 @@ export default function DriveSidebar({ controller, user, onLogout }) { const options=spaceOptions(space) if(options.length)setSpaceMenu({space,x,y,options}) } - const spaceCommand=command=>{ - const space=spaceMenu?.space + const spaceCommand=(command, selectedMenu)=>{ + // Use the space attached to the clicked menu, not a parent or a later selection. + const space=selectedMenu?.space if(!space)return if(command==='manage')setSpaceDialog({mode:'manage',spaceId:space.id}) if(command==='delete')setSpaceDialog({mode:'delete',spaceId:space.id}) diff --git a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx index 40e1e39..a3b9ba3 100644 --- a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx +++ b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx @@ -33,6 +33,7 @@ export default function SpaceSettingsDialog({controller: c}) { event.preventDefault() setBusy(true);setLocalError('') try { + if (manage && !current) throw new Error('Sous-espace introuvable. Actualise la page.') const data={display_name:name.trim(),quota_bytes:toQuota(quota)} const result=manage ? await drive.editSpace(current.id,data) : await drive.createSpace({...data,parent_space:Number(parent)}) await loadSpaces() @@ -51,8 +52,10 @@ export default function SpaceSettingsDialog({controller: c}) { return {if(!open&&!busy)setSpaceDialog(null)}}> - {manage ? 'Gérer l’espace' : 'Créer un espace partagé'} - Le nom affiché est indépendant du dossier numéroté sur le serveur. + {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 &&
@@ -71,7 +74,7 @@ export default function SpaceSettingsDialog({controller: c}) { {localError &&

{localError}

} {manage && current?.can_invite && } - + diff --git a/frontend/src/pages/MembersPage.jsx b/frontend/src/pages/MembersPage.jsx index 38fd147..1d5f89b 100644 --- a/frontend/src/pages/MembersPage.jsx +++ b/frontend/src/pages/MembersPage.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Check, Copy, KeyRound, Link2, ShieldCheck, Trash2, Users } from 'lucide-react' import { drive, message } from '@/api/client' import { Button } from '@/components/ui/button' @@ -20,9 +20,10 @@ function Permissions({ value, onChange, disabled = false }) { } export default function MembersPage({ controller, user, embedded = false }) { - const { spaces, activeSpaceId, loadSpaces, managedSpaceId } = controller + const { spaces, activeSpaceId, loadSpaces, managedSpaceId, setManagedSpaceId, setSpaceDialog } = controller const [parentId, setParentId] = useState(managedSpaceId || user.space_id) const [record, setRecord] = useState(null) + const reloadSequence = useRef(0) const [rights, setRights] = useState({can_view_parent:true,can_write_parent:false,can_invite:false}) const [url, setUrl] = useState('') const [title, setTitle] = useState('') @@ -40,15 +41,27 @@ export default function MembersPage({ controller, user, embedded = false }) { const mine = spaces.find(s => s.id === user.space_id) const managed = spaces.filter(s => !s.is_private && s.can_invite && (!embedded || s.id === managedSpaceId)) + const selectedManagedSpace = managed.find(space => space.id === parentId) + // Never render stale information from a previously selected parent space. + const selectedRecord = record?.parent?.id === parentId ? record : null const managedKey = managed.map(space => space.id).join(',') useEffect(() => { if (managedSpaceId) setParentId(managedSpaceId) }, [managedSpaceId]) useEffect(() => { if (managed.length && !managed.some(space => space.id === parentId)) setParentId(managed[0].id) }, [managedKey, parentId]) const reload = useCallback(async () => { + const sequence = ++reloadSequence.current if (!managedKey.split(',').includes(String(parentId))) {setRecord(null);return} - try { setRecord(await drive.members(parentId)); setError('') } - catch (e) { setError(message(e)) } + try { + const nextRecord = await drive.members(parentId) + if (sequence !== reloadSequence.current) return + // Responses for a previously selected space must never overwrite this one. + if (nextRecord?.parent?.id !== parentId) throw new Error('Réponse incohérente pour cet espace.') + setRecord(nextRecord) + setError('') + } catch (e) { + if (sequence === reloadSequence.current) setError(e?.response ? message(e) : e.message || message(e)) + } }, [parentId,managedKey]) useEffect(() => { reload() }, [reload]) useEffect(() => { setTitle(mine?.display_name || '') }, [mine?.display_name]) @@ -114,6 +127,11 @@ export default function MembersPage({ controller, user, embedded = false }) { try {await drive.grantRemove(grantId);await reload();setInfo('Accès révoqué.')} catch(e){setError(message(e))}finally{setBusy(false)} } + const manageSubspace = child => { + // Close the access dialog before opening the *child's* settings. + setManagedSpaceId(null) + setSpaceDialog({ mode: 'manage', spaceId: child.id }) + } const rename = async () => { setBusy(true) try {await drive.renameSpace(title);await loadSpaces();setInfo('Nom de ton espace enregistré.')} @@ -125,11 +143,15 @@ export default function MembersPage({ controller, user, embedded = false }) { {!embedded &&

Espaces & invitations

} {error &&
{error}
} {info &&
{info}
} -

Nom de mon espace

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

-
- {user.is_admin &&
+
} + {!embedded && user.is_admin &&

Quota de mon espace racine

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

@@ -160,7 +182,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
} - {!!record?.grants?.length &&

Utilisateurs ajoutés directement

+ {!!selectedRecord?.grants?.length &&

Utilisateurs ajoutés directement

{record.grants.map(g=>updateGrant(g.id,data)} remove={()=>revokeGrant(g.id)}/>)}
}
@@ -175,24 +197,27 @@ export default function MembersPage({ controller, user, embedded = false }) { {url &&

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

}

Sous-utilisateurs et sous-espaces

- {!record?.children?.length &&

Aucun sous-utilisateur dans cet espace.

} - {record?.children?.map(child=>change(child.id,data)} onInvalid={setError}/>)} + {!selectedRecord?.children?.length &&

Aucun sous-utilisateur dans cet espace.

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

Invitations

-
{record?.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.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&&}
)}
:

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

}
} -function MemberRow({child,save,busy,onInvalid}) { +function MemberRow({child,save,busy,onManage,onInvalid}) { const [rights,setRights]=useState({can_view_parent:child.can_view_parent,can_write_parent:child.can_write_parent,can_invite:child.can_invite}) const [quota,setQuota]=useState(fromQuota(child.quota_bytes)) useEffect(()=>{setRights({can_view_parent:child.can_view_parent,can_write_parent:child.can_write_parent,can_invite:child.can_invite});setQuota(fromQuota(child.quota_bytes))},[child]) - if (!child.has_account) return
-
{child.display_name}
-

Espace partagé indépendant · paramètres accessibles par clic droit dans « Espaces partagés ».

+ if (!child.has_account) return
+
+
{child.display_name}
+

Sous-espace partagé indépendant

+
+ {child.can_invite && }
return
{child.display_name}
{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}