This commit is contained in:
Louis Mazin 2026-10-14 02:35:51 +02:00
parent ac4c0f4d6a
commit d689a2253d
4 changed files with 50 additions and 21 deletions

View File

@ -53,7 +53,7 @@ export default function ContextActions({ menu, onClose, onChoose }) {
style={{left:x,top:y}}>
{menu.options.map(option => <button key={option.id} type="button" role="menuitem"
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-none hover:bg-accent focus:bg-accent ${option.danger ? 'text-destructive' : ''}`}
onClick={() => { onClose(); onChoose(option.id) }}>
onClick={() => { onClose(); onChoose(option.id, menu) }}>
<option.icon className="size-4 shrink-0" />{option.title}
</button>)}
</div>,document.body)

View File

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

View File

@ -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 <Dialog open={!!spaceDialog && ['manage','create'].includes(spaceDialog.mode)} onOpenChange={open=>{if(!open&&!busy)setSpaceDialog(null)}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{manage ? 'Gérer l’espace' : 'Créer un espace partagé'}</DialogTitle>
<DialogDescription>Le nom affiché est indépendant du dossier numéroté sur le serveur.</DialogDescription>
<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>
</DialogHeader>
<form onSubmit={save} className="space-y-4">
{!manage && <div className="space-y-2"><Label htmlFor="space-parent">Espace parent</Label>
@ -71,7 +74,7 @@ export default function SpaceSettingsDialog({controller: c}) {
{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>}
<Button disabled={busy||!name.trim()||(!manage&&!parent)} type="submit">{busy ? 'Enregistrement…' : manage ? 'Enregistrer' : 'Créer l’espace'}</Button>
<Button disabled={busy||!name.trim()||(manage&&!current)||(!manage&&!parent)} type="submit">{busy ? 'Enregistrement…' : manage ? 'Enregistrer' : 'Créer l’espace'}</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@ -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 && <div><h1 className="text-2xl font-semibold">Espaces & invitations</h1></div>}
{error && <div className="rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">{error}</div>}
{info && <div className="rounded-xl border bg-muted p-3 text-sm">{info}</div>}
<div className="rounded-2xl border bg-card p-5"><h2 className="mb-3 font-semibold">Nom de mon espace</h2>
{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>
{user.is_admin && <div className="rounded-2xl border bg-card p-5 space-y-4">
</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>
@ -160,7 +182,7 @@ export default function MembersPage({ controller, user, embedded = false }) {
<label className="flex items-center gap-2 text-sm"><Checkbox checked={directRights.can_invite} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_invite:!!v}))}/> Autoriser la gestion des utilisateurs de cet espace</label>
<Button disabled={busy} onClick={addExisting}>Ajouter cet utilisateur</Button>
</div>}
{!!record?.grants?.length && <div className="space-y-2 border-t pt-3"><h3 className="text-sm font-semibold">Utilisateurs ajoutés directement</h3>
{!!selectedRecord?.grants?.length && <div className="space-y-2 border-t pt-3"><h3 className="text-sm font-semibold">Utilisateurs ajoutés directement</h3>
{record.grants.map(g=><DirectMember key={g.id} grant={g} busy={busy} save={data=>updateGrant(g.id,data)} remove={()=>revokeGrant(g.id)}/>)}
</div>}
</div>
@ -175,24 +197,27 @@ export default function MembersPage({ controller, user, embedded = false }) {
{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>}
</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>
{!record?.children?.length && <p className="text-sm text-muted-foreground">Aucun sous-utilisateur dans cet espace.</p>}
{record?.children?.map(child=><MemberRow key={child.id} child={child} busy={busy} save={data=>change(child.id,data)} onInvalid={setError}/>)}
{!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">{record?.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.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>
</> : <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>
</section>
}
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 <div className="rounded-xl border p-4">
<div className="text-sm font-semibold">{child.display_name}</div>
<p className="mt-1 text-xs text-muted-foreground">Espace partagé indépendant · paramètres accessibles par clic droit dans « Espaces partagés ».</p>
if (!child.has_account) return <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-4">
<div className="min-w-0">
<div className="text-sm font-semibold">{child.display_name}</div>
<p className="mt-1 text-xs text-muted-foreground">Sous-espace partagé indépendant</p>
</div>
{child.can_invite && <Button type="button" size="sm" variant="outline" disabled={busy} onClick={onManage}>Gérer ce sous-espace</Button>}
</div>
return <div className="rounded-xl border p-4"><div className="mb-2 flex flex-wrap items-center justify-between gap-2"><div><div className="font-medium">{child.display_name}</div><div className="text-xs text-muted-foreground">{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}</div></div><div className="flex gap-2"><Button size="sm" variant="outline" disabled={busy} onClick={()=>{try{save({...rights,...(quota === fromQuota(child.quota_bytes) ? {} : {quota_bytes:toQuota(quota)})})}catch(e){onInvalid(e.message)}}}><Check className="size-4"/> Appliquer</Button><Button size="sm" variant="outline" disabled={busy} onClick={()=>save({is_active:!child.is_active})}>{child.is_active?'Suspendre':'Réactiver'}</Button></div></div><Permissions value={rights} onChange={setRights}/>
<div className="mt-3 border-t pt-3 space-y-3"><QuotaField id={`quota-${child.id}`} value={quota} onChange={setQuota} disabled={busy}/>