nice
This commit is contained in:
parent
ac4c0f4d6a
commit
d689a2253d
@ -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)
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user