569 lines
18 KiB
TypeScript
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'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'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'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>
|
|
)
|
|
}
|