Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
252 lines
8.1 KiB
TypeScript
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
|
|
}
|