GooDrive/frontend/src/pages/MembersPage.jsx
Louis Mazin c11c661897 test
2026-10-14 15:58:46 +02:00

238 lines
18 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 { Checkbox } from '@/components/ui/checkbox'
import QuotaField from '@/components/drive/QuotaField'
import {fromQuota, toQuota} from '@/lib/quota-utils'
import {fmtSize} from '@/lib/drive-format'
const options = [
{ key: 'can_view_parent', label: 'Voir les fichiers du parent' },
{ key: 'can_write_parent', label: 'Modifier les fichiers du parent' },
{ key: 'can_invite', label: 'Créer des sous-utilisateurs' },
]
function Permissions({ value, onChange, disabled = false, maxRights = {}, fixedRead = false }) {
return <div className="space-y-2">{options.map(opt => <label key={opt.key} className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1 text-sm">
<Checkbox disabled={disabled || maxRights[opt.key] === false || (fixedRead && opt.key === 'can_view_parent')} checked={Boolean(value[opt.key])} onCheckedChange={checked => onChange({ ...value, [opt.key]:Boolean(checked), ...(opt.key==='can_view_parent' && !checked ? {can_write_parent:false} : opt.key==='can_write_parent' && checked ? {can_view_parent:true} : {}) })}/>{opt.label}
</label>)}</div>
}
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({can_view_parent:true,can_write_parent:false,can_invite:false})
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({can_write:false,can_invite: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 {
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({ ...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.can_write,directRights.can_invite)
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>
<label className="flex items-center gap-2 text-sm"><Checkbox disabled={!selectedManagedSpace?.can_write} checked={directRights.can_write} onCheckedChange={v=>setDirectRights(prev=>({...prev,can_write:!!v}))}/> Autoriser les modifications dans cet espace</label>
<label className="flex items-center gap-2 text-sm"><Checkbox disabled={!selectedManagedSpace?.can_invite} 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>}
{!!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} maxRights={selectedManagedSpace} 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>
<Permissions value={rights} onChange={setRights} fixedRead maxRights={{can_view_parent:true,can_write_parent:!!selectedManagedSpace?.can_write,can_invite:!!selectedManagedSpace?.can_invite}}/>
<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({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="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} maxRights={{can_write_parent:!!maxRights?.can_write,can_invite:!!maxRights?.can_invite}}/>
<div className="mt-3 border-t pt-3 space-y-3"><QuotaField id={`quota-${child.id}`} value={quota} onChange={setQuota} disabled={busy}/>
<p className="text-xs text-muted-foreground">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 [canWrite,setCanWrite] = useState(grant.can_write)
const [canInvite,setCanInvite] = useState(grant.can_invite)
useEffect(()=>{setCanWrite(grant.can_write);setCanInvite(grant.can_invite)},[grant])
return <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-3">
<div className="min-w-36"><div className="text-sm font-semibold">{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}</div><div className="text-xs text-muted-foreground">@{grant.username}</div></div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-xs"><Checkbox disabled={!maxRights?.can_write} checked={canWrite} onCheckedChange={v=>setCanWrite(!!v)}/> Écriture</label>
<label className="flex items-center gap-2 text-xs"><Checkbox disabled={!maxRights?.can_invite} checked={canInvite} onCheckedChange={v=>setCanInvite(!!v)}/> Invitations</label>
<Button size="sm" variant="outline" disabled={busy} onClick={()=>save({can_write:canWrite,can_invite:canInvite})}>Enregistrer</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={remove} title="Retirer l’accès"><Trash2 className="size-4" /></Button>
</div>
</div>
}