Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Added SessionGuard component to manage session expiration and online status. - Updated AuthProvider to streamline session fetching and handling. - Introduced IdentityProvidersSection for managing OAuth, SAML, and LDAP identity providers. - Implemented identity provider guides for easier configuration. - Enhanced mail settings with infinite scroll option for improved user experience. - Updated global styles and layout components for better consistency across the application.
444 lines
12 KiB
TypeScript
444 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { Trash2 } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible"
|
|
import { NavColorPickerTrigger } from "@/components/gmail/nav/nav-color-picker-trigger"
|
|
import {
|
|
NavMessageVisibilityFields,
|
|
NavSidebarVisibilityFields,
|
|
} from "@/components/gmail/nav/nav-visibility-fields"
|
|
import { useSidebarNav, folderMoveParentOptions } from "@/lib/sidebar-nav-context"
|
|
import { normalizeNavColorClass } from "@/lib/nav-color"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export function NavLabelSettingsCard({
|
|
id,
|
|
name,
|
|
color,
|
|
depth = 0,
|
|
}: {
|
|
id: string
|
|
name: string
|
|
color: string
|
|
depth?: number
|
|
}) {
|
|
const nav = useSidebarNav()
|
|
const prefs = nav.getNavItemPrefs(id)
|
|
const colorClass = normalizeNavColorClass(color)
|
|
|
|
const [renameDraft, setRenameDraft] = useState(name)
|
|
const [sublabelName, setSublabelName] = useState("")
|
|
|
|
useEffect(() => {
|
|
setRenameDraft(name)
|
|
}, [name])
|
|
|
|
return (
|
|
<NavItemSettingsShell
|
|
title={name}
|
|
color={colorClass}
|
|
depth={depth}
|
|
onColorChange={(sw) => nav.updateFolderOrLabelColor(id, sw)}
|
|
onDelete={() => nav.removeFolderOrLabelRow(id)}
|
|
deleteLabel="Supprimer le libellé"
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<NavSidebarVisibilityFields
|
|
listKind="labels"
|
|
value={prefs.sidebar}
|
|
onChange={(v) => nav.setNavItemSidebarVisibility(id, v)}
|
|
/>
|
|
<NavMessageVisibilityFields
|
|
value={prefs.messages}
|
|
onChange={(v) => nav.setNavItemMessageVisibility(id, v)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs" htmlFor={`rename-label-${id}`}>
|
|
Renommer
|
|
</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Input
|
|
id={`rename-label-${id}`}
|
|
value={renameDraft}
|
|
onChange={(e) => setRenameDraft(e.target.value)}
|
|
className="min-w-[160px] flex-1"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={!renameDraft.trim() || renameDraft.trim() === name}
|
|
onClick={() => nav.renameFolderOrLabel(id, renameDraft.trim())}
|
|
>
|
|
Enregistrer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs" htmlFor={`sublabel-${id}`}>
|
|
Ajouter un sous-libellé
|
|
</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Input
|
|
id={`sublabel-${id}`}
|
|
value={sublabelName}
|
|
onChange={(e) => setSublabelName(e.target.value)}
|
|
placeholder="Nom du sous-libellé"
|
|
className="min-w-[160px] flex-1"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={!sublabelName.trim()}
|
|
onClick={() => {
|
|
nav.addChildLabelRow(id, sublabelName.trim())
|
|
setSublabelName("")
|
|
}}
|
|
>
|
|
Ajouter
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</NavItemSettingsShell>
|
|
)
|
|
}
|
|
|
|
export function NavImapFolderSettingsCard({
|
|
id,
|
|
name,
|
|
remoteName,
|
|
depth = 0,
|
|
}: {
|
|
id: string
|
|
name: string
|
|
remoteName?: string
|
|
depth?: number
|
|
}) {
|
|
const nav = useSidebarNav()
|
|
const prefs = nav.getNavItemPrefs(id)
|
|
const subtitle =
|
|
remoteName && remoteName !== name ? remoteName : undefined
|
|
|
|
return (
|
|
<NavItemSettingsShell
|
|
title={name}
|
|
subtitle={subtitle}
|
|
depth={depth}
|
|
hideDelete
|
|
hideColor
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<NavSidebarVisibilityFields
|
|
listKind="folders"
|
|
value={prefs.sidebar}
|
|
onChange={(v) => nav.setNavItemSidebarVisibility(id, v)}
|
|
/>
|
|
<NavMessageVisibilityFields
|
|
value={prefs.messages}
|
|
onChange={(v) => nav.setNavItemMessageVisibility(id, v)}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Dossier synchronisé depuis le serveur mail — structure non modifiable ici.
|
|
</p>
|
|
</div>
|
|
</NavItemSettingsShell>
|
|
)
|
|
}
|
|
|
|
export function NavFolderSettingsCard({
|
|
id,
|
|
name,
|
|
color,
|
|
depth = 0,
|
|
}: {
|
|
id: string
|
|
name: string
|
|
color?: string
|
|
depth?: number
|
|
}) {
|
|
const nav = useSidebarNav()
|
|
const prefs = nav.getNavItemPrefs(id)
|
|
const colorClass = normalizeNavColorClass(color)
|
|
|
|
const [renameDraft, setRenameDraft] = useState(name)
|
|
const [moveParent, setMoveParent] = useState("__root__")
|
|
const [subfolderName, setSubfolderName] = useState("")
|
|
|
|
const moveTargets = useMemo(
|
|
() => folderMoveParentOptions(nav.folderTree, id),
|
|
[nav.folderTree, id]
|
|
)
|
|
|
|
useEffect(() => {
|
|
setRenameDraft(name)
|
|
}, [name])
|
|
|
|
return (
|
|
<NavItemSettingsShell
|
|
title={name}
|
|
color={colorClass}
|
|
depth={depth}
|
|
onColorChange={(sw) => nav.updateFolderOrLabelColor(id, sw)}
|
|
onDelete={() => nav.removeFolderOrLabelRow(id)}
|
|
deleteLabel="Supprimer le dossier"
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<NavSidebarVisibilityFields
|
|
listKind="folders"
|
|
value={prefs.sidebar}
|
|
onChange={(v) => nav.setNavItemSidebarVisibility(id, v)}
|
|
/>
|
|
<NavMessageVisibilityFields
|
|
value={prefs.messages}
|
|
onChange={(v) => nav.setNavItemMessageVisibility(id, v)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs" htmlFor={`rename-folder-${id}`}>
|
|
Renommer
|
|
</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Input
|
|
id={`rename-folder-${id}`}
|
|
value={renameDraft}
|
|
onChange={(e) => setRenameDraft(e.target.value)}
|
|
className="min-w-[160px] flex-1"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={!renameDraft.trim() || renameDraft.trim() === name}
|
|
onClick={() => nav.renameFolderOrLabel(id, renameDraft.trim())}
|
|
>
|
|
Enregistrer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">Déplacer vers</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Select value={moveParent} onValueChange={setMoveParent}>
|
|
<SelectTrigger className="min-w-[200px] flex-1">
|
|
<SelectValue placeholder="Emplacement" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{moveTargets.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() =>
|
|
nav.moveFolder(id, moveParent === "__root__" ? null : moveParent)
|
|
}
|
|
>
|
|
Déplacer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs" htmlFor={`subfolder-${id}`}>
|
|
Nouveau sous-dossier
|
|
</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Input
|
|
id={`subfolder-${id}`}
|
|
value={subfolderName}
|
|
onChange={(e) => setSubfolderName(e.target.value)}
|
|
placeholder="Nom du sous-dossier"
|
|
className="min-w-[160px] flex-1"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={!subfolderName.trim()}
|
|
onClick={() => {
|
|
nav.addSubfolder(id, subfolderName.trim())
|
|
setSubfolderName("")
|
|
}}
|
|
>
|
|
Créer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</NavItemSettingsShell>
|
|
)
|
|
}
|
|
|
|
function NavItemSettingsShell({
|
|
title,
|
|
subtitle,
|
|
color,
|
|
depth,
|
|
onColorChange,
|
|
onDelete,
|
|
deleteLabel,
|
|
hideDelete = false,
|
|
hideColor = false,
|
|
children,
|
|
}: {
|
|
title: string
|
|
subtitle?: string
|
|
color?: string
|
|
depth: number
|
|
onColorChange?: (swatch: string) => void
|
|
onDelete?: () => void
|
|
deleteLabel?: string
|
|
hideDelete?: boolean
|
|
hideColor?: boolean
|
|
children: import("react").ReactNode
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
|
|
return (
|
|
<Collapsible
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
className="mail-settings-card rounded-lg border border-mail-border bg-mail-surface shadow-sm dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]"
|
|
>
|
|
<div
|
|
className="flex items-center gap-1 px-3 py-2"
|
|
style={{ paddingLeft: `${depth * 12 + 12}px` }}
|
|
>
|
|
{!hideColor && color && onColorChange ? (
|
|
<NavColorPickerTrigger
|
|
value={color}
|
|
onChange={onColorChange}
|
|
aria-label="Couleur"
|
|
/>
|
|
) : null}
|
|
<CollapsibleTrigger className="flex min-w-0 flex-1 flex-col items-start text-left">
|
|
<span className="truncate text-sm font-medium">{title}</span>
|
|
{subtitle ? (
|
|
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
|
|
) : null}
|
|
</CollapsibleTrigger>
|
|
{!hideDelete && onDelete ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
aria-label={deleteLabel ?? "Supprimer"}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onDelete()
|
|
}}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<CollapsibleContent className="border-t border-border px-3 py-4">
|
|
{children}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)
|
|
}
|
|
|
|
/** Flatten label rows for settings (parent/child via `/` in name). */
|
|
export function flattenLabelRowsForSettings(
|
|
rows: { id: string; label: string; color: string }[]
|
|
) {
|
|
return rows.map((row) => ({
|
|
...row,
|
|
depth: Math.max(0, row.label.split("/").length - 1),
|
|
}))
|
|
}
|
|
|
|
type FolderSettingsNode = {
|
|
id: string
|
|
label: string
|
|
color?: string
|
|
children?: FolderSettingsNode[]
|
|
}
|
|
|
|
export function FolderSettingsTree({
|
|
nodes,
|
|
depth = 0,
|
|
}: {
|
|
nodes: FolderSettingsNode[]
|
|
depth?: number
|
|
}) {
|
|
return (
|
|
<ul className={cn("space-y-2", depth > 0 && "mt-2")}>
|
|
{nodes.map((node) => (
|
|
<li key={node.id}>
|
|
<NavFolderSettingsCard
|
|
id={node.id}
|
|
name={node.label}
|
|
color={node.color}
|
|
depth={depth}
|
|
/>
|
|
{node.children?.length ? (
|
|
<FolderSettingsTree nodes={node.children} depth={depth + 1} />
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
type ImapFolderSettingsNode = {
|
|
id: string
|
|
label: string
|
|
remoteName?: string
|
|
children?: ImapFolderSettingsNode[]
|
|
}
|
|
|
|
export function ImapFolderSettingsTree({
|
|
nodes,
|
|
depth = 0,
|
|
}: {
|
|
nodes: ImapFolderSettingsNode[]
|
|
depth?: number
|
|
}) {
|
|
return (
|
|
<ul className={cn("space-y-2", depth > 0 && "mt-2")}>
|
|
{nodes.map((node) => (
|
|
<li key={node.id}>
|
|
<NavImapFolderSettingsCard
|
|
id={node.id}
|
|
name={node.label}
|
|
remoteName={node.remoteName}
|
|
depth={depth}
|
|
/>
|
|
{node.children?.length ? (
|
|
<ImapFolderSettingsTree nodes={node.children} depth={depth + 1} />
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|