ultisuite-client/components/admin/settings/sections/users-section.tsx
2026-06-07 21:55:42 +02:00

569 lines
18 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, UserPlus } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useAdminUser, useAdminUsers } from "@/lib/api/hooks/use-admin-queries"
import {
useDeleteAdminUser,
useDisableAdminUser,
useInviteAdminUser,
useReactivateAdminUser,
useSetAdminUserQuota,
useSetAdminUserRole,
useUpdateAdminUser,
} from "@/lib/api/hooks/use-admin-mutations"
import type { AdminUser, AdminUserRole } from "@/lib/api/admin-types"
import { bytesToGib, formatBytes, gibToBytes } from "@/lib/admin/format-bytes"
import {
resolveUserRole,
USER_ROLE_DESCRIPTIONS,
USER_ROLE_FILTER_ALL_ICON,
USER_ROLE_ICONS,
USER_ROLE_LABELS,
} from "@/lib/admin/user-role"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const ROLE_OPTIONS: AdminUserRole[] = ["admin", "user", "guest", "suspended"]
export function UsersSection() {
const [q, setQ] = useState("")
const [role, setRole] = useState<string>("all")
const [page, setPage] = useState(1)
const [inviteOpen, setInviteOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const queryParams = useMemo(
() => ({
page,
page_size: 25,
q: q.trim() || undefined,
role: role === "all" ? undefined : role,
}),
[page, q, role]
)
const { data, isFetching, isError, refetch } = useAdminUsers(queryParams)
const users = data?.users ?? []
const total = data?.pagination.total ?? 0
const pageSize = data?.pagination.page_size ?? 25
const totalPages = Math.max(1, Math.ceil(total / pageSize))
return (
<>
<SettingsSectionHeader
title="Utilisateurs"
description="Comptes, types d'utilisateur, invitations et quotas."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className="mb-4 flex flex-wrap items-end gap-3">
<div className="min-w-[200px] flex-1">
<Label className="text-xs">Recherche</Label>
<Input
className="mt-1 h-9"
value={q}
onChange={(e) => {
setQ(e.target.value)
setPage(1)
}}
placeholder="E-mail, nom ou ID externe"
/>
</div>
<div className="w-52">
<Label className="text-xs">Type d&apos;utilisateur</Label>
<Select
value={role}
onValueChange={(v) => {
setRole(v)
setPage(1)
}}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue>
{role === "all" ? (
<UserRoleLabel role="all" />
) : (
<UserRoleLabel role={role as AdminUserRole} />
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<UserRoleLabel role="all" />
</SelectItem>
{ROLE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
<UserRoleLabel role={option} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button className="h-9" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-2 size-4" />
Inviter
</Button>
</div>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Utilisateur</TableHead>
<TableHead>Type</TableHead>
<TableHead className="hidden lg:table-cell">Mail</TableHead>
<TableHead className="hidden lg:table-cell">Drive</TableHead>
<TableHead className="hidden md:table-cell">ID externe</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
Aucun utilisateur trouvé.
</TableCell>
</TableRow>
) : (
users.map((user) => (
<UserRow
key={user.id}
user={user}
onOpen={() => setSelectedId(user.id)}
/>
))
)}
</TableBody>
</Table>
</div>
{totalPages > 1 ? (
<div className="mt-4 flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{total.toLocaleString("fr-FR")} utilisateur(s)
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Suivant
</Button>
</div>
</div>
) : null}
<InviteUserDialog open={inviteOpen} onOpenChange={setInviteOpen} />
<UserDetailSheet userId={selectedId} onClose={() => setSelectedId(null)} />
</>
)
}
function UserRow({ user, onOpen }: { user: AdminUser; onOpen: () => void }) {
const disableUser = useDisableAdminUser()
const reactivateUser = useReactivateAdminUser()
const deleteUser = useDeleteAdminUser()
return (
<TableRow className="cursor-pointer" onClick={onOpen}>
<TableCell>
<div className="font-medium">{user.name || "—"}</div>
<div className="text-xs text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell>
<RoleBadge role={resolveUserRole(user)} />
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
{formatBytes(user.storage?.mail_used_bytes ?? 0)}
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
{formatBytes(user.storage?.drive_used_bytes ?? 0)}
</TableCell>
<TableCell className="hidden max-w-[200px] truncate font-mono text-xs md:table-cell">
{user.external_id}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onOpen}>Détails et quotas</DropdownMenuItem>
{resolveUserRole(user) !== "suspended" ? (
<DropdownMenuItem
onClick={() => void disableUser.mutateAsync(user.id)}
>
Suspendre
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => void reactivateUser.mutateAsync(user.id)}
>
Réactiver
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => {
if (confirm(`Supprimer définitivement ${user.email} ?`)) {
void deleteUser.mutateAsync(user.id)
}
}}
>
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
}
function UserRoleLabel({
role,
className,
}: {
role: AdminUserRole | "all"
className?: string
}) {
const Icon =
role === "all" ? USER_ROLE_FILTER_ALL_ICON : USER_ROLE_ICONS[role]
const label = role === "all" ? "Tous les types" : USER_ROLE_LABELS[role]
return (
<span className={cn("flex items-center gap-2", className)}>
<Icon className="size-4 shrink-0 opacity-80" aria-hidden />
<span>{label}</span>
</span>
)
}
function RoleBadge({ role }: { role: AdminUserRole }) {
const variant =
role === "admin"
? "default"
: role === "user"
? "secondary"
: role === "guest"
? "outline"
: "destructive"
const Icon = USER_ROLE_ICONS[role]
return (
<Badge variant={variant} className="gap-1.5 pr-2.5">
<Icon className="size-3.5" aria-hidden />
{USER_ROLE_LABELS[role] ?? role}
</Badge>
)
}
function InviteUserDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const invite = useInviteAdminUser()
const [email, setEmail] = useState("")
const [name, setName] = useState("")
async function submit() {
await invite.mutateAsync({ email, name: name || undefined })
setEmail("")
setName("")
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Inviter un utilisateur</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>E-mail</Label>
<Input
className="mt-1"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<Label>Nom (optionnel)</Label>
<Input
className="mt-1"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button disabled={!email || invite.isPending} onClick={() => void submit()}>
Envoyer l&apos;invitation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function UserDetailSheet({
userId,
onClose,
}: {
userId: string | null
onClose: () => void
}) {
const { data: user, isFetching } = useAdminUser(userId)
const updateUser = useUpdateAdminUser(userId ?? "")
const setRole = useSetAdminUserRole(userId ?? "")
const setQuota = useSetAdminUserQuota(userId ?? "")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [selectedRole, setSelectedRole] = useState<AdminUserRole>("user")
const [mailGib, setMailGib] = useState("5")
const [driveGib, setDriveGib] = useState("5")
const [photosGib, setPhotosGib] = useState("5")
const open = Boolean(userId)
useEffect(() => {
if (!user) return
setName(user.name ?? "")
setEmail(user.email ?? "")
setSelectedRole(resolveUserRole(user))
if (user.quota) {
setMailGib(String(bytesToGib(user.quota.mail.max_storage_bytes).toFixed(1)))
setDriveGib(String(bytesToGib(user.quota.drive.max_storage_bytes).toFixed(1)))
setPhotosGib(String(bytesToGib(user.quota.photos.max_storage_bytes).toFixed(1)))
}
}, [user])
async function saveProfile() {
if (!userId) return
await updateUser.mutateAsync({ name, email })
}
async function saveRole() {
if (!userId || !user || selectedRole === resolveUserRole(user)) return
await setRole.mutateAsync({ role: selectedRole })
}
async function saveQuotas() {
if (!userId) return
await setQuota.mutateAsync({
mail_max_storage_bytes: gibToBytes(Number(mailGib)),
drive_max_storage_bytes: gibToBytes(Number(driveGib)),
photos_max_storage_bytes: gibToBytes(Number(photosGib)),
})
}
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent className="flex flex-col gap-0 overflow-y-auto p-0 sm:max-w-lg">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>Détails utilisateur</SheetTitle>
</SheetHeader>
{isFetching || !user ? (
<p className="px-6 py-5 text-sm text-muted-foreground">Chargement</p>
) : (
<div className="space-y-8 px-6 py-6">
<div className="space-y-4">
<RoleBadge role={resolveUserRole(user)} />
<div className="space-y-2">
<Label>Type d&apos;utilisateur</Label>
<Select
value={selectedRole}
onValueChange={(v) => setSelectedRole(v as AdminUserRole)}
>
<SelectTrigger>
<SelectValue>
<UserRoleLabel role={selectedRole} />
</SelectValue>
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
<UserRoleLabel role={option} />
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{USER_ROLE_DESCRIPTIONS[selectedRole]}
</p>
<Button
size="sm"
variant="secondary"
onClick={() => void saveRole()}
disabled={setRole.isPending || selectedRole === resolveUserRole(user)}
>
Enregistrer le type
</Button>
</div>
<div className="space-y-2">
<Label>Nom</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>E-mail</Label>
<Input value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<Button size="sm" onClick={() => void saveProfile()} disabled={updateUser.isPending}>
Mettre à jour le profil
</Button>
</div>
{user.quota ? (
<div className="space-y-5 rounded-lg border bg-muted/20 p-5">
<h3 className="text-sm font-medium">Quotas stockage</h3>
<QuotaField
label="Mail"
used={coerceBytes(user.quota.mail.used_storage_bytes)}
max={coerceBytes(user.quota.mail.max_storage_bytes)}
gib={mailGib}
onGibChange={setMailGib}
count={user.quota.mail.count}
/>
<QuotaField
label="Drive"
used={coerceBytes(user.quota.drive.used_storage_bytes)}
max={coerceBytes(user.quota.drive.max_storage_bytes)}
gib={driveGib}
onGibChange={setDriveGib}
/>
<QuotaField
label="Photos"
used={coerceBytes(user.quota.photos.used_storage_bytes)}
max={coerceBytes(user.quota.photos.max_storage_bytes)}
gib={photosGib}
onGibChange={setPhotosGib}
/>
<Button size="sm" onClick={() => void saveQuotas()} disabled={setQuota.isPending}>
Enregistrer les quotas
</Button>
</div>
) : null}
<p className="font-mono text-xs text-muted-foreground">ID : {user.id}</p>
</div>
)}
</SheetContent>
</Sheet>
)
}
function coerceBytes(value: unknown): number {
const n = typeof value === "number" ? value : Number(value)
return Number.isFinite(n) && n >= 0 ? n : 0
}
function QuotaField({
label,
used,
max,
gib,
onGibChange,
count,
}: {
label: string
used: number
max: number
gib: string
onGibChange: (v: string) => void
count?: number
}) {
const maxBytes = max > 0 ? max : gibToBytes(Number(gib) || 0)
const pct = maxBytes > 0 ? Math.min(100, Math.round((used / maxBytes) * 100)) : 0
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-medium text-foreground">{label}</span>
<span className="text-right text-muted-foreground">
{formatBytes(used)} / {formatBytes(maxBytes)}
{count !== undefined ? ` · ${count} messages` : ""}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex items-center gap-2">
<Label className="sr-only">Quota {label}</Label>
<Input
type="number"
min={0}
step={0.5}
className="h-9"
value={gib}
onChange={(e) => onGibChange(e.target.value)}
/>
<span className="shrink-0 text-sm text-muted-foreground">Go max</span>
</div>
</div>
)
}