GooDrive/frontend/src/pages/MembersPage.jsx
Louis Mazin 9834626cf4 nice
2026-10-14 17:57:24 +02:00

228 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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