ultisuite-client/components/admin/settings/sections/users-section.tsx
R3D347HR4Y 2a0958b70d
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: update agenda references to use ULTICAL_APP_NAME and enhance AI usage sections
- Replaced hardcoded "Agenda" labels with dynamic ULTICAL_APP_NAME in various components for consistency.
- Introduced new AiUsageSection and CompteAiUsageSection components to track AI usage and costs.
- Updated settings and metadata to reflect changes in AI cost policies and usage limits.
- Enhanced user interface elements for better accessibility and user experience across admin settings.
2026-06-16 10:46:31 +02:00

756 lines
25 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { MoreHorizontal, UserPlus, UsersRound } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { AdminListControls, type AdminListSortOption } from "@/components/admin/settings/admin-list-controls"
import { UsersBulkToolbar } from "@/components/admin/settings/sections/users-bulk-toolbar"
import { UsersGroupsDialog } from "@/components/admin/settings/sections/users-groups-dialog"
import { useAdminUser, useAdminUserGroups, useAdminUsers } from "@/lib/api/hooks/use-admin-queries"
import {
useDeleteAdminUser,
useDisableAdminUser,
useInviteAdminUser,
useReactivateAdminUser,
useSetAdminUserQuota,
useSetAdminUserRole,
useUpdateAdminUser,
} from "@/lib/api/hooks/use-admin-mutations"
import { useUpdateUserAICostPolicy } from "@/lib/api/hooks/use-admin-ai-usage"
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 { Checkbox } from "@/components/ui/checkbox"
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"]
const USER_SORT_OPTIONS: AdminListSortOption[] = [
{ value: "-created_at", label: "Création (récent)" },
{ value: "created_at", label: "Création (ancien)" },
{ value: "name", label: "Nom (A→Z)" },
{ value: "-name", label: "Nom (Z→A)" },
{ value: "email", label: "E-mail (A→Z)" },
{ value: "-email", label: "E-mail (Z→A)" },
{ value: "-updated_at", label: "Mise à jour (récent)" },
{ value: "updated_at", label: "Mise à jour (ancien)" },
]
export function UsersSection() {
const [q, setQ] = useState("")
const [role, setRole] = useState<string>("all")
const [groupId, setGroupId] = useState<string>("all")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [sort, setSort] = useState("-created_at")
const [inviteOpen, setInviteOpen] = useState(false)
const [groupsOpen, setGroupsOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const queryParams = useMemo(
() => ({
page,
page_size: pageSize,
sort,
q: q.trim() || undefined,
role: role === "all" ? undefined : role,
group_id: groupId === "all" ? undefined : groupId,
}),
[page, pageSize, sort, q, role, groupId]
)
const { data: groupsData } = useAdminUserGroups({ page: 1, page_size: 200 })
const groups = groupsData?.groups ?? []
const { data, isFetching, isError, refetch } = useAdminUsers(queryParams)
const users = data?.users
const total = data?.pagination.total ?? 0
const resolvedPageSize = data?.pagination.page_size ?? pageSize
const totalPages = Math.max(1, Math.ceil(total / resolvedPageSize))
const pageUserIds = useMemo(() => (users ?? []).map((user) => user.id), [users])
const pageUserIdsKey = pageUserIds.join(",")
const allPageSelected =
pageUserIds.length > 0 && pageUserIds.every((id) => selectedIds.includes(id))
const somePageSelected =
pageUserIds.some((id) => selectedIds.includes(id)) && !allPageSelected
useEffect(() => {
const ids = pageUserIdsKey.length > 0 ? pageUserIdsKey.split(",") : []
setSelectedIds((prev) => {
const next = prev.filter((id) => ids.includes(id))
if (next.length === prev.length && next.every((id, index) => id === prev[index])) {
return prev
}
return next
})
}, [pageUserIdsKey])
function toggleUser(id: string, checked: boolean) {
setSelectedIds((prev) =>
checked ? Array.from(new Set([...prev, id])) : prev.filter((value) => value !== id)
)
}
function togglePage(checked: boolean) {
if (checked) {
setSelectedIds((prev) => Array.from(new Set([...prev, ...pageUserIds])))
return
}
setSelectedIds((prev) => prev.filter((id) => !pageUserIds.includes(id)))
}
return (
<>
<SettingsSectionHeader
title="Utilisateurs"
description="Comptes, groupes, 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 placeholder="Type d'utilisateur" />
</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>
<div className="w-52">
<Label className="text-xs">Groupe</Label>
<Select
value={groupId}
onValueChange={(v) => {
setGroupId(v)
setPage(1)
}}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue placeholder="Groupe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="flex items-center gap-2">
<UsersRound className="size-4 opacity-80" />
Tous les groupes
</span>
</SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline" className="h-9" onClick={() => setGroupsOpen(true)}>
<UsersRound className="mr-2 size-4" />
Groupes
</Button>
<Button className="h-9" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-2 size-4" />
Inviter
</Button>
</div>
<UsersBulkToolbar
selectedIds={selectedIds}
groups={groups}
onClear={() => setSelectedIds([])}
/>
<AdminListControls
page={page}
pageSize={resolvedPageSize}
total={total}
totalPages={totalPages}
sort={sort}
sortOptions={USER_SORT_OPTIONS}
onPageChange={setPage}
onPageSizeChange={(next) => {
setPageSize(next)
setPage(1)
}}
onSortChange={(next) => {
setSort(next)
setPage(1)
}}
itemLabel="utilisateur(s)"
/>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allPageSelected ? true : somePageSelected ? "indeterminate" : false}
onCheckedChange={(checked) => togglePage(checked === true)}
aria-label="Sélectionner la page"
/>
</TableHead>
<TableHead>Utilisateur</TableHead>
<TableHead>Type</TableHead>
<TableHead className="hidden xl:table-cell">Groupes</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={8} className="text-center text-muted-foreground">
Aucun utilisateur trouvé.
</TableCell>
</TableRow>
) : (
(users ?? []).map((user) => (
<UserRow
key={user.id}
user={user}
selected={selectedIds.includes(user.id)}
onToggleSelect={(checked) => toggleUser(user.id, checked)}
onOpen={() => setSelectedId(user.id)}
/>
))
)}
</TableBody>
</Table>
</div>
<InviteUserDialog open={inviteOpen} onOpenChange={setInviteOpen} />
<UsersGroupsDialog open={groupsOpen} onOpenChange={setGroupsOpen} />
<UserDetailSheet userId={selectedId} onClose={() => setSelectedId(null)} />
</>
)
}
function UserRow({
user,
selected,
onToggleSelect,
onOpen,
}: {
user: AdminUser
selected: boolean
onToggleSelect: (checked: boolean) => void
onOpen: () => void
}) {
const disableUser = useDisableAdminUser()
const reactivateUser = useReactivateAdminUser()
const deleteUser = useDeleteAdminUser()
return (
<TableRow className="cursor-pointer">
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected}
onCheckedChange={(checked) => onToggleSelect(checked === true)}
aria-label={`Sélectionner ${user.email}`}
/>
</TableCell>
<TableCell onClick={onOpen}>
<div className="font-medium">{user.name || "—"}</div>
<div className="text-xs text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell onClick={onOpen}>
<RoleBadge role={resolveUserRole(user)} />
</TableCell>
<TableCell className="hidden xl:table-cell" onClick={onOpen}>
<UserGroupsBadges groups={user.groups ?? []} />
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell" onClick={onOpen}>
{formatBytes(user.storage?.mail_used_bytes ?? 0)}
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell" onClick={onOpen}>
{formatBytes(user.storage?.drive_used_bytes ?? 0)}
</TableCell>
<TableCell
className="hidden max-w-[200px] truncate font-mono text-xs md:table-cell"
onClick={onOpen}
>
{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 UserGroupsBadges({ groups }: { groups: AdminUser["groups"] }) {
if (!groups?.length) {
return <span className="text-xs text-muted-foreground"></span>
}
const visible = groups.slice(0, 2)
const extra = groups.length - visible.length
return (
<div className="flex flex-wrap gap-1">
{visible.map((group) => (
<Badge key={group.id} variant="outline" className="text-xs font-normal">
{group.name}
</Badge>
))}
{extra > 0 ? (
<Badge variant="secondary" className="text-xs font-normal">
+{extra}
</Badge>
) : null}
</div>
)
}
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 setAiPolicy = useUpdateUserAICostPolicy(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 [aiDailyEur, setAiDailyEur] = useState("")
const [aiMonthlyEur, setAiMonthlyEur] = useState("")
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)),
})
}
async function saveAiPolicy() {
if (!userId) return
await setAiPolicy.mutateAsync({
daily_limit_eur: aiDailyEur.trim() === "" ? null : Number(aiDailyEur),
monthly_limit_eur: aiMonthlyEur.trim() === "" ? null : Number(aiMonthlyEur),
})
}
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)} />
{user.groups?.length ? (
<div className="space-y-2">
<Label>Groupes</Label>
<UserGroupsBadges groups={user.groups} />
</div>
) : null}
<div className="space-y-2">
<Label>Type d&apos;utilisateur</Label>
<Select
value={selectedRole}
onValueChange={(v) => setSelectedRole(v as AdminUserRole)}
>
<SelectTrigger>
<SelectValue placeholder="Type d'utilisateur" />
</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}
<div className="space-y-4 rounded-lg border bg-muted/20 p-5">
<h3 className="text-sm font-medium">Plafond IA (clé org)</h3>
<p className="text-xs text-muted-foreground">
Override utilisateur. Laissez vide pour hériter des plafonds org/groupe.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Journalier ()</Label>
<Input
type="number"
min={0}
step={0.5}
placeholder="Hérité"
value={aiDailyEur}
onChange={(e) => setAiDailyEur(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Mensuel ()</Label>
<Input
type="number"
min={0}
step={1}
placeholder="Hérité"
value={aiMonthlyEur}
onChange={(e) => setAiMonthlyEur(e.target.value)}
/>
</div>
</div>
<Button size="sm" variant="secondary" onClick={() => void saveAiPolicy()} disabled={setAiPolicy.isPending}>
Enregistrer le plafond IA
</Button>
</div>
<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>
)
}