feat(admin): add VirusTotal scan settings and mail UI
Some checks failed
E2E / Playwright e2e (push) Has been cancelled

File policies API key field, conditional scan badge, safe policy merge.
This commit is contained in:
R3D347HR4Y 2026-06-07 22:05:28 +02:00
parent 8b9717861c
commit f44dadc453
8 changed files with 91 additions and 36 deletions

View File

@ -17,6 +17,13 @@ import {
export function FilePoliciesSection() {
const filePolicies = useOrgSettingsStore((s) => s.filePolicies)
const setFilePolicies = useOrgSettingsStore((s) => s.setFilePolicies)
const vtKeyConfigured = useOrgSettingsStore(
(s) => s.meta?.secrets?.virustotal_api_key?.configured ?? false
)
const vtKeyMissing =
filePolicies.virus_scan_enabled &&
!vtKeyConfigured &&
!(filePolicies.virustotal_api_key ?? "").trim()
return (
<OrgSettingsSection
@ -105,12 +112,36 @@ export function FilePoliciesSection() {
<label className="flex items-center justify-between gap-4 rounded-lg border p-3 sm:col-span-2">
<div>
<p className="text-sm font-medium">Analyse antivirus à l&apos;upload</p>
<p className="text-xs text-muted-foreground">
VirusTotal scan synchrone à l&apos;upload Drive et pièces jointes mail
</p>
</div>
<Switch
checked={filePolicies.virus_scan_enabled}
onCheckedChange={(virus_scan_enabled) => setFilePolicies({ virus_scan_enabled })}
/>
</label>
{filePolicies.virus_scan_enabled ? (
<div className="sm:col-span-2">
<Label>Clé API VirusTotal</Label>
<Input
className="mt-1 h-9"
type="password"
autoComplete="off"
value={filePolicies.virustotal_api_key ?? ""}
onChange={(e) => setFilePolicies({ virustotal_api_key: e.target.value })}
placeholder={vtKeyConfigured ? "•••••••• (laisser vide pour conserver)" : "Coller la clé API"}
/>
{vtKeyConfigured && !(filePolicies.virustotal_api_key ?? "").trim() ? (
<p className="mt-1 text-xs text-muted-foreground">Clé configurée</p>
) : null}
{vtKeyMissing ? (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-500">
Analyse activée sans clé API les uploads ne seront pas scannés.
</p>
) : null}
</div>
) : null}
</div>
</OrgSettingsSection>
)

View File

@ -85,6 +85,34 @@ function DriveLocationBadge({ folderPath }: { folderPath: string }) {
)
}
function attachmentsVirusTotalScanned(attachments: EmailAttachment[]): boolean {
return attachments.some((a) => a.virusScanStatus === "clean")
}
function VirusTotalScanBadge() {
return (
<>
<span aria-hidden> · </span>
<span>Analysé par VirusTotal</span>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants.
</TooltipContent>
</Tooltip>
</>
)
}
export function MessageAttachmentsSection({
messageId,
attachments,
@ -117,6 +145,7 @@ export function MessageAttachmentsSection({
const summary = n === 1 ? "Une pièce jointe" : `${n} pièces jointes`
const asPills = shouldUseAttachmentPillsInPreview(attachments)
const showVirusTotal = attachmentsVirusTotalScanned(attachments)
const openPreview = (index: number) => {
if (!attachments.some((a) => a.id)) {
@ -167,24 +196,8 @@ export function MessageAttachmentsSection({
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
<span className="min-w-0 truncate">
{summary}
<span aria-hidden> · </span>
<span>Analysé par VirusTotal</span>
{showVirusTotal ? <VirusTotalScanBadge /> : null}
</span>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants.
</TooltipContent>
</Tooltip>
</div>
{allSaved && uniqueSaveFolders.length === 1 ? (
<DriveLocationBadge folderPath={uniqueSaveFolders[0]!} />
@ -316,6 +329,7 @@ export function ConversationAttachmentsSection({
? "Une pièce jointe dans cette conversation"
: `${n} pièces jointes dans cette conversation`
const asPills = shouldUseAttachmentPillsInPreview(flat.map((item) => item.attachment))
const showVirusTotal = attachmentsVirusTotalScanned(flat.map((item) => item.attachment))
const openPreview = (messageId: string, attachments: EmailAttachment[], index: number) => {
if (!attachments.some((a) => a.id)) {
@ -336,24 +350,8 @@ export function ConversationAttachmentsSection({
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
<span className="min-w-0 truncate">
{summary}
<span aria-hidden> · </span>
<span>Analysé par VirusTotal</span>
{showVirusTotal ? <VirusTotalScanBadge /> : null}
</span>
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants.
</TooltipContent>
</Tooltip>
</div>
</div>

View File

@ -1,6 +1,6 @@
import type { ApiOrgPolicy, ApiOrgSettingsResponse } from "@/lib/api/admin-org-types"
import type { OrgPolicySectionKey } from "@/lib/api/admin-org-types"
import type { IntegrationEntry, OrgSettingsState } from "@/lib/admin-settings/org-settings-types"
import type { IntegrationEntry, OrgSettingsState, FilePolicySettings } from "@/lib/admin-settings/org-settings-types"
const INTEGRATION_HREFS: Record<string, string> = {
authentik: "/admin/settings/authentication",
@ -19,6 +19,25 @@ function mergeIntegrations(
}))
}
const DEFAULT_FILE_POLICIES: FilePolicySettings = {
max_upload_mib: 512,
allowed_extensions: "",
block_executable: true,
external_sharing: "authenticated",
default_link_expiry_days: 30,
virus_scan_enabled: false,
virustotal_api_key: "",
retention_trash_days: 30,
}
function mergeFilePolicies(fromApi: Partial<FilePolicySettings> | undefined): FilePolicySettings {
return {
...DEFAULT_FILE_POLICIES,
...fromApi,
virustotal_api_key: fromApi?.virustotal_api_key ?? "",
}
}
export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsState> {
return {
authentik: {
@ -39,7 +58,7 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
},
storageQuotas: { ...policy.storage_quotas },
usageQuotas: { ...policy.usage_quotas },
filePolicies: { ...policy.file_policies },
filePolicies: mergeFilePolicies(policy.file_policies),
llm: {
...policy.llm,
providers: policy.llm.providers ?? [],

View File

@ -58,6 +58,7 @@ const DEFAULT_FILE_POLICIES: FilePolicySettings = {
external_sharing: "authenticated",
default_link_expiry_days: 30,
virus_scan_enabled: false,
virustotal_api_key: "",
retention_trash_days: 30,
}

View File

@ -40,6 +40,7 @@ export type FilePolicySettings = {
external_sharing: "disabled" | "authenticated" | "public_link"
default_link_expiry_days: number
virus_scan_enabled: boolean
virustotal_api_key: string
retention_trash_days: number
}

View File

@ -40,6 +40,7 @@ export type ApiOrgFilePolicies = {
external_sharing: "disabled" | "authenticated" | "public_link"
default_link_expiry_days: number
virus_scan_enabled: boolean
virustotal_api_key: string
retention_trash_days: number
}

View File

@ -9,6 +9,7 @@ export interface ApiMessageAttachment {
is_inline?: boolean
content_id?: string
drive_path?: string
virus_scan_status?: "clean" | "skipped" | "malicious"
}
export function mapApiAttachmentsToEmail(
@ -24,6 +25,7 @@ export function mapApiAttachmentsToEmail(
contentType: a.content_type || undefined,
drivePath: a.drive_path || undefined,
sizeBytes: a.size > 0 ? a.size : undefined,
virusScanStatus: a.virus_scan_status,
}))
}

View File

@ -11,6 +11,8 @@ export interface EmailAttachment {
drivePath?: string
/** Taille en octets (optionnelle) — tooltips / pills vs cartes dans laperçu */
sizeBytes?: number
/** Statut scan VirusTotal côté serveur */
virusScanStatus?: "clean" | "skipped" | "malicious"
/** Contenu texte inline (ex. ICS) — fixtures / import */
inlineText?: string
}