228 lines
16 KiB
JavaScript
228 lines
16 KiB
JavaScript
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'
|
||
import { Input } from '@/components/ui/input'
|
||
import QuotaField from '@/components/drive/QuotaField'
|
||
import {fromQuota, toQuota} from '@/lib/quota-utils'
|
||
import {fmtSize} from '@/lib/drive-format'
|
||
import PermissionsEditor, {PRESETS} from '@/components/drive/PermissionsEditor'
|
||
|
||
export default function MembersPage({ controller, user, embedded = false }) {
|
||
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(PRESETS.reader)
|
||
const [url, setUrl] = useState('')
|
||
const [title, setTitle] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [info, setInfo] = useState('')
|
||
const [expires, setExpires] = useState('7')
|
||
const [rootQuota, setRootQuota] = useState('')
|
||
const [userQuery, setUserQuery] = useState('')
|
||
const [userResults, setUserResults] = useState([])
|
||
const [searching, setSearching] = useState(false)
|
||
const [selectedAccount, setSelectedAccount] = useState(null)
|
||
const [directRights, setDirectRights] = useState(PRESETS.reader)
|
||
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
|
||
// An account must never appear among its own directly added members.
|
||
const directGrants = (selectedRecord?.grants || []).filter(grant =>
|
||
Number(grant.user_id) !== Number(user.id) &&
|
||
Number(grant.user_id) !== Number(selectedRecord.parent.user_id)
|
||
)
|
||
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 {
|
||
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])
|
||
useEffect(() => { setRootQuota(fromQuota(mine?.quota_bytes)) }, [mine?.quota_bytes])
|
||
|
||
const create = async () => {
|
||
setBusy(true);setError('');setUrl('')
|
||
try {
|
||
const inv = await drive.invite({ permissions: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()
|
||
} catch(e) { setError(e?.response ? message(e) : (e.message || message(e))) } finally { setBusy(false) }
|
||
}
|
||
const change = async (id, data) => {
|
||
setBusy(true);setError('')
|
||
try {await drive.memberPatch(id,data);await reload();await loadSpaces();controller.reloadStats();setInfo('Paramètres de l’espace mis à jour.')}
|
||
catch(e){setError(e?.response ? message(e) : (e.message || message(e)))}finally{setBusy(false)}
|
||
}
|
||
const revoke = async id => {
|
||
setBusy(true)
|
||
try {await drive.revokeInvite(id);await reload();setInfo('Invitation révoquée.')}
|
||
catch(e){setError(message(e))}finally{setBusy(false)}
|
||
}
|
||
const saveRootQuota = async () => {
|
||
setBusy(true);setError('')
|
||
try {
|
||
await drive.spaceQuota(user.space_id, toQuota(rootQuota))
|
||
await loadSpaces(); await reload(); controller.reloadStats()
|
||
setInfo('Limite de stockage enregistrée.')
|
||
} catch(e) {setError(e?.response ? message(e) : (e.message || message(e)))}
|
||
finally {setBusy(false)}
|
||
}
|
||
useEffect(() => {
|
||
if (selectedAccount?.username === userQuery || userQuery.trim().length < 2) { setUserResults([]); return }
|
||
let alive = true
|
||
const timer = setTimeout(async () => {
|
||
setSearching(true)
|
||
try { const users = await drive.searchUsers(userQuery.trim(), parentId); if(alive) setUserResults(users) }
|
||
catch(e) { if(alive) setError(message(e)) }
|
||
finally { if(alive) setSearching(false) }
|
||
}, 300)
|
||
return () => { alive = false; clearTimeout(timer) }
|
||
}, [userQuery, parentId, selectedAccount?.username])
|
||
const addExisting = async () => {
|
||
if (!selectedAccount) return
|
||
setBusy(true); setError('')
|
||
try {
|
||
await drive.addUser(selectedAccount.id,parentId,directRights)
|
||
setInfo(`Accès accordé à ${selectedAccount.first_name} ${selectedAccount.last_name}`.trim())
|
||
setSelectedAccount(null);setUserQuery('');setUserResults([])
|
||
await reload();await loadSpaces()
|
||
} catch(e) { setError(message(e)) }
|
||
finally { setBusy(false) }
|
||
}
|
||
const updateGrant = async (grantId, data) => {
|
||
setBusy(true);setError('')
|
||
try { await drive.grantPatch(grantId,data);await reload();setInfo('Droits mis à jour.') }
|
||
catch(e){setError(message(e))}finally{setBusy(false)}
|
||
}
|
||
const revokeGrant = async grantId => {
|
||
setBusy(true);setError('')
|
||
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é.')}
|
||
catch(e){setError(message(e))}finally{setBusy(false)}
|
||
}
|
||
|
||
return <section className={embedded ? "min-h-0 flex-1 overflow-y-auto p-4 sm:p-6" : "min-h-0 flex-1 overflow-y-auto p-4 sm:p-7"}>
|
||
<div className="mx-auto max-w-5xl space-y-6">
|
||
{!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>}
|
||
{embedded && selectedManagedSpace && <div className="rounded-2xl border bg-card p-4 text-sm">
|
||
<p className="font-semibold">Gestion de « {selectedManagedSpace.display_name} »</p>
|
||
</div>}
|
||
{!embedded && mine?.user_id === user.id && <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>
|
||
</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}/>
|
||
<Button variant="outline" disabled={busy} onClick={saveRootQuota}>Enregistrer la limite</Button>
|
||
</div>}
|
||
{managed.length > 0 ? <>
|
||
{managed.length > 1 && <div className="rounded-2xl border bg-card p-5">
|
||
<label className="block text-sm font-semibold">Espace à gérer
|
||
<select className="mt-2 w-full rounded-lg border bg-background p-2" value={parentId} onChange={e=>{setParentId(Number(e.target.value));setSelectedAccount(null);setUserQuery('')}}>
|
||
{managed.map(space=><option key={space.id} value={space.id}>{space.display_name}</option>)}
|
||
</select>
|
||
</label>
|
||
</div>}
|
||
<div className="rounded-2xl border bg-card p-5 space-y-4">
|
||
<div className="flex items-center gap-2 font-semibold"><Users className="size-5 text-primary"/> Ajouter un utilisateur existant</div>
|
||
<p className="text-sm text-muted-foreground">Cherche son identifiant, vérifie son nom et prénom, puis autorise l'accès à cet espace. Ses fichiers personnels restent privés.</p>
|
||
<Input value={userQuery} onChange={e=>{setUserQuery(e.target.value);setSelectedAccount(null)}} placeholder="Rechercher par nom d’utilisateur…" aria-label="Rechercher un utilisateur" />
|
||
{searching && <p className="text-xs text-muted-foreground">Recherche en cours…</p>}
|
||
{!!userResults.length && <div className="max-h-52 space-y-1 overflow-auto rounded-lg border p-1" role="listbox" aria-label="Utilisateurs trouvés">
|
||
{userResults.map(account=><button type="button" key={account.id} onClick={()=>{setSelectedAccount(account);setUserQuery(account.username);setUserResults([])}} className="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-muted">
|
||
<span className="font-medium">{[account.first_name,account.last_name].filter(Boolean).join(' ') || account.username}</span>
|
||
<span className="ml-2 text-muted-foreground">@{account.username}</span>
|
||
</button>)}
|
||
</div>}
|
||
{selectedAccount && <div className="space-y-3 rounded-xl border p-3">
|
||
<p className="text-sm">Utilisateur sélectionné : <strong>{[selectedAccount.first_name,selectedAccount.last_name].filter(Boolean).join(' ')}</strong> (@{selectedAccount.username})</p>
|
||
<PermissionsEditor value={directRights} onChange={setDirectRights} maxRights={selectedManagedSpace?.permissions}/>
|
||
<Button disabled={busy} onClick={addExisting}>Ajouter cet utilisateur</Button>
|
||
</div>}
|
||
{!!directGrants.length && <div className="space-y-2 border-t pt-3"><h3 className="text-sm font-semibold">Utilisateurs ajoutés directement</h3>
|
||
{directGrants.map(g=><DirectMember key={g.id} grant={g} maxRights={selectedManagedSpace?.permissions} busy={busy} save={data=>updateGrant(g.id,data)} remove={()=>revokeGrant(g.id)}/>)}
|
||
</div>}
|
||
</div>
|
||
<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>
|
||
<PermissionsEditor value={rights} onChange={setRights} maxRights={selectedManagedSpace?.permissions}/>
|
||
<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"><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} maxRights={selectedManagedSpace} 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.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,onManage,onInvalid,maxRights}) {
|
||
const [rights,setRights]=useState(child.parent_permissions || PRESETS.reader)
|
||
const [quota,setQuota]=useState(fromQuota(child.quota_bytes))
|
||
useEffect(()=>{setRights(child.parent_permissions || PRESETS.reader);setQuota(fromQuota(child.quota_bytes))},[child])
|
||
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({permissions:rights,...(!child.can_edit_quota || 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><PermissionsEditor value={rights} onChange={setRights} maxRights={maxRights?.permissions}/>
|
||
<div className="mt-3 border-t pt-3 space-y-3">
|
||
{child.can_edit_quota && <QuotaField id={`quota-${child.id}`} value={quota} onChange={setQuota} disabled={busy}/>}
|
||
<p className="text-xs text-muted-foreground">Limite : {child.quota_bytes == null ? 'Sans limite' : fmtSize(child.quota_bytes)} · Utilisé : {fmtSize(child.used_bytes || 0)}{child.reserved_bytes > 0 ? ` · Transferts réservés : ${fmtSize(child.reserved_bytes)}` : ''} · Disponible : {child.available_bytes == null ? 'Sans limite' : fmtSize(child.available_bytes)}</p>
|
||
</div></div>
|
||
}
|
||
|
||
|
||
function DirectMember({grant,save,remove,busy,maxRights}) {
|
||
const [permissions,setPermissions]=useState(grant.permissions || PRESETS.reader)
|
||
useEffect(()=>setPermissions(grant.permissions || PRESETS.reader),[grant])
|
||
return <div className="space-y-3 rounded-xl border p-3">
|
||
<div className="flex items-center justify-between gap-3"><div><p className="text-sm font-semibold">{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}</p><p className="text-xs text-muted-foreground">@{grant.username}</p></div>
|
||
<Button size="icon-sm" variant="ghost" disabled={busy} onClick={remove} aria-label="Retirer l’accès"><Trash2 className="size-4"/></Button></div>
|
||
<PermissionsEditor value={permissions} maxRights={maxRights} onChange={setPermissions}/>
|
||
<Button size="sm" variant="outline" disabled={busy} onClick={()=>save({permissions})}>Enregistrer les droits</Button>
|
||
</div>
|
||
}
|