ultisuite-client/components/admin/settings/sections/public-shares-section.tsx
R3D347HR4Y 8f81d7aba1
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(admin-settings): refactor admin settings components for improved usability and consistency
- Replaced legacy components with new `SettingsCard`, `SettingsField`, and `SettingsToggleRow` for a unified design.
- Enhanced `AdminListControls` to support compact mode and improved pagination controls.
- Updated various sections including `AiAssistantSection`, `AuthenticationSection`, and `DriveMountOAuthSection` to utilize new components, streamlining the settings interface.
- Improved accessibility and user experience across admin settings with clearer labels and hints.
- Deprecated old components while maintaining backward compatibility for existing admin sections.
2026-06-15 11:10:17 +02:00

252 lines
8.1 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { ExternalLink, Link2, Trash2 } from "lucide-react"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { AdminListControls } from "@/components/admin/settings/admin-list-controls"
import { useAdminPublicShares } from "@/lib/api/hooks/use-admin-queries"
import { useRevokeAdminPublicShare } from "@/lib/api/hooks/use-admin-mutations"
import type { AdminPublicShare } from "@/lib/api/admin-types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const ACCESS_MODE_LABELS: Record<string, string> = {
public: "Lien public",
email: "Invitation e-mail",
internal: "Interne",
}
const PUBLIC_SHARE_SORT_OPTIONS = [
{ value: "-created_at", label: "Créé (récent)" },
{ value: "created_at", label: "Créé (ancien)" },
{ value: "-last_access_at", label: "Dernier accès (récent)" },
{ value: "last_access_at", label: "Dernier accès (ancien)" },
{ value: "-access_count", label: "Accès (plus)" },
{ value: "access_count", label: "Accès (moins)" },
{ value: "path", label: "Chemin (A→Z)" },
{ value: "-path", label: "Chemin (Z→A)" },
{ value: "owner_email", label: "Propriétaire (A→Z)" },
{ value: "-owner_email", label: "Propriétaire (Z→A)" },
] as const
export function PublicSharesSection() {
const [q, setQ] = useState("")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [sort, setSort] = useState("-created_at")
const queryParams = useMemo(
() => ({
page,
page_size: pageSize,
sort,
q: q.trim() || undefined,
}),
[page, pageSize, sort, q]
)
const { data, isFetching, isError, refetch } = useAdminPublicShares(queryParams)
const revoke = useRevokeAdminPublicShare()
const shares = data?.shares ?? []
const total = data?.pagination.total ?? 0
const resolvedPageSize = data?.pagination.page_size ?? pageSize
const totalPages = Math.max(1, Math.ceil(total / resolvedPageSize))
return (
<>
<SettingsSectionHeader
title="Partages externes"
description="Audit des liens publics et invitations Drive — création, dernier accès et révocation."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<AdminListControls
compact
leading={
<Input
className="h-9 min-w-40 flex-1 basis-40"
value={q}
aria-label="Recherche"
onChange={(e) => {
setQ(e.target.value)
setPage(1)
}}
placeholder="Propriétaire, chemin, token…"
/>
}
page={page}
pageSize={resolvedPageSize}
total={total}
totalPages={totalPages}
sort={sort}
sortOptions={[...PUBLIC_SHARE_SORT_OPTIONS]}
onPageChange={setPage}
onPageSizeChange={(next) => {
setPageSize(next)
setPage(1)
}}
onSortChange={(next) => {
setSort(next)
setPage(1)
}}
itemLabel="partage(s)"
/>
<div className="overflow-x-auto rounded-lg border border-mail-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ressource</TableHead>
<TableHead>Propriétaire</TableHead>
<TableHead>Type</TableHead>
<TableHead className="hidden md:table-cell">Créé le</TableHead>
<TableHead className="hidden lg:table-cell">Dernier accès</TableHead>
<TableHead className="w-24 text-right">Accès</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{shares.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
{isFetching ? "Chargement…" : "Aucun partage externe actif."}
</TableCell>
</TableRow>
) : (
shares.map((share) => (
<ShareRow
key={`${share.owner_nc_user_id}-${share.id}`}
share={share}
revoking={revoke.isPending}
onRevoke={() =>
void revoke.mutateAsync({
shareId: share.id,
ownerNcUserId: share.owner_nc_user_id,
})
}
/>
))
)}
</TableBody>
</Table>
</div>
</>
)
}
function ShareRow({
share,
revoking,
onRevoke,
}: {
share: AdminPublicShare
revoking: boolean
onRevoke: () => void
}) {
const modeLabel =
ACCESS_MODE_LABELS[share.access_mode] ??
(share.share_type === 4 ? "Invitation e-mail" : "Lien public")
function handleRevoke() {
const label = share.path || share.token
if (
confirm(
`Révoquer le partage « ${label} » créé par ${share.owner_email} ?\nLe lien ne sera plus accessible.`
)
) {
onRevoke()
}
}
return (
<TableRow>
<TableCell>
<div className="flex items-start gap-2">
<Link2 className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<div className="truncate font-medium" title={share.path}>
{basename(share.path) || "—"}
</div>
<div className="truncate text-xs text-muted-foreground" title={share.path}>
{share.path}
</div>
{share.share_with ? (
<div className="mt-0.5 text-xs text-muted-foreground">
{share.share_with_display_name || share.share_with}
</div>
) : null}
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm">{share.owner_display_name || "—"}</div>
<div className="text-xs text-muted-foreground">{share.owner_email}</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1">
<Badge variant="secondary">{modeLabel}</Badge>
{share.has_password ? <Badge variant="outline">Mot de passe</Badge> : null}
</div>
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground md:table-cell">
{formatDateTime(share.created_at)}
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
{share.last_access_at ? formatDateTime(share.last_access_at) : "Jamais"}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{share.access_count > 0 ? share.access_count.toLocaleString("fr-FR") : "—"}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
{share.url ? (
<Button variant="ghost" size="icon" className="size-8" asChild>
<a href={share.url} target="_blank" rel="noopener noreferrer" title="Ouvrir le lien">
<ExternalLink className="size-4" />
</a>
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="size-8 text-destructive hover:text-destructive"
disabled={revoking}
onClick={handleRevoke}
title="Révoquer le partage"
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
}
function formatDateTime(value?: string | null): string {
if (!value) return "—"
const d = new Date(value)
if (Number.isNaN(d.getTime())) return "—"
return d.toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
})
}
function basename(path: string): string {
const trimmed = path.replace(/\/+$/, "")
const idx = trimmed.lastIndexOf("/")
if (idx < 0) return trimmed
return trimmed.slice(idx + 1) || trimmed
}