feat(auth): enhance session management and identity provider settings
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.
This commit is contained in:
R3D347HR4Y 2026-06-09 09:36:46 +02:00
parent f44dadc453
commit 5304790ed5
79 changed files with 3252 additions and 956 deletions

View File

@ -2,7 +2,8 @@
NEXT_PUBLIC_API_URL=/api/v1
NEXT_PUBLIC_WS_URL=ws://localhost/ws
# Cible du proxy Next (optionnel, défaut 127.0.0.1:80)
# ULTI_PROXY_ORIGIN=http://127.0.0.1
# Sert aussi aux appels OIDC serveur (discovery/token) — Docker: http://nginx
# ULTI_PROXY_ORIGIN=http://127.0.0.1:80
# OIDC Authentik (blueprints deploy/authentik dans ulti-backend)
NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/
@ -10,6 +11,8 @@ NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
# URL publique navigateur (suite nginx) — pas :3000 si tu passes par http://localhost/mail
NEXT_PUBLIC_APP_URL=http://localhost
# Cookies session Secure (auto: true seulement si NEXT_PUBLIC_APP_URL est https://)
# COOKIE_SECURE=false
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
OIDC_CLIENT_SECRET=changeme

View File

@ -1,6 +1,10 @@
import { cookies } from "next/headers"
import { NextResponse } from "next/server"
import { resolveServerOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config"
import {
resolveServerOidcConfig,
getAppOrigin,
oidcServerFetchHeaders,
} from "@/lib/auth/oidc-config"
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
import {
applySessionCookies,
@ -75,7 +79,10 @@ export async function GET(request: Request) {
try {
const res = await fetch(cfg.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
headers: {
"Content-Type": "application/x-www-form-urlencoded",
...oidcServerFetchHeaders(),
},
body,
})
if (!res.ok) {

View File

@ -3,6 +3,7 @@ import { NextResponse } from "next/server"
import { createPkcePair, randomString } from "@/lib/auth/pkce"
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
import { resolveOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config"
import { sessionCookieOptions } from "@/lib/auth/session"
const PKCE_COOKIE = "ulti_pkce_verifier"
const STATE_COOKIE = "ulti_oauth_state"
@ -12,11 +13,8 @@ const COOKIE_MAX_AGE = 600
function oauthCookieOptions() {
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
...sessionCookieOptions(),
maxAge: COOKIE_MAX_AGE,
secure: process.env.NODE_ENV === "production",
}
}

View File

@ -8,6 +8,7 @@ import {
computeExpiresAt,
exchangeRefreshToken,
isAccessTokenValid,
isIdTokenJwtValid,
resolveBearerToken,
} from "@/lib/auth/session"
@ -40,7 +41,25 @@ export async function GET() {
try {
const cfg = await resolveServerOidcConfig()
const tokens = await exchangeRefreshToken(refreshToken, cfg)
const bearer = resolveBearerToken(tokens)
let bearer: string
try {
bearer = resolveBearerToken(tokens)
} catch {
if (accessToken && isIdTokenJwtValid(accessToken)) {
const expiresAt = Number(expiresAtRaw) || computeExpiresAt(3600)
const user = platformUserFromToken(accessToken)
return NextResponse.json({
authenticated: true,
accessToken,
refreshToken: refreshToken ?? null,
expiresAt,
user,
})
}
return NextResponse.json({ authenticated: false, expired: true })
}
const expiresAt = computeExpiresAt(tokens.expires_in ?? 3600)
const user = platformUserFromToken(bearer)

View File

@ -2,13 +2,11 @@
import { useEffect, Suspense } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useAuthStore } from "@/lib/api/auth-store"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
import { applySessionToStore } from "@/lib/auth/session-sync"
function AuthCompleteInner() {
const router = useRouter()
const searchParams = useSearchParams()
const login = useAuthStore((s) => s.login)
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
const accountNotice = searchParams.get("accountNotice")
@ -23,20 +21,9 @@ function AuthCompleteInner() {
accessToken?: string
refreshToken?: string | null
expiresAt?: number
user?: PlatformUser | null
user?: { sub: string; email: string; name: string } | null
}
if (
data.authenticated &&
data.accessToken &&
data.expiresAt &&
!cancelled
) {
login(
data.accessToken,
data.refreshToken ?? "",
data.expiresAt,
data.user ?? null
)
if (applySessionToStore(data) && !cancelled) {
if (accountNotice === "same") {
sessionStorage.setItem("ulti_account_notice", "same")
}
@ -55,7 +42,7 @@ function AuthCompleteInner() {
return () => {
cancelled = true
}
}, [accountNotice, login, returnTo, router])
}, [accountNotice, returnTo, router])
return null
}

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next"
import { displayFileName } from "@/lib/drive/display-file-name"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
type LayoutProps = {
@ -9,7 +9,9 @@ type LayoutProps = {
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
const { fileId } = await params
const name = displayFileName(decodeURIComponent(fileId))
const raw = decodeURIComponent(fileId)
const baseName = raw.split("/").filter(Boolean).pop() ?? raw
const name = displayFileBaseName(baseName)
return suitePageMetadata({
app: "drive",
titleSegment: name,

View File

@ -13,6 +13,7 @@ export default function PublicShareEditPage() {
const filePath = filePathFromPublicEditSegments(token, pathSegments)
const returnTo = searchParams.get("returnTo")
const mode = searchParams.get("mode") === "view" ? "view" : "edit"
const fileDisplayName = searchParams.get("name") ?? undefined
const [password] = useState<string | undefined>(() => {
if (typeof window === "undefined") return undefined
return sessionStorage.getItem(`public-share-pw:${token}`) ?? undefined
@ -25,6 +26,7 @@ export default function PublicShareEditPage() {
password={password}
returnTo={returnTo}
mode={mode}
fileDisplayName={fileDisplayName}
/>
)
}

View File

@ -719,6 +719,8 @@ html[data-mail-background]:not([data-mail-background='none'])
.ultimail-app {
position: relative;
isolation: isolate;
--border: var(--mail-border);
--input: var(--mail-border);
}
/* Lignes de liste */
@ -840,8 +842,8 @@ html.dark .ultimail-app {
--muted-foreground: var(--mail-text-muted);
--accent: var(--mail-nav-hover);
--accent-foreground: var(--mail-text);
--border: var(--mail-border-subtle);
--input: var(--mail-border-subtle);
--border: var(--mail-border);
--input: var(--mail-border);
--ring: var(--mail-border);
}
@ -917,17 +919,73 @@ html.dark .ultimail-app :where(.tiptap code, .tiptap pre) {
background-color: var(--mail-surface-muted);
}
/* ── Dark : portails Radix & toasts (rendus hors .ultimail-app) ── */
html.dark [data-slot='dropdown-menu-content'],
html.dark [data-slot='dropdown-menu-sub-content'],
html.dark [data-slot='context-menu-content'],
html.dark [data-slot='context-menu-sub-content'],
html.dark [data-slot='popover-content'],
html.dark [data-slot='select-content'],
html.dark [data-slot='menubar-content'] {
background-color: var(--popover) !important;
color: var(--popover-foreground) !important;
border-color: var(--border) !important;
/* Portails Radix (rendus hors .ultimail-app) — bordures/champs alignés sur le gris mail */
:where(
[data-slot='dialog-content'],
[data-slot='alert-dialog-content'],
[data-slot='popover-content'],
[data-slot='hover-card-content'],
[data-slot='dropdown-menu-content'],
[data-slot='dropdown-menu-sub-content'],
[data-slot='context-menu-content'],
[data-slot='context-menu-sub-content'],
[data-slot='select-content'],
[data-slot='menubar-content'],
[data-slot='menubar-sub-content'],
[data-slot='tooltip-content'],
[data-slot='sheet-content'],
[data-slot='drawer-content']
) {
--border: var(--mail-border);
--input: var(--mail-border);
}
/* ── Dark : portails Radix (rendus hors .ultimail-app) ── */
html.dark :where(
[data-slot='dialog-content'],
[data-slot='alert-dialog-content'],
[data-slot='popover-content'],
[data-slot='hover-card-content'],
[data-slot='dropdown-menu-content'],
[data-slot='dropdown-menu-sub-content'],
[data-slot='context-menu-content'],
[data-slot='context-menu-sub-content'],
[data-slot='select-content'],
[data-slot='menubar-content'],
[data-slot='menubar-sub-content'],
[data-slot='tooltip-content'],
[data-slot='sheet-content'],
[data-slot='drawer-content']
) {
--background: var(--mail-surface-elevated);
--foreground: var(--mail-text);
--popover: var(--mail-surface-elevated);
--popover-foreground: var(--mail-text);
--card: var(--mail-surface-elevated);
--card-foreground: var(--mail-text);
--secondary: var(--mail-surface-muted);
--secondary-foreground: var(--mail-text);
--muted: var(--mail-surface-muted);
--muted-foreground: var(--mail-text-muted);
--accent: var(--mail-nav-hover);
--accent-foreground: var(--mail-text);
--border: var(--mail-border);
--input: var(--mail-border);
--ring: var(--mail-border);
background-color: var(--mail-surface-elevated) !important;
color: var(--mail-text) !important;
border-color: var(--mail-border) !important;
}
html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content'])
:where([data-slot='input'], textarea, [data-slot='select-trigger']) {
background-color: var(--mail-surface-muted) !important;
border-color: var(--mail-border) !important;
color: var(--mail-text) !important;
}
html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) fieldset.border {
border-color: var(--mail-border) !important;
}
/* Drive / Contacts : menus portés — gris mail, pas le noir `popover`. */
@ -960,13 +1018,13 @@ html.dark [data-slot='context-menu-item']:focus,
html.dark [data-slot='context-menu-item'][data-highlighted],
html.dark [data-slot='context-menu-sub-trigger']:focus,
html.dark [data-slot='context-menu-sub-trigger'][data-state='open'] {
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
background-color: var(--mail-nav-hover) !important;
color: var(--mail-text) !important;
}
html.dark [data-slot='dropdown-menu-separator'],
html.dark [data-slot='context-menu-separator'] {
background-color: var(--border) !important;
background-color: var(--mail-border-subtle) !important;
}
/* Recherche avancée — champs (sheet xs + panneau dropdown desktop) */
@ -1012,12 +1070,14 @@ html.dark .ultimail-app :where(.text-\[#0f172a\], .text-\[#0b57d0\]) {
color: var(--foreground) !important;
}
html.dark .ultimail-app :where([data-slot='checkbox']) {
background-color: transparent;
border-color: #9aa0a6;
}
html.dark .ultimail-app :where([data-slot='checkbox'][data-state='checked']) {
html.dark
:where(
.ultimail-app,
[data-slot='dialog-content'],
[data-slot='popover-content'],
[data-slot='sheet-content']
)
:where([data-slot='checkbox'][data-state='checked'], [data-slot='checkbox'][data-state='indeterminate']) {
background-color: #1a73e8;
border-color: #1a73e8;
}
@ -1121,6 +1181,37 @@ html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] .
border-color: var(--border) !important;
}
/* Réglages mail : cartes cohérentes en dark mode (fond + bordure plus visible) */
html.dark [data-mail-settings-main] {
--border: var(--mail-border);
}
html.dark [data-mail-settings-main] :where(
.mail-settings-card,
[data-slot='card'],
[class*='rounded-lg'].border,
[class*='rounded-xl'].border,
[class*='rounded-md'].border
):not(.mail-settings-masonry-section) {
background-color: var(--mail-surface-elevated) !important;
border-color: var(--mail-border) !important;
}
@media (min-width: 1024px) {
html.dark [data-mail-settings-main] .mail-settings-masonry-section {
background-color: var(--mail-surface-elevated) !important;
border-color: var(--mail-border) !important;
}
}
html.dark [data-mail-settings-main] :where(
[class*='rounded-lg'][class*='border-dashed'],
[class*='rounded-xl'][class*='border-dashed'],
[class*='rounded-md'][class*='border-dashed']
) {
border-color: color-mix(in srgb, var(--mail-border) 72%, transparent) !important;
}
/* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */
html.dark .ultimail-app :where(.bg-background) {
background-color: var(--mail-surface-muted) !important;
@ -1132,6 +1223,6 @@ html.dark .ultimail-app :where(.bg-muted\/10, .bg-muted\/20, .bg-muted\/30, .bg-
html.dark .ultimail-app :where([data-slot='input'], [data-slot='select-trigger'], [data-slot='textarea']) {
background-color: var(--mail-surface-muted) !important;
border-color: var(--mail-border-subtle) !important;
border-color: var(--mail-border) !important;
color: var(--mail-text) !important;
}

View File

@ -6,6 +6,7 @@ import { ThemeInitScript } from '@/components/theme-init-script'
import { FirstLaunchSplash } from '@/components/first-launch-splash'
import { QueryProvider } from '@/lib/api/query-provider'
import { AuthProvider } from '@/components/auth/auth-provider'
import { SessionGuard } from '@/components/auth/session-guard'
import { MailToaster } from '@/components/gmail/mail-toaster'
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
@ -34,6 +35,7 @@ export default function RootLayout({
<ThemeInitScript />
<QueryProvider>
<AuthProvider>
<SessionGuard />
<FirstLaunchSplash>{children}</FirstLaunchSplash>
</AuthProvider>
</QueryProvider>

View File

@ -16,7 +16,8 @@ import { cn } from "@/lib/utils"
const LOGIN_CARD_CLASS = cn(
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none",
"sm:gap-5 sm:bg-card sm:px-8 sm:py-8 sm:text-card-foreground sm:shadow-none"
"sm:gap-5 sm:bg-card sm:dark:bg-mail-surface-elevated sm:px-8 sm:py-8",
"sm:text-card-foreground sm:dark:text-mail-text sm:shadow-none"
)
function LoginContent() {

View File

@ -1,3 +1,4 @@
import { redirect } from "next/navigation"
import { MailSettingsSectionFromSegments } from "@/components/gmail/settings/mail-settings-section-view"
export default async function MailSettingsSectionPage({
@ -6,5 +7,8 @@ export default async function MailSettingsSectionPage({
params: Promise<{ section?: string[] }>
}) {
const { section } = await params
if (section?.[0] === "signatures") {
redirect("/mail/settings/accounts")
}
return <MailSettingsSectionFromSegments segments={section} />
}

View File

@ -0,0 +1,95 @@
import type {
IdentityProviderType,
OAuthProviderPreset,
} from "@/lib/admin-settings/org-settings-types"
export type IdentityProviderGuide = {
title: string
steps: string[]
}
const OAUTH_GUIDES: Record<OAuthProviderPreset, IdentityProviderGuide> = {
google: {
title: "Google Workspace / Google Cloud",
steps: [
"Console Google Cloud → APIs & Services → Credentials.",
"Créer un client OAuth « Web application ».",
"Ajouter l'URI de redirection Authentik (copier depuis le formulaire).",
"Renseigner Client ID et Client Secret ici.",
"Restreindre aux comptes de votre organisation via domaines autorisés (claim hd).",
"Scopes recommandés : openid email profile.",
],
},
github: {
title: "GitHub OAuth App",
steps: [
"GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.",
"Authorization callback URL = URI de redirection Authentik.",
"Copier Client ID et générer un Client Secret.",
"Limiter l'accès avec la liste d'organisations GitHub autorisées si besoin.",
],
},
linkedin: {
title: "LinkedIn OAuth 2.0",
steps: [
"LinkedIn Developer Portal → créer une application.",
"Ajouter l'URI de redirection Authentik dans Authorized redirect URLs.",
"Activer les produits Sign In with LinkedIn / OpenID Connect.",
"Copier Client ID et Client Secret.",
],
},
microsoft: {
title: "Microsoft Entra ID (Azure AD)",
steps: [
"Portail Azure → App registrations → New registration.",
"Type de compte : organisation uniquement si SSO entreprise.",
"Redirect URI (Web) = URI Authentik.",
"Créer un client secret dans Certificates & secrets.",
"Renseigner le tenant ID dans organisations autorisées (claim tid).",
],
},
custom: {
title: "OAuth / OpenID Connect personnalisé",
steps: [
"Créer une application OAuth chez votre fournisseur.",
"Renseigner authorization, token et profile/userinfo URLs.",
"URI de redirection = callback Authentik affiché dans le formulaire.",
"Scopes minimum : openid email profile.",
],
},
}
const SAML_GUIDE: IdentityProviderGuide = {
title: "Fournisseur SAML (Azure AD, Okta, Google Workspace…)",
steps: [
"Créer une application SAML côté IdP entreprise.",
"Renseigner l'Entity ID / Audience = slug Authentik ou valeur fournie.",
"ACS / SSO URL = URL de connexion Authentik pour cette source.",
"Importer metadata URL/XML ou renseigner SSO URL + certificat signing.",
"Mapper l'email dans les attributs SAML (NameID ou mail).",
],
}
const LDAP_GUIDE: IdentityProviderGuide = {
title: "LDAP / Active Directory",
steps: [
"Préparer un compte de bind en lecture (bind DN + mot de passe).",
"Indiquer server_uri (ldap:// ou ldaps://) et base_dn de recherche.",
"Activer StartTLS si le serveur ne supporte que LDAP clair + TLS.",
"Optionnel : filtre utilisateur (ex. (sAMAccountName=%(user)s)).",
"Laisser sync_users désactivé pour l'authentification seule.",
],
}
export function guideForProvider(
type: IdentityProviderType,
oauthPreset?: OAuthProviderPreset
): IdentityProviderGuide {
if (type === "oauth") {
return OAUTH_GUIDES[oauthPreset ?? "custom"]
}
if (type === "saml") {
return SAML_GUIDE
}
return LDAP_GUIDE
}

View File

@ -1,6 +1,7 @@
"use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { IdentityProvidersSection } from "@/components/admin/settings/sections/identity-providers-section"
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input"
@ -22,12 +23,13 @@ export function AuthenticationSection() {
const clientID = clientLocked ? (effective?.client_id ?? authentik.client_id) : authentik.client_id
return (
<OrgSettingsSection
title="Authentification Authentik"
description="SSO, provisionnement des comptes Ultimail et groupes par défaut."
policySection="authentik"
>
<Card>
<div className="space-y-8">
<OrgSettingsSection
title="Authentification Authentik"
description="SSO, provisionnement des comptes Ultimail et groupes par défaut."
policySection="authentik"
>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-4">
<div>
@ -106,6 +108,9 @@ export function AuthenticationSection() {
</label>
</CardContent>
</Card>
</OrgSettingsSection>
</OrgSettingsSection>
<IdentityProvidersSection />
</div>
)
}

View File

@ -0,0 +1,759 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import {
Copy,
Loader2,
Plus,
RefreshCw,
TestTube2,
Trash2,
} from "lucide-react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { guideForProvider } from "@/components/admin/settings/guides/identity-provider-guides"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type {
IdentityProvider,
IdentityProviderType,
OAuthProviderPreset,
} from "@/lib/admin-settings/org-settings-types"
import {
useIdentityProviderRedirectURI,
useSyncIdentityProvider,
useTestIdentityProvider,
} from "@/lib/api/hooks/use-identity-provider-actions"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "sonner"
function splitList(value: string): string[] {
return value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean)
}
function joinList(values: string[] | undefined): string {
return (values ?? []).join("\n")
}
function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
function emptyProvider(type: IdentityProviderType): IdentityProvider {
const id = crypto.randomUUID()
const base = {
id,
name: "",
slug: "",
type,
enabled: true,
sync_status: "pending" as const,
allowed_email_domains: [],
allowed_identities: [],
allowed_organizations: [],
default_groups: [],
}
if (type === "oauth") {
return {
...base,
oauth: {
provider: "google",
client_id: "",
client_secret: "",
scopes: "openid email profile",
},
}
}
if (type === "saml") {
return { ...base, saml: { metadata_url: "", entity_id: "", sso_url: "" } }
}
return {
...base,
ldap: {
server_uri: "",
bind_dn: "",
bind_password: "",
base_dn: "",
user_filter: "",
start_tls: true,
sync_users: false,
},
}
}
function syncBadge(status: IdentityProvider["sync_status"]) {
switch (status) {
case "synced":
return <Badge variant="secondary">Synchronisé</Badge>
case "error":
return <Badge variant="destructive">Erreur sync</Badge>
default:
return <Badge variant="outline">En attente</Badge>
}
}
export function IdentityProvidersSection() {
const identityProviders = useOrgSettingsStore((s) => s.identityProviders)
const setIdentityProviders = useOrgSettingsStore((s) => s.setIdentityProviders)
const meta = useOrgSettingsStore((s) => s.meta)
const [draft, setDraft] = useState(identityProviders)
const [addOpen, setAddOpen] = useState(false)
const [editIndex, setEditIndex] = useState<number | null>(null)
const [newType, setNewType] = useState<IdentityProviderType>("oauth")
const testMutation = useTestIdentityProvider()
const syncMutation = useSyncIdentityProvider()
const redirectMutation = useIdentityProviderRedirectURI()
useEffect(() => {
setDraft(identityProviders)
}, [identityProviders])
const redirectTemplate =
meta?.effective.identity_providers?.oauth_redirect_template ??
"http://localhost/auth/source/oauth/callback/{slug}/"
const editingProvider = editIndex != null ? draft.providers[editIndex] : null
const guide = useMemo(() => {
if (!editingProvider) return null
return guideForProvider(
editingProvider.type,
editingProvider.oauth?.provider
)
}, [editingProvider])
function updateProvider(index: number, patch: Partial<IdentityProvider>) {
setDraft((prev) => {
const providers = [...prev.providers]
providers[index] = { ...providers[index], ...patch }
return { ...prev, providers }
})
}
function addProvider(type: IdentityProviderType) {
const provider = emptyProvider(type)
setDraft((prev) => ({
...prev,
providers: [...prev.providers, provider],
}))
setAddOpen(false)
setEditIndex(draft.providers.length)
}
function removeProvider(index: number) {
setDraft((prev) => ({
...prev,
providers: prev.providers.filter((_, i) => i !== index),
}))
if (editIndex === index) setEditIndex(null)
}
function providerSecrets(provider: IdentityProvider): Record<string, { configured?: boolean }> {
const idpSecrets = meta?.secrets?.identity_providers
if (!idpSecrets || typeof idpSecrets !== "object") return {}
const entry = (idpSecrets as Record<string, Record<string, { configured?: boolean }>>)[
provider.id
]
return entry ?? {}
}
async function handleTest(provider: IdentityProvider) {
try {
await testMutation.mutateAsync(provider.id)
toast.success("Configuration valide")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Test échoué")
}
}
async function handleSync(provider: IdentityProvider) {
try {
await syncMutation.mutateAsync(provider.id)
toast.success("Synchronisation Authentik lancée")
} catch {
toast.error("Synchronisation échouée")
}
}
async function copyRedirect(slug: string) {
try {
const res = await redirectMutation.mutateAsync(slug)
await navigator.clipboard.writeText(res.redirect_uri)
toast.success("URI de redirection copiée")
} catch {
const fallback = redirectTemplate.replace("{slug}", slug)
await navigator.clipboard.writeText(fallback)
toast.success("URI de redirection copiée")
}
}
return (
<OrgSettingsSection
title="Fournisseurs d'identité"
description="Sources upstream Authentik (OAuth, SAML, LDAP) avec restrictions d'accès."
policySection="identity_providers"
beforeSave={() => setIdentityProviders(draft)}
>
<label className="flex items-center justify-between gap-4 rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Inscription self-service Authentik</p>
<p className="text-xs text-muted-foreground">
Flow ulti-enrollment : autoriser la création de compte locale en parallèle du SSO entreprise.
</p>
</div>
<Switch
checked={draft.allow_self_enrollment}
onCheckedChange={(allow_self_enrollment) =>
setDraft((prev) => ({ ...prev, allow_self_enrollment }))
}
/>
</label>
<div className="flex items-center justify-between">
<Label>Fournisseurs configurés</Label>
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
<Plus className="mr-2 size-4" />
Ajouter
</Button>
</div>
{draft.providers.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
Aucun fournisseur. Ajoutez Google Workspace, Azure AD SAML, LDAP AD ou un OAuth custom.
</p>
) : (
<div className="grid gap-3">
{draft.providers.map((provider, index) => (
<div
key={provider.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-4"
>
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">
{provider.name || provider.slug || "Nouveau fournisseur"}
</span>
<Badge variant="outline">{provider.type.toUpperCase()}</Badge>
{syncBadge(provider.sync_status)}
</div>
<p className="text-xs text-muted-foreground">
Slug : {provider.slug || "—"}
{provider.sync_error ? ` · ${provider.sync_error}` : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Switch
checked={provider.enabled}
onCheckedChange={(enabled) => updateProvider(index, { enabled })}
/>
<Button variant="outline" size="sm" onClick={() => setEditIndex(index)}>
Modifier
</Button>
<Button
variant="outline"
size="icon"
disabled={testMutation.isPending}
onClick={() => handleTest(provider)}
>
<TestTube2 className="size-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={syncMutation.isPending}
onClick={() => handleSync(provider)}
>
{syncMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
<Button variant="ghost" size="icon" onClick={() => removeProvider(index)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
)}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ajouter un fournisseur</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Type</Label>
<Select
value={newType}
onValueChange={(value) => setNewType(value as IdentityProviderType)}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="oauth">OAuth (Google, GitHub, LinkedIn)</SelectItem>
<SelectItem value="saml">SAML (Azure AD, Okta)</SelectItem>
<SelectItem value="ldap">LDAP / Active Directory</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={() => addProvider(newType)}>Créer</Button>
</div>
</DialogContent>
</Dialog>
<Sheet open={editIndex != null} onOpenChange={(open) => !open && setEditIndex(null)}>
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-2xl">
{editingProvider && editIndex != null ? (
<>
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>Configurer le fournisseur</SheetTitle>
</SheetHeader>
<div className="grid gap-6 px-6 py-6 lg:grid-cols-[1fr_240px]">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Nom</Label>
<Input
className="mt-1 h-9"
value={editingProvider.name}
onChange={(e) => {
const name = e.target.value
updateProvider(editIndex, {
name,
slug: editingProvider.slug || slugify(name),
})
}}
/>
</div>
<div>
<Label>Slug Authentik</Label>
<Input
className="mt-1 h-9"
value={editingProvider.slug}
onChange={(e) =>
updateProvider(editIndex, { slug: slugify(e.target.value) })
}
/>
</div>
</div>
{editingProvider.type === "oauth" ? (
<div className="space-y-4 rounded-lg border p-4">
<div>
<Label>Présélection</Label>
<Select
value={editingProvider.oauth?.provider ?? "google"}
onValueChange={(provider) =>
updateProvider(editIndex, {
oauth: {
...(editingProvider.oauth ?? {
client_id: "",
client_secret: "",
scopes: "openid email profile",
provider: "google" as OAuthProviderPreset,
}),
provider: provider as OAuthProviderPreset,
},
})
}
>
<SelectTrigger className="mt-1 h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="google">Google</SelectItem>
<SelectItem value="github">GitHub</SelectItem>
<SelectItem value="linkedin">LinkedIn</SelectItem>
<SelectItem value="microsoft">Microsoft</SelectItem>
<SelectItem value="custom">Autre / custom</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Client ID</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth?.client_id ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: { ...editingProvider.oauth!, client_id: e.target.value },
})
}
/>
</div>
<div>
<Label>Client Secret</Label>
<Input
className="mt-1 h-9"
type="password"
placeholder={
providerSecrets(editingProvider).oauth_client_secret?.configured
? "Laisser vide pour conserver"
: "Secret OAuth"
}
value={editingProvider.oauth?.client_secret ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: {
...editingProvider.oauth!,
client_secret: e.target.value,
},
})
}
/>
</div>
<div>
<Label>Scopes</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth?.scopes ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: { ...editingProvider.oauth!, scopes: e.target.value },
})
}
/>
</div>
{editingProvider.oauth?.provider === "custom" ? (
<>
<div>
<Label>Authorization URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth.authorization_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: {
...editingProvider.oauth!,
authorization_url: e.target.value,
},
})
}
/>
</div>
<div>
<Label>Token URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth.token_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: {
...editingProvider.oauth!,
token_url: e.target.value,
},
})
}
/>
</div>
<div>
<Label>Profile URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth.profile_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: {
...editingProvider.oauth!,
profile_url: e.target.value,
},
})
}
/>
</div>
</>
) : null}
<div>
<Label>URI de redirection</Label>
<div className="mt-1 flex gap-2">
<Input
className="h-9"
readOnly
value={redirectTemplate.replace(
"{slug}",
editingProvider.slug || "votre-slug"
)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyRedirect(editingProvider.slug)}
>
<Copy className="size-4" />
</Button>
</div>
</div>
</div>
) : null}
{editingProvider.type === "saml" ? (
<div className="space-y-4 rounded-lg border p-4">
<div>
<Label>Metadata URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.saml?.metadata_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
saml: { ...editingProvider.saml!, metadata_url: e.target.value },
})
}
/>
</div>
<div>
<Label>Entity ID / Issuer</Label>
<Input
className="mt-1 h-9"
value={editingProvider.saml?.entity_id ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
saml: { ...editingProvider.saml!, entity_id: e.target.value },
})
}
/>
</div>
<div>
<Label>SSO URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.saml?.sso_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
saml: { ...editingProvider.saml!, sso_url: e.target.value },
})
}
/>
</div>
<div>
<Label>Certificat signing (PEM)</Label>
<Textarea
className="mt-1 min-h-24 font-mono text-xs"
placeholder={
providerSecrets(editingProvider).saml_signing_cert?.configured
? "Laisser vide pour conserver"
: "-----BEGIN CERTIFICATE-----"
}
value={editingProvider.saml?.signing_cert ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
saml: {
...editingProvider.saml!,
signing_cert: e.target.value,
},
})
}
/>
</div>
</div>
) : null}
{editingProvider.type === "ldap" ? (
<div className="space-y-4 rounded-lg border p-4">
<div>
<Label>Server URI</Label>
<Input
className="mt-1 h-9"
placeholder="ldaps://ad.company.com:636"
value={editingProvider.ldap?.server_uri ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, server_uri: e.target.value },
})
}
/>
</div>
<div>
<Label>Bind DN</Label>
<Input
className="mt-1 h-9"
value={editingProvider.ldap?.bind_dn ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, bind_dn: e.target.value },
})
}
/>
</div>
<div>
<Label>Bind password</Label>
<Input
className="mt-1 h-9"
type="password"
placeholder={
providerSecrets(editingProvider).ldap_bind_password?.configured
? "Laisser vide pour conserver"
: "Mot de passe LDAP"
}
value={editingProvider.ldap?.bind_password ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: {
...editingProvider.ldap!,
bind_password: e.target.value,
},
})
}
/>
</div>
<div>
<Label>Base DN</Label>
<Input
className="mt-1 h-9"
value={editingProvider.ldap?.base_dn ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, base_dn: e.target.value },
})
}
/>
</div>
<div>
<Label>Filtre utilisateur</Label>
<Input
className="mt-1 h-9"
placeholder="(sAMAccountName=%(user)s)"
value={editingProvider.ldap?.user_filter ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: {
...editingProvider.ldap!,
user_filter: e.target.value,
},
})
}
/>
</div>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<span className="text-sm">StartTLS</span>
<Switch
checked={editingProvider.ldap?.start_tls ?? true}
onCheckedChange={(start_tls) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, start_tls },
})
}
/>
</label>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<span className="text-sm">Synchroniser les utilisateurs LDAP</span>
<Switch
checked={editingProvider.ldap?.sync_users ?? false}
onCheckedChange={(sync_users) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, sync_users },
})
}
/>
</label>
</div>
) : null}
<div className="space-y-3 rounded-lg border p-4">
<p className="text-sm font-medium">Restrictions d&apos;accès</p>
<div>
<Label>Domaines email autorisés</Label>
<Textarea
className="mt-1 min-h-20"
placeholder="company.com&#10;subsidiary.fr"
value={joinList(editingProvider.allowed_email_domains)}
onChange={(e) =>
updateProvider(editIndex, {
allowed_email_domains: splitList(e.target.value),
})
}
/>
</div>
<div>
<Label>Identités autorisées (emails)</Label>
<Textarea
className="mt-1 min-h-20"
placeholder="admin@company.com"
value={joinList(editingProvider.allowed_identities)}
onChange={(e) =>
updateProvider(editIndex, {
allowed_identities: splitList(e.target.value),
})
}
/>
</div>
<div>
<Label>Organisations autorisées</Label>
<Textarea
className="mt-1 min-h-20"
placeholder="tenant-id Azure, domaine Google hd, org GitHub…"
value={joinList(editingProvider.allowed_organizations)}
onChange={(e) =>
updateProvider(editIndex, {
allowed_organizations: splitList(e.target.value),
})
}
/>
</div>
<div>
<Label>Groupes Authentik par défaut</Label>
<Input
className="mt-1 h-9"
placeholder="ulti-users, ulti-admins"
value={joinList(editingProvider.default_groups).replace(/\n/g, ", ")}
onChange={(e) =>
updateProvider(editIndex, {
default_groups: splitList(e.target.value.replace(/,/g, "\n")),
})
}
/>
</div>
</div>
</div>
{guide ? (
<aside className="rounded-lg border bg-muted/30 p-4">
<p className="text-sm font-medium">{guide.title}</p>
<ol className="mt-3 list-decimal space-y-2 pl-4 text-xs text-muted-foreground">
{guide.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</aside>
) : null}
</div>
</>
) : null}
</SheetContent>
</Sheet>
</OrgSettingsSection>
)
}

View File

@ -31,8 +31,8 @@ export function SecuritySection() {
description="Politiques d'authentification à deux facteurs pour l'organisation."
policySection="two_factor"
>
<Card>
<CardHeader className="pb-3">
<Card className="gap-3 py-4">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium">Exigences</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
@ -60,8 +60,8 @@ export function SecuritySection() {
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<Card className="gap-3 py-4">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium">Méthodes autorisées</CardTitle>
</CardHeader>
<CardContent className="space-y-2">

View File

@ -4,7 +4,15 @@ import { useCallback, useEffect, useState, type ReactNode } from "react"
import { usePathname, useRouter } from "next/navigation"
import { useAuthStore, AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS } from "@/lib/api/auth-store"
import { isOidcConfigured } from "@/lib/auth/oidc-config"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
import {
fetchSession,
applySessionToStore,
type SessionPayload,
} from "@/lib/auth/session-sync"
import {
isSessionExpired,
useSessionGuardStore,
} from "@/lib/auth/session-guard-store"
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
const REFRESH_LEAD_MS = 5 * 60 * 1000
@ -17,52 +25,16 @@ function isPublicPath(pathname: string) {
)
}
type SessionPayload = {
authenticated?: boolean
accessToken?: string
refreshToken?: string | null
expiresAt?: number
user?: PlatformUser | null
}
async function fetchSession(): Promise<SessionPayload | null> {
try {
const res = await fetch("/api/auth/session", { credentials: "include" })
if (!res.ok) return null
return (await res.json()) as SessionPayload
} catch {
return null
}
}
function canTrustPersistedAuth() {
return useAuthStore.persist.hasHydrated() && useAuthStore.getState().isAuthenticated()
}
export function AuthProvider({ children }: { children: ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const login = useAuthStore((s) => s.login)
const logout = useAuthStore((s) => s.logout)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const [ready, setReady] = useState(
() => !isOidcConfigured() || canTrustPersistedAuth()
)
const [ready, setReady] = useState(() => !isOidcConfigured())
const applySession = useCallback(
(data: SessionPayload) => {
if (data.authenticated && data.accessToken && data.expiresAt) {
login(
data.accessToken,
data.refreshToken ?? "",
data.expiresAt,
data.user ?? null
)
return true
}
return false
},
[login]
(data: SessionPayload) => applySessionToStore(data),
[]
)
const syncSession = useCallback(async () => {
@ -81,10 +53,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return
}
if (canTrustPersistedAuth()) {
setReady(true)
}
const data = await fetchSession()
if (cancelled) return
@ -93,19 +61,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return
}
if (data?.authenticated === false || !canTrustPersistedAuth()) {
logout()
const hadMemoryAuth = useAuthStore.getState().isAuthenticated()
logout()
if (hadMemoryAuth && !isPublicPath(pathname) && !isSessionExpired()) {
useSessionGuardStore.getState().setExpired()
}
setReady(true)
}
if (!useAuthStore.persist.hasHydrated()) {
const unsubHydrate = useAuthStore.persist.onFinishHydration(() => {
if (useAuthStore.getState().isAuthenticated()) {
setReady(true)
}
void bootstrap()
})
void bootstrap()
return () => {
cancelled = true
unsubHydrate()
@ -116,7 +83,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true
}
}, [applySession, logout])
}, [applySession, logout, pathname])
useEffect(() => {
if (!ready || !isOidcConfigured()) return
@ -140,6 +107,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
let cancelled = false
void syncSession().then((ok) => {
if (cancelled || ok) return
if (useSessionGuardStore.getState().status === "expired") return
const returnTo = encodeURIComponent(pathname)
router.replace(`/login?returnTo=${returnTo}`)
})

View File

@ -0,0 +1,99 @@
"use client"
import { useCallback, useEffect } from "react"
import { usePathname } from "next/navigation"
import { Icon } from "@iconify/react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
import { tryRefreshSession } from "@/lib/auth/session-sync"
import { cn } from "@/lib/utils"
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
function isPublicPath(pathname: string) {
if (pathname.startsWith("/drive/s/")) return true
return PUBLIC_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix)
)
}
export function SessionGuard() {
const pathname = usePathname()
const status = useSessionGuardStore((s) => s.status)
const returnTo = pathname.startsWith("/") ? pathname : "/mail/inbox"
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
const retrySession = useCallback(async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) return
await tryRefreshSession()
}, [])
useEffect(() => {
if (status !== "offline") return
const onOnline = () => {
void retrySession()
}
window.addEventListener("online", onOnline)
return () => window.removeEventListener("online", onOnline)
}, [status, retrySession])
if (isPublicPath(pathname)) return null
return (
<>
<div
className={cn(
"pointer-events-none fixed inset-x-0 top-0 z-[60] overflow-hidden transition-all duration-300",
status === "offline" ? "max-h-10 opacity-100" : "max-h-0 opacity-0"
)}
aria-live="polite"
>
<div className="pointer-events-auto flex h-10 items-center justify-center gap-2 bg-amber-50 px-4 text-xs font-medium text-amber-900 dark:bg-amber-950/60 dark:text-amber-100">
<Icon icon="mdi:wifi-off" className="size-3.5 shrink-0" />
<span>Pas de connexion internet session non vérifiable pour le moment.</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 border-amber-300 bg-transparent text-xs dark:border-amber-800"
onClick={() => void retrySession()}
>
Réessayer
</Button>
</div>
</div>
<AlertDialog open={status === "expired"}>
<AlertDialogContent
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<AlertDialogHeader>
<AlertDialogTitle>Vous avez é déconnecté</AlertDialogTitle>
<AlertDialogDescription>
Votre session a expiré ou n&apos;est plus valide. Reconnectez-vous
pour continuer à utiliser Ultimail.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction asChild>
<a href={loginHref}>Se reconnecter</a>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -0,0 +1,43 @@
"use client"
import { useRef, useState } from "react"
import { AccountAvatar } from "@/components/suite/account-avatar"
import { AccountSwitcherDropdown } from "@/components/suite/account-switcher-dropdown"
import { Button } from "@/components/ui/button"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
export function EditorAccountButton() {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const identity = useChromeIdentity()
return (
<div className="relative" ref={containerRef}>
<Button
variant="ghost"
size="icon"
className="size-8 overflow-hidden rounded-full p-0"
aria-label={`Compte : ${identity?.email ?? "Utilisateur"}`}
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((current) => !current)}
>
{identity ? (
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
size="sm"
/>
) : (
<span className="flex size-8 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
?
</span>
)}
</Button>
<AccountSwitcherDropdown
open={open}
onOpenChange={setOpen}
containerRef={containerRef}
/>
</div>
)
}

View File

@ -0,0 +1,93 @@
"use client"
import type { ReactNode } from "react"
import Link from "next/link"
import { ArrowLeft, Globe, Lock, Users } from "lucide-react"
import { EditorAccountButton } from "@/components/drive/editor-account-button"
import { OfficeEditorInlineTitle } from "@/components/drive/office-editor-inline-title"
import { ShareDialog } from "@/components/drive/share-dialog"
import { Button } from "@/components/ui/button"
import type { DriveShare } from "@/lib/api/types"
import {
resolveShareButtonIcon,
type ShareButtonIcon,
} from "@/lib/drive/drive-share-button-state"
import { cn } from "@/lib/utils"
function ShareButtonIcon({ kind }: { kind: ShareButtonIcon }) {
if (kind === "globe") return <Globe className="h-4 w-4" aria-hidden />
if (kind === "users") return <Users className="h-4 w-4" aria-hidden />
return <Lock className="h-4 w-4" aria-hidden />
}
export function OfficeEditorChrome({
backHref,
backLabel,
title,
onRename,
renameDisabled = false,
shares = [],
onShareClick,
showShare = false,
showAccount = false,
trailing,
}: {
backHref: string
backLabel: string
title: string
onRename?: (next: string) => Promise<void>
renameDisabled?: boolean
shares?: DriveShare[]
onShareClick?: () => void
showShare?: boolean
showAccount?: boolean
trailing?: React.ReactNode
}) {
const shareIcon = resolveShareButtonIcon(shares)
return (
<>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
<Button variant="ghost" size="sm" asChild className="shrink-0">
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
{backLabel}
</Link>
</Button>
<div className="min-w-0 flex-1">
{onRename ? (
<OfficeEditorInlineTitle
value={title}
onRename={onRename}
disabled={renameDisabled}
/>
) : (
<span className="block truncate px-1.5 text-sm font-medium">{title}</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3">
{trailing}
{showShare ? (
<Button
type="button"
size="sm"
className={cn(
"gap-2 rounded-full border-0 px-4 shadow-none",
"bg-[#1967d2] text-white hover:bg-[#185abc] hover:text-white",
"dark:bg-[#e8eaed] dark:text-[#3c4043] dark:hover:bg-[#dadce0] dark:hover:text-[#202124]"
)}
onClick={onShareClick}
>
<ShareButtonIcon kind={shareIcon} />
Partager
</Button>
) : null}
{showAccount ? <EditorAccountButton /> : null}
</div>
</div>
{showShare ? <ShareDialog /> : null}
</>
)
}

View File

@ -0,0 +1,110 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
export function OfficeEditorInlineTitle({
value,
onRename,
disabled = false,
className,
}: {
value: string
onRename: (next: string) => Promise<void>
disabled?: boolean
className?: string
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(value)
const [busy, setBusy] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const skipBlurCommitRef = useRef(false)
useEffect(() => {
if (!editing) setDraft(value)
}, [value, editing])
useEffect(() => {
if (!editing) return
const timer = window.setTimeout(() => {
const el = inputRef.current
if (!el) return
el.focus()
el.select()
}, 0)
return () => window.clearTimeout(timer)
}, [editing])
const cancelEdit = () => {
skipBlurCommitRef.current = true
setDraft(value)
setEditing(false)
}
const commitEdit = async () => {
if (skipBlurCommitRef.current) {
skipBlurCommitRef.current = false
return
}
const trimmed = draft.trim()
if (!trimmed || trimmed === value) {
setDraft(value)
setEditing(false)
return
}
setBusy(true)
try {
await onRename(trimmed)
setEditing(false)
} catch {
setDraft(value)
setEditing(false)
} finally {
setBusy(false)
}
}
if (editing && !disabled) {
return (
<input
ref={inputRef}
value={draft}
disabled={busy}
aria-label="Nom du fichier"
className={cn(
"h-8 min-w-0 max-w-[min(420px,50vw)] rounded-md border border-border bg-background px-2 text-sm font-medium text-foreground outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring",
className
)}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
void commitEdit()
}
if (event.key === "Escape") {
event.preventDefault()
cancelEdit()
}
}}
onBlur={() => void commitEdit()}
/>
)
}
return (
<button
type="button"
disabled={disabled || busy}
title={disabled ? undefined : "Renommer"}
className={cn(
"min-w-0 truncate rounded-md px-1.5 py-1 text-left text-sm font-medium text-foreground transition-colors hover:bg-muted/70 disabled:cursor-default disabled:hover:bg-transparent",
className
)}
onClick={() => {
if (!disabled && !busy) setEditing(true)
}}
>
{value}
</button>
)
}

View File

@ -1,123 +1,29 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { apiClient } from "@/lib/api/client"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { apiClient } from "@/lib/api/client"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
import { useDriveMutations, useDriveShares } from "@/lib/api/hooks/use-drive-queries"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { resolveRenameName } from "@/lib/drive/drive-default-name"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
import { resolveDriveEditReturnTo } from "@/lib/drive/drive-url"
import { buildDriveEditHref, resolveDriveEditReturnTo } from "@/lib/drive/drive-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
type DocEditorInstance = { destroyEditor: () => void }
declare global {
interface Window {
DocsAPI?: {
DocEditor: new (id: string, config: Record<string, unknown>) => DocEditorInstance
}
DocEditor?: { instances: Record<string, DocEditorInstance | undefined> }
}
function fileNameFromPath(filePath: string): string {
return filePath.split("/").filter(Boolean).pop() ?? filePath
}
let docsApiLoad: Promise<void> | null = null
function loadDocsApi(documentServerUrl: string): Promise<void> {
if (window.DocsAPI) return Promise.resolve()
if (docsApiLoad) return docsApiLoad
const base = documentServerUrl.replace(/\/$/, "") + "/"
docsApiLoad = new Promise((resolve, reject) => {
const script = document.createElement("script")
script.id = "onlyoffice-docs-api"
script.src = `${base}web-apps/apps/api/documents/api.js`
script.async = true
script.onload = () => resolve()
script.onerror = () => {
docsApiLoad = null
reject(new Error(`Error load DocsAPI from ${base}`))
}
document.body.appendChild(script)
})
return docsApiLoad
}
function destroyDocEditor(id: string) {
const inst = window.DocEditor?.instances?.[id]
if (inst) {
try {
inst.destroyEditor()
} catch {
/* ignore */
}
delete window.DocEditor!.instances[id]
}
document.getElementById(id)?.replaceChildren()
}
function OnlyOfficeMount({
editorId,
documentServerUrl,
config,
onError,
}: {
editorId: string
documentServerUrl: string
config: Record<string, unknown>
onError: (message: string) => void
}) {
const configJson = JSON.stringify(config)
const onErrorRef = useRef(onError)
onErrorRef.current = onError
useEffect(() => {
let cancelled = false
const id = editorId
const parsed = JSON.parse(configJson) as Record<string, unknown>
const editorConfig: Record<string, unknown> = {
type: "desktop",
width: "100%",
height: "100%",
events: {
onDocumentReady: () => {
/* loaded */
},
onError: (event: { data?: { errorCode?: number; errorDescription?: string } }) => {
const code = event?.data?.errorCode
const desc = event?.data?.errorDescription
const msg =
desc ||
(code != null ? `OnlyOffice error ${code}` : "Erreur OnlyOffice.")
onErrorRef.current(msg)
},
},
...parsed,
}
void loadDocsApi(documentServerUrl)
.then(() => {
if (cancelled) return
if (!window.DocsAPI) throw new Error("DocsAPI is not defined")
destroyDocEditor(id)
if (!window.DocEditor) window.DocEditor = { instances: {} }
const editor = new window.DocsAPI.DocEditor(id, editorConfig)
window.DocEditor.instances[id] = editor
})
.catch((err: unknown) => {
if (!cancelled) {
onErrorRef.current(
err instanceof Error ? err.message : "Impossible de charger OnlyOffice.",
)
}
})
return () => {
cancelled = true
destroyDocEditor(id)
}
}, [editorId, documentServerUrl, configJson])
return <div id={editorId} className="h-full w-full min-h-0" />
function renameTargetPath(filePath: string, newName: string): string {
const parent = filePath.replace(/\/[^/]+$/, "") || "/"
const base = parent === "/" ? "" : parent
return `${base}/${newName}`.replace(/\/+/g, "/") || `/${newName}`
}
export function OfficeEditor({
@ -127,18 +33,34 @@ export function OfficeEditor({
filePath: string
returnTo?: string | null
}) {
const router = useRouter()
const instanceSeq = useRef(0)
const setSharePath = useDriveUIStore((s) => s.setSharePath)
const [config, setConfig] = useState<Record<string, unknown> | null>(null)
const [serverUrl, setServerUrl] = useState("")
const [editorId, setEditorId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [displayPath, setDisplayPath] = useState(filePath)
useEffect(() => {
setDisplayPath(filePath)
}, [filePath])
const fileName = fileNameFromPath(displayPath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
const backHref = useMemo(
() =>
resolveDriveEditReturnTo(returnTo, filePath, (folderPath) =>
resolveDriveEditReturnTo(returnTo, displayPath, (folderPath) =>
driveFolderHref("files", folderPath)
),
[returnTo, filePath]
[returnTo, displayPath]
)
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
const { rename } = useDriveMutations()
const handleEditorError = useCallback((message: string) => {
setError(message)
}, [])
@ -155,7 +77,7 @@ export function OfficeEditor({
const res = await apiClient.post<{
config: Record<string, unknown>
serverUrl: string
}>("/office/session", { path: filePath, mode: "edit" })
}>("/office/session", { path: displayPath, mode: "edit" })
if (cancelled) return
instanceSeq.current += 1
setConfig(res.config)
@ -169,7 +91,26 @@ export function OfficeEditor({
return () => {
cancelled = true
}
}, [filePath])
}, [displayPath])
const handleRename = useCallback(
async (input: string) => {
const newName = resolveRenameName(
{ name: fileName, type: "file" },
input
)
if (displayFileBaseName(fileName) === input.trim()) return
await rename.mutateAsync({ path: displayPath, new_name: newName })
const nextPath = renameTargetPath(displayPath, newName)
setDisplayPath(nextPath)
router.replace(buildDriveEditHref(nextPath, returnTo ?? undefined))
},
[displayPath, fileName, rename, returnTo, router]
)
const openShareDialog = useCallback(() => {
setSharePath(displayPath, "file")
}, [displayPath, setSharePath])
if (error) {
return (
@ -193,15 +134,16 @@ export function OfficeEditor({
return (
<div className="flex h-dvh flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
<Button variant="ghost" size="sm" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
Drive
</Link>
</Button>
<span className="truncate text-sm font-medium">{filePath.split("/").pop()}</span>
</div>
<OfficeEditorChrome
backHref={backHref}
backLabel="Drive"
title={title}
onRename={handleRename}
shares={sharesData?.shares ?? []}
onShareClick={openShareDialog}
showShare
showAccount
/>
<div className="relative min-h-0 flex-1">
<OnlyOfficeMount
editorId={editorId}

View File

@ -0,0 +1,117 @@
"use client"
import { useEffect, useRef } from "react"
type DocEditorInstance = { destroyEditor: () => void }
declare global {
interface Window {
DocsAPI?: {
DocEditor: new (id: string, config: Record<string, unknown>) => DocEditorInstance
}
DocEditor?: { instances: Record<string, DocEditorInstance | undefined> }
}
}
let docsApiLoad: Promise<void> | null = null
function loadDocsApi(documentServerUrl: string, scriptId: string): Promise<void> {
if (window.DocsAPI) return Promise.resolve()
if (docsApiLoad) return docsApiLoad
const base = documentServerUrl.replace(/\/$/, "") + "/"
docsApiLoad = new Promise((resolve, reject) => {
const script = document.createElement("script")
script.id = scriptId
script.src = `${base}web-apps/apps/api/documents/api.js`
script.async = true
script.onload = () => resolve()
script.onerror = () => {
docsApiLoad = null
reject(new Error(`Error load DocsAPI from ${base}`))
}
document.body.appendChild(script)
})
return docsApiLoad
}
function destroyDocEditor(id: string) {
const inst = window.DocEditor?.instances?.[id]
if (inst) {
try {
inst.destroyEditor()
} catch {
/* ignore */
}
delete window.DocEditor!.instances[id]
}
document.getElementById(id)?.replaceChildren()
}
export function OnlyOfficeMount({
editorId,
documentServerUrl,
config,
onError,
scriptId = "onlyoffice-docs-api",
}: {
editorId: string
documentServerUrl: string
config: Record<string, unknown>
onError: (message: string) => void
scriptId?: string
}) {
const configJson = JSON.stringify(config)
const onErrorRef = useRef(onError)
onErrorRef.current = onError
useEffect(() => {
let cancelled = false
const id = editorId
const parsed = JSON.parse(configJson) as Record<string, unknown>
const editorConfig: Record<string, unknown> = {
type: "desktop",
width: "100%",
height: "100%",
events: {
onDocumentReady: () => {
/* loaded */
},
onError: (event: { data?: { errorCode?: number; errorDescription?: string } }) => {
const code = event?.data?.errorCode
const desc = event?.data?.errorDescription
const msg =
desc ||
(code != null ? `OnlyOffice error ${code}` : "Erreur OnlyOffice.")
onErrorRef.current(msg)
},
},
...parsed,
}
void loadDocsApi(documentServerUrl, scriptId)
.then(() => {
if (cancelled) return
if (!window.DocsAPI) throw new Error("DocsAPI is not defined")
destroyDocEditor(id)
if (!window.DocEditor) window.DocEditor = { instances: {} }
const editor = new window.DocsAPI.DocEditor(id, editorConfig)
window.DocEditor.instances[id] = editor
})
.catch((err: unknown) => {
if (!cancelled) {
onErrorRef.current(
err instanceof Error ? err.message : "Impossible de charger OnlyOffice.",
)
}
})
return () => {
cancelled = true
destroyDocEditor(id)
}
}, [editorId, documentServerUrl, configJson, scriptId])
return <div id={editorId} className="h-full w-full min-h-0" />
}

View File

@ -4,109 +4,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react"
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
import { displayFileBaseName } from "@/lib/drive/display-file-name"
import { resolvePublicShareEditReturnTo } from "@/lib/drive/public-share-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
type DocEditorInstance = { destroyEditor: () => void }
declare global {
interface Window {
DocsAPI?: {
DocEditor: new (id: string, config: Record<string, unknown>) => DocEditorInstance
}
DocEditor?: { instances: Record<string, DocEditorInstance | undefined> }
}
}
let docsApiLoad: Promise<void> | null = null
function loadDocsApi(documentServerUrl: string): Promise<void> {
if (window.DocsAPI) return Promise.resolve()
if (docsApiLoad) return docsApiLoad
const base = documentServerUrl.replace(/\/$/, "") + "/"
docsApiLoad = new Promise((resolve, reject) => {
const script = document.createElement("script")
script.id = "onlyoffice-docs-api-public"
script.src = `${base}web-apps/apps/api/documents/api.js`
script.async = true
script.onload = () => resolve()
script.onerror = () => {
docsApiLoad = null
reject(new Error(`Error load DocsAPI from ${base}`))
}
document.body.appendChild(script)
})
return docsApiLoad
}
function destroyDocEditor(id: string) {
const inst = window.DocEditor?.instances?.[id]
if (inst) {
try {
inst.destroyEditor()
} catch {
/* ignore */
}
}
document.getElementById(id)?.replaceChildren()
}
function OnlyOfficeMount({
editorId,
documentServerUrl,
config,
onError,
}: {
editorId: string
documentServerUrl: string
config: Record<string, unknown>
onError: (message: string) => void
}) {
const configJson = JSON.stringify(config)
const onErrorRef = useRef(onError)
onErrorRef.current = onError
useEffect(() => {
let cancelled = false
const id = editorId
const parsed = JSON.parse(configJson) as Record<string, unknown>
const editorConfig: Record<string, unknown> = {
type: "desktop",
width: "100%",
height: "100%",
events: {
onError: (event: { data?: { errorDescription?: string; errorCode?: number } }) => {
const msg =
event?.data?.errorDescription ||
(event?.data?.errorCode != null
? `OnlyOffice error ${event.data.errorCode}`
: "Erreur OnlyOffice.")
onErrorRef.current(msg)
},
},
...parsed,
}
void loadDocsApi(documentServerUrl)
.then(() => {
if (cancelled || !window.DocsAPI) return
destroyDocEditor(id)
if (!window.DocEditor) window.DocEditor = { instances: {} }
const editor = new window.DocsAPI.DocEditor(id, editorConfig)
window.DocEditor.instances[id] = editor
})
.catch((err: unknown) => {
if (!cancelled) {
onErrorRef.current(
err instanceof Error ? err.message : "Impossible de charger OnlyOffice."
)
}
})
return () => {
cancelled = true
destroyDocEditor(id)
}
}, [editorId, documentServerUrl, configJson])
return <div id={editorId} className="h-full w-full min-h-0" />
function fileNameFromPath(filePath: string, fallback?: string): string {
const base = filePath.split("/").filter(Boolean).pop()
return base || fallback || filePath
}
export function PublicOfficeEditor({
@ -115,12 +21,14 @@ export function PublicOfficeEditor({
password,
returnTo,
mode = "edit",
fileDisplayName,
}: {
token: string
filePath: string
password?: string
returnTo?: string | null
mode?: "edit" | "view"
fileDisplayName?: string
}) {
const instanceSeq = useRef(0)
const guestId = useRef(
@ -132,6 +40,12 @@ export function PublicOfficeEditor({
const [serverUrl, setServerUrl] = useState("")
const [editorId, setEditorId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [resolvedMode, setResolvedMode] = useState<"edit" | "view">(mode)
const fileName = fileDisplayName || fileNameFromPath(filePath)
const title = displayFileBaseName(fileName)
useDriveDocumentTitle(title)
const backHref = useMemo(
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),
[token, returnTo, filePath]
@ -143,6 +57,7 @@ export function PublicOfficeEditor({
setServerUrl("")
setEditorId(null)
setError(null)
setResolvedMode(mode)
void (async () => {
try {
@ -163,12 +78,21 @@ export function PublicOfficeEditor({
const data = (await res.json()) as {
config: Record<string, unknown>
serverUrl: string
mode?: "edit" | "view"
}
if (cancelled) return
instanceSeq.current += 1
setConfig(data.config)
setServerUrl(data.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
setEditorId(`ultidrive-public-editor-${instanceSeq.current}`)
if (data.mode === "edit" || data.mode === "view") {
setResolvedMode(data.mode)
} else {
const editorConfig = data.config?.editorConfig as { mode?: string } | undefined
if (editorConfig?.mode === "edit" || editorConfig?.mode === "view") {
setResolvedMode(editorConfig.mode)
}
}
} catch {
if (!cancelled) setError("Impossible de charger léditeur.")
}
@ -203,21 +127,25 @@ export function PublicOfficeEditor({
return (
<div className="flex h-dvh flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
<Button variant="ghost" size="sm" asChild>
<Link href={backHref}>
<ArrowLeft className="mr-1 h-4 w-4" />
Partage
</Link>
</Button>
<span className="truncate text-sm font-medium">{filePath.split("/").pop()}</span>
</div>
<OfficeEditorChrome
backHref={backHref}
backLabel="Partage"
title={title}
trailing={
resolvedMode === "view" ? (
<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
Lecture seule
</span>
) : null
}
/>
<div className="relative min-h-0 flex-1">
<OnlyOfficeMount
editorId={editorId}
documentServerUrl={serverUrl.replace(/\/$/, "")}
config={config}
onError={handleEditorError}
scriptId="onlyoffice-docs-api-public"
/>
</div>
</div>

View File

@ -2,6 +2,7 @@
import dynamic from "next/dynamic"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect, useState, type ReactNode } from "react"
import { ChevronRight, Download, FolderOpen, Loader2, Lock } from "lucide-react"
import { toast } from "sonner"
@ -20,6 +21,11 @@ import type { DriveFileInfo } from "@/lib/api/types"
import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes"
import { shouldOpenInOnlyOffice } from "@/lib/drive/drive-preview"
import {
sharePermCanEdit,
} from "@/lib/drive/drive-share-permissions"
import { buildPublicShareEditHref } from "@/lib/drive/public-share-url"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { cn } from "@/lib/utils"
@ -206,10 +212,30 @@ export function PublicShareViewPanel({
data: PublicShareView
password?: string
}) {
const router = useRouter()
const file = data.item_type === "file" ? data.file : null
const files = data.item_type === "folder" ? (data.files ?? []) : []
const rootShareName = usePublicShareRootName(token, path, data.name)
const sharedByLabel = publicShareOwnerLabel(data)
const permissions = data.permissions ?? 1
const canEdit = sharePermCanEdit(permissions)
useEffect(() => {
if (!file || !shouldOpenInOnlyOffice(file)) return
const returnTo =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: undefined
router.replace(
buildPublicShareEditHref(
token,
file.path,
returnTo,
canEdit ? "edit" : "view",
file.name
)
)
}, [canEdit, file, router, token])
const downloadCurrent = () => {
if (!file) return
@ -223,6 +249,14 @@ export function PublicShareViewPanel({
anchor.remove()
}
if (file && shouldOpenInOnlyOffice(file)) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
return (
<div
className={cn(

View File

@ -246,6 +246,18 @@ export function EmailListBody({
{listEmails.map((email) => (
<EmailListRow key={email.id} email={email} {...rowPropsBase} />
))}
{data.scrollInfiniteList && data.hasMoreInfinite ? (
<div
ref={data.loadMoreSentinelRef}
className="h-px w-full shrink-0"
aria-hidden
/>
) : null}
{data.isFetchingNextInfinitePage ? (
<div className="flex justify-center py-3 text-xs text-muted-foreground">
Chargement
</div>
) : null}
</div>
)}
</>

View File

@ -107,6 +107,7 @@ export function EmailListLayout({
listPageSize: data.listPageSize,
paginationRangeStart: data.paginationRangeStart,
paginationRangeEnd: data.paginationRangeEnd,
infiniteScroll: data.infiniteScroll,
onListPageSizeChange: data.handleListPageSizeChange,
openMailIndex: reading.openMailIndex,
goListPrevPage: reading.goListPrevPage,

View File

@ -1,5 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { Icon } from "@iconify/react"
import {
Archive,
@ -129,6 +130,7 @@ export type EmailListToolbarProps = {
listPageSize: number
paginationRangeStart: number
paginationRangeEnd: number
infiniteScroll: boolean
onListPageSizeChange: (size: ListPageSize) => void
openMailIndex: number
goListPrevPage: () => void
@ -216,6 +218,7 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
listPageSize,
paginationRangeStart,
paginationRangeEnd,
infiniteScroll,
onListPageSizeChange,
openMailIndex,
goListPrevPage,
@ -249,6 +252,9 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
part = "all",
} = props
const [countsMounted, setCountsMounted] = useState(false)
useEffect(() => setCountsMounted(true), [])
const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS
const openMailToolbar = (showBack: boolean) => (
@ -611,8 +617,16 @@ const mailPaginationControls = (mode: "list" | "view") => {
{mobileFolderLabel}
</h1>
<p className="text-xs text-[#5f6368] leading-snug">
{displayListEmails.length} message{displayListEmails.length !== 1 ? "s" : ""}
{mobileUnreadCount > 0 && ` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`}
{countsMounted ? (
<>
{displayListEmails.length} message
{displayListEmails.length !== 1 ? "s" : ""}
{mobileUnreadCount > 0 &&
` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`}
</>
) : (
"…"
)}
</p>
</div>
<Button
@ -1084,7 +1098,7 @@ const mailPaginationControls = (mode: "list" | "view") => {
<div className="flex-1" />
{listToolbarMode ? mailPaginationControls("list") : null}
{listToolbarMode && !infiniteScroll ? mailPaginationControls("list") : null}
{!splitView && !listToolbarMode ? mailPaginationControls("view") : null}
</div>

View File

@ -10,7 +10,7 @@ import {
import { useSearchParams, useRouter } from "next/navigation"
import { useQueryClient } from "@tanstack/react-query"
import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills"
import { useMessages, useMailSearch } from "@/lib/api/hooks/use-mail-queries"
import { useMessages, useMailSearch, fetchMessagesPage, messagesQueryKey } from "@/lib/api/hooks/use-mail-queries"
import {
useUpdateFlags,
useUpdateLabels,
@ -65,6 +65,7 @@ import {
buildInboxTabBarItems,
} from "@/components/gmail/email-list/email-list-helpers"
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh"
import { useMailListInfiniteScroll } from "@/hooks/use-mail-list-infinite-scroll"
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { resolveListRowAttachments } from "@/lib/attachment-display"
import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
@ -183,6 +184,9 @@ export function useEmailListData({
const queryClient = useQueryClient()
const listPageSize = useMailSettingsStore((s) => s.listPageSize)
const setListPageSize = useMailSettingsStore((s) => s.setListPageSize)
const infiniteScroll = useMailSettingsStore((s) => s.infiniteScroll)
const isXs = useIsXs()
const touchNav = useTouchNav()
const effectiveApiFolder = useMemo(() => {
if (isSearchMode) return "__search__"
@ -196,12 +200,21 @@ export function useEmailListData({
return searchParamsToMessageSearchFilter(searchParams, accountId)
}, [isSearchMode, searchParams, accountId])
const messagesQuery = useMessages(
const scrollInfiniteList = (isXs || infiniteScroll) && !isViewMode
const usesApiInfiniteScroll =
scrollInfiniteList &&
effectiveApiFolder !== "__local__" &&
!isSearchMode
const messagesApiFolder =
effectiveApiFolder === "__search__" || effectiveApiFolder === "__local__"
? "inbox"
: effectiveApiFolder,
: effectiveApiFolder
const messagesQueryPage = usesApiInfiniteScroll ? 1 : listPage
const messagesQuery = useMessages(
messagesApiFolder,
accountId,
listPage,
messagesQueryPage,
listPageSize
)
@ -451,8 +464,12 @@ export function useEmailListData({
const [labelPickerQuery, setLabelPickerQuery] = useState("")
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE)
const isXs = useIsXs()
const touchNav = useTouchNav()
const [accumulatedApiEmails, setAccumulatedApiEmails] = useState<Email[]>([])
const [loadedApiPage, setLoadedApiPage] = useState(1)
const [isFetchingNextInfinitePage, setIsFetchingNextInfinitePage] =
useState(false)
const loadMoreSentinelRef = useRef<HTMLDivElement>(null)
const infiniteListContextRef = useRef("")
const seenEmailIdsRaw = useMailStore((s) => s.seenEmailIds)
const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw])
@ -598,6 +615,158 @@ export function useEmailListData({
[listPageSize, setListPageSize, onMailRouteNavigate]
)
const processEmailsForDisplay = useCallback(
(emails: Email[]) => {
let rows =
selectedFolder !== "inbox"
? emails
: emails.filter((e) =>
emailMatchesInboxTab(e, inboxTab, folderFilterCtx, navMaps)
)
if (conversationMode) {
rows = rows.filter(isThreadHeadMessage)
}
const byId = new Map(emails.map((e) => [e.id, e]))
return sortEmailsForInbox(
rows,
inboxSort,
{
readOverrides: {},
starredIds: [],
importantIds: [],
},
{ conversationMode, byId }
)
},
[
selectedFolder,
inboxTab,
folderFilterCtx,
navMaps,
conversationMode,
inboxSort,
]
)
const infiniteListContextKey = `${selectedFolder}:${inboxTab}:${accountId ?? ""}:${messagesApiFolder}`
useEffect(() => {
if (!usesApiInfiniteScroll) {
setAccumulatedApiEmails([])
setLoadedApiPage(1)
infiniteListContextRef.current = ""
return
}
if (infiniteListContextRef.current !== infiniteListContextKey) {
infiniteListContextRef.current = infiniteListContextKey
setAccumulatedApiEmails(displayListEmails)
setLoadedApiPage(1)
setMobileVisibleCount(LIST_PAGE_SIZE)
listViewportRef.current?.scrollTo(0, 0)
return
}
if (loadedApiPage === 1) {
setAccumulatedApiEmails(displayListEmails)
}
}, [
usesApiInfiniteScroll,
infiniteListContextKey,
displayListEmails,
loadedApiPage,
listViewportRef,
])
const fetchNextApiPage = useCallback(async () => {
if (!usesApiInfiniteScroll || isFetchingNextInfinitePage) return
if (loadedApiPage >= totalPages) return
const nextPage = loadedApiPage + 1
setIsFetchingNextInfinitePage(true)
try {
const result = await queryClient.fetchQuery({
queryKey: messagesQueryKey(
messagesApiFolder,
accountId,
nextPage,
listPageSize
),
queryFn: () =>
fetchMessagesPage(
messagesApiFolder,
accountId,
nextPage,
listPageSize
),
staleTime: 60_000,
})
const processed = processEmailsForDisplay(
result.data.map(apiMessageToEmail)
)
setAccumulatedApiEmails((prev) => {
const ids = new Set(prev.map((e) => e.id))
const appended = processed.filter((e) => !ids.has(e.id))
if (appended.length === 0) return prev
return [...prev, ...appended]
})
setLoadedApiPage(nextPage)
if (nextPage < totalPages) {
void queryClient.prefetchQuery({
queryKey: messagesQueryKey(
messagesApiFolder,
accountId,
nextPage + 1,
listPageSize
),
queryFn: () =>
fetchMessagesPage(
messagesApiFolder,
accountId,
nextPage + 1,
listPageSize
),
staleTime: 60_000,
})
}
} finally {
setIsFetchingNextInfinitePage(false)
}
}, [
usesApiInfiniteScroll,
isFetchingNextInfinitePage,
loadedApiPage,
totalPages,
queryClient,
messagesApiFolder,
accountId,
listPageSize,
processEmailsForDisplay,
])
useEffect(() => {
if (!usesApiInfiniteScroll || loadedApiPage !== 1 || totalPages <= 1) return
void queryClient.prefetchQuery({
queryKey: messagesQueryKey(messagesApiFolder, accountId, 2, listPageSize),
queryFn: () =>
fetchMessagesPage(messagesApiFolder, accountId, 2, listPageSize),
staleTime: 60_000,
})
}, [
usesApiInfiniteScroll,
loadedApiPage,
totalPages,
queryClient,
messagesApiFolder,
accountId,
listPageSize,
])
const infiniteScrollSourceEmails = usesApiInfiniteScroll
? accumulatedApiEmails
: displayListEmails
const pagedEmails = useMemo(() => {
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
return displayListEmails
@ -607,11 +776,74 @@ export function useEmailListData({
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode, listPageSize])
const listEmails = useMemo(() => {
if (isXs && !isViewMode) {
return displayListEmails.slice(0, mobileVisibleCount)
if (!scrollInfiniteList) return pagedEmails
if (usesApiInfiniteScroll) {
if (isXs) {
return infiniteScrollSourceEmails.slice(0, mobileVisibleCount)
}
return infiniteScrollSourceEmails
}
return pagedEmails
}, [isXs, isViewMode, displayListEmails, mobileVisibleCount, pagedEmails])
return displayListEmails.slice(0, mobileVisibleCount)
}, [
scrollInfiniteList,
usesApiInfiniteScroll,
isXs,
infiniteScrollSourceEmails,
mobileVisibleCount,
displayListEmails,
pagedEmails,
])
const hasMoreInfinite = scrollInfiniteList
? usesApiInfiniteScroll
? isXs
? mobileVisibleCount < infiniteScrollSourceEmails.length ||
loadedApiPage < totalPages
: loadedApiPage < totalPages
: mobileVisibleCount < displayListEmails.length
: false
const mobileVisibleCountRef = useRef(mobileVisibleCount)
mobileVisibleCountRef.current = mobileVisibleCount
const loadMoreInfinite = useCallback(() => {
if (!scrollInfiniteList) return
if (usesApiInfiniteScroll) {
const sourceLength = infiniteScrollSourceEmails.length
if (
isXs &&
mobileVisibleCountRef.current < sourceLength
) {
setMobileVisibleCount((prev) =>
Math.min(prev + LIST_PAGE_SIZE, sourceLength)
)
return
}
void fetchNextApiPage()
return
}
setMobileVisibleCount((prev) =>
Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length)
)
}, [
scrollInfiniteList,
usesApiInfiniteScroll,
isXs,
infiniteScrollSourceEmails.length,
fetchNextApiPage,
displayListEmails.length,
])
useMailListInfiniteScroll({
enabled: scrollInfiniteList,
sentinelRef: loadMoreSentinelRef,
scrollRootRef: listViewportRef,
hasMore: hasMoreInfinite,
isLoadingMore: isFetchingNextInfinitePage,
onLoadMore: loadMoreInfinite,
})
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
@ -674,42 +906,34 @@ export function useEmailListData({
inboxCategoryTabIconsCatalog,
])
const prevInfiniteScrollRef = useRef(infiniteScroll)
useEffect(() => {
if (isXs) return
const turnedOn = infiniteScroll && !prevInfiniteScrollRef.current
prevInfiniteScrollRef.current = infiniteScroll
if (!turnedOn || isXs) return
setAccumulatedApiEmails([])
setLoadedApiPage(1)
setMobileVisibleCount(LIST_PAGE_SIZE)
if (listPage !== 1) onMailRouteNavigate({ page: 1 })
}, [infiniteScroll, isXs, listPage, onMailRouteNavigate])
useEffect(() => {
if (isXs || infiniteScroll) return
if (listPage > totalPages) {
onMailRouteNavigate({ page: totalPages })
}
}, [isXs, listPage, totalPages, onMailRouteNavigate])
}, [isXs, infiniteScroll, listPage, totalPages, onMailRouteNavigate])
useEffect(() => {
if (isXs && !isViewMode) return
if (scrollInfiniteList) return
listViewportRef.current?.scrollTo(0, 0)
}, [listPage, selectedFolder, inboxTab, isXs, isViewMode, listViewportRef])
}, [listPage, selectedFolder, inboxTab, scrollInfiniteList, listViewportRef])
useEffect(() => {
if (!isXs) return
if (!scrollInfiniteList || usesApiInfiniteScroll) return
setMobileVisibleCount(LIST_PAGE_SIZE)
listViewportRef.current?.scrollTo(0, 0)
}, [selectedFolder, inboxTab, isXs, listViewportRef])
useEffect(() => {
const root = listViewportRef.current
if (!root || !isXs || isViewMode) return
const onScroll = () => {
if (mobileVisibleCount >= displayListEmails.length) return
const nearBottom =
root.scrollTop + root.clientHeight >= root.scrollHeight - 120
if (nearBottom) {
setMobileVisibleCount((prev) =>
Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length)
)
}
}
root.addEventListener("scroll", onScroll, { passive: true })
return () => root.removeEventListener("scroll", onScroll)
}, [isXs, isViewMode, mobileVisibleCount, displayListEmails.length, listViewportRef])
}, [selectedFolder, inboxTab, scrollInfiniteList, usesApiInfiniteScroll, listViewportRef])
const moveTargets = useMoveTargets({
folderTree: sidebarNav.folderTree,
@ -855,6 +1079,11 @@ export function useEmailListData({
notSpamEmailIds,
recentMoveTargets,
mobileVisibleCount,
infiniteScroll,
scrollInfiniteList,
hasMoreInfinite,
loadMoreSentinelRef,
isFetchingNextInfinitePage,
isXs,
touchNav,
seenEmailIds,
@ -880,6 +1109,10 @@ export function useEmailListData({
listEmails,
listMailIndex,
listRowExtras,
scrollInfiniteList,
hasMoreInfinite,
loadMoreSentinelRef,
isFetchingNextInfinitePage,
moveTargets,
folderUnreadCounts,
unseenInTabById,

View File

@ -510,11 +510,24 @@ export function useEmailListReading(
},
{ root, threshold: 0.12, rootMargin: "0px" }
)
root.querySelectorAll<HTMLElement>("[data-email-row-id]").forEach((el) => {
obs.observe(el)
})
return () => obs.disconnect()
}, [listRowsDep, markEmailSeen, listViewportRef])
const observeNewRows = () => {
root.querySelectorAll<HTMLElement>("[data-email-row-id]").forEach((el) => {
if (el.dataset.seenObserved === "1") return
el.dataset.seenObserved = "1"
obs.observe(el)
})
}
observeNewRows()
const mutationObserver = new MutationObserver(observeNewRows)
mutationObserver.observe(root, { childList: true, subtree: true })
return () => {
mutationObserver.disconnect()
obs.disconnect()
}
}, [markEmailSeen, listViewportRef])
useEffect(() => {
if (!isViewMode && !showSplitReadingPane) return

View File

@ -197,6 +197,8 @@ export function MailSettingsFields({
const setReadingPane = useMailSettingsStore((s) => s.setReadingPane)
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
const infiniteScroll = useMailSettingsStore((s) => s.infiniteScroll)
const setInfiniteScroll = useMailSettingsStore((s) => s.setInfiniteScroll)
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
const isPage = variant === "page"
@ -269,7 +271,14 @@ export function MailSettingsFields({
}
}
/>
<span className="max-w-full truncate text-[10px] text-muted-foreground">
<span
className={cn(
"max-w-full truncate text-[10px]",
activeBackgroundId === preset.id
? "font-bold text-foreground dark:text-white"
: "text-muted-foreground dark:text-mail-text"
)}
>
{preset.label}
</span>
</button>
@ -308,10 +317,20 @@ export function MailSettingsFields({
))}
</SettingsSection>
<SettingsSection title="Affichage" variant={variant}>
<QuickSettingsCheckbox
label="Scroll infini"
checked={infiniteScroll}
onChange={setInfiniteScroll}
helpLabel="Faire défiler la liste au lieu d'utiliser la pagination par pages (bureau)"
/>
</SettingsSection>
<section
className={cn(
"border-b border-border px-4 py-4",
isPage && cn("border-b border-border", MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS)
"px-4 py-4",
!isPage && "border-b border-border",
isPage && MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS
)}
>
<SectionHeader title="Fils de discussion" />

View File

@ -39,7 +39,9 @@ export function QuickSettingsOption({
<span
className={cn(
"min-w-0 flex-1 text-sm",
checked ? "text-[#1a73e8]" : "text-foreground"
checked
? "font-bold text-[#1a73e8] dark:text-white"
: "text-foreground"
)}
>
{label}

View File

@ -30,7 +30,7 @@ export function QuickSettingsPanel() {
className="fixed right-0 top-0 z-[61] flex h-full w-full max-w-[360px] flex-col border-l border-border bg-mail-surface shadow-lg"
>
<header className="flex shrink-0 items-center justify-between gap-2 px-4 pt-5 pb-3">
<h1 className="text-base font-normal text-foreground">
<h1 className="text-base font-normal text-foreground dark:text-white">
Configuration rapide
</h1>
<Button
@ -49,7 +49,7 @@ export function QuickSettingsPanel() {
<div className="px-4 pb-4">
<Button
variant="outline"
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50"
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50 dark:border-[#9aa0a6] dark:text-white dark:hover:bg-[#3c4043]/50"
asChild
>
<Link href="/mail/settings" onClick={() => setOpen(false)}>

View File

@ -58,7 +58,14 @@ export function ThemeSettingsDialog() {
}
}
/>
<span className="max-w-full truncate text-[10px] text-muted-foreground">
<span
className={cn(
"max-w-full truncate text-[10px]",
activeBackgroundId === preset.id
? "font-bold text-foreground dark:text-white"
: "text-muted-foreground dark:text-mail-text"
)}
>
{preset.label}
</span>
</button>

View File

@ -71,16 +71,16 @@ export function AutomationDomainFilterTab({
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-colors",
"inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-muted-foreground hover:bg-muted"
)}
>
{domain === "all" ? (
<LayoutGrid className="size-3.5 shrink-0 opacity-80" aria-hidden />
<LayoutGrid className="size-4 shrink-0 opacity-80" aria-hidden />
) : (
<AutomationDomainMark domain={domain} className="size-3.5" alt="" />
<AutomationDomainMark domain={domain} className="size-4" alt="" />
)}
<span>{label}</span>
</button>

View File

@ -60,12 +60,13 @@ export function WebhookEventScopeEditor({
<p className="text-xs text-muted-foreground">
Le webhook part uniquement pour les événements cochés, dans le périmètre défini ci-dessous.
</p>
<div className="space-y-3">
<div className="flex flex-col gap-4">
{DOMAINS.map((domain) => {
const options = WEBHOOK_EVENT_OPTIONS.filter((o) => o.domain === domain)
return (
<AutomationBorderedFieldset
key={domain}
className="shrink-0"
legend={
<>
<AutomationDomainMark domain={domain} className="size-3.5" alt="" />

View File

@ -208,7 +208,7 @@ export function WebhooksPanel() {
{!editingId ? (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Modèle de départ</Label>
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-2">
{CREATE_DOMAINS.map((domain) => (
<AutomationDomainFilterTab
key={domain}

View File

@ -4,6 +4,7 @@ import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import {
isMailSettingsLeftAlignedPath,
isMailSettingsNavActive,
isMailSettingsWideLayoutPath,
MAIL_SETTINGS_NAV,
@ -107,8 +108,9 @@ export function MailSettingsLayout({ children }: { children: React.ReactNode })
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
<div
className={cn(
"mx-auto w-full max-w-3xl",
isMailSettingsWideLayoutPath(pathname) && "lg:max-w-6xl"
"w-full max-w-3xl",
isMailSettingsWideLayoutPath(pathname) && "lg:max-w-6xl",
isMailSettingsLeftAlignedPath(pathname) ? "mr-auto" : "mx-auto"
)}
>
{children}

View File

@ -6,7 +6,6 @@ import {
} from "@/lib/mail-settings/settings-nav"
import { DisplaySettingsSection } from "@/components/gmail/settings/sections/display-settings-section"
import { AccountsSettingsSection } from "@/components/gmail/settings/sections/accounts-settings-section"
import { SignaturesSettingsSection } from "@/components/gmail/settings/sections/signatures-settings-section"
import { LabelsFoldersSettingsSection } from "@/components/gmail/settings/sections/labels-folders-settings-section"
import { NotificationsSettingsSection } from "@/components/gmail/settings/sections/notifications-settings-section"
import { AutomationSettingsSection } from "@/components/gmail/settings/sections/automation-settings-section"
@ -14,7 +13,6 @@ import { AutomationSettingsSection } from "@/components/gmail/settings/sections/
const SECTIONS: Record<MailSettingsSectionId, React.ComponentType> = {
display: DisplaySettingsSection,
accounts: AccountsSettingsSection,
signatures: SignaturesSettingsSection,
labels: LabelsFoldersSettingsSection,
notifications: NotificationsSettingsSection,
automation: AutomationSettingsSection,

View File

@ -324,7 +324,7 @@ function NavItemSettingsShell({
<Collapsible
open={open}
onOpenChange={setOpen}
className="rounded-lg border border-border bg-card"
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"

View File

@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
@ -12,6 +12,13 @@ import {
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Card,
CardContent,
@ -21,6 +28,7 @@ import {
} from "@/components/ui/card"
import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form"
import { EditMailAccountForm } from "@/components/gmail/settings/edit-mail-account-form"
import { SignatureLibraryCard } from "@/components/gmail/settings/signature-library-card"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import {
useCreateMailAccount,
@ -34,10 +42,13 @@ import {
useUpdateIdentity,
useDeleteIdentity,
} from "@/lib/api/hooks/use-identity-mutations"
import { useMailSignatures } from "@/lib/api/hooks/use-mail-signatures"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import type { ApiMailAccount } from "@/lib/api/types"
import type { ApiMailAccount, ApiMailSignature } from "@/lib/api/types"
const NONE_SIGNATURE = "__none__"
export function AccountsSettingsSection() {
const router = useRouter()
@ -45,8 +56,17 @@ export function AccountsSettingsSection() {
const oauthStatus = searchParams.get("oauth")
const { ready, authenticated } = useAuthReady()
const { data: accounts = [], isFetching, isError, refetch, isPending } = useMailAccounts()
const {
data: signatures = [],
isFetching: signaturesFetching,
isError: signaturesError,
refetch: refetchSignatures,
isPending: signaturesPending,
} = useMailSignatures()
const createAccount = useCreateMailAccount()
const showInitialLoad = ready && authenticated && isPending && accounts.length === 0
const showSignaturesInitialLoad =
ready && authenticated && signaturesPending && signatures.length === 0
useEffect(() => {
if (oauthStatus === "success") {
@ -55,11 +75,19 @@ export function AccountsSettingsSection() {
}
}, [oauthStatus, refetch, router])
const syncFetching = isFetching || signaturesFetching
const syncError = isError || signaturesError
function handleRetry() {
void refetch()
void refetchSignatures()
}
return (
<>
<SettingsSectionHeader
title="Comptes mail"
description="Connexions IMAP/SMTP et identités d'envoi par compte."
description="Connexions IMAP/SMTP, identités d'envoi et signatures."
/>
{oauthStatus === "success" ? (
<p className="text-sm text-green-600 dark:text-green-500">
@ -72,7 +100,11 @@ export function AccountsSettingsSection() {
{searchParams.get("code") ? ` (${searchParams.get("code")})` : ""}.
</p>
) : null}
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<SettingsSyncBanner
isFetching={syncFetching}
isError={syncError}
onRetry={handleRetry}
/>
<div className="space-y-6">
<AddMailAccountForm
@ -80,19 +112,32 @@ export function AccountsSettingsSection() {
onSubmit={(payload) => createAccount.mutate(payload)}
/>
{showInitialLoad ? null : accounts.length === 0 ? (
{!ready || showInitialLoad ? null : accounts.length === 0 ? (
<p className="text-sm text-muted-foreground">
Aucun compte mail configuré. Ajoutez votre adresse e-mail ci-dessus pour commencer.
</p>
) : (
accounts.map((account) => <AccountCard key={account.id} account={account} />)
accounts.map((account) => (
<AccountCard key={account.id} account={account} signatures={signatures} />
))
)}
<SignatureLibraryCard
signatures={signatures}
showInitialLoad={showSignaturesInitialLoad}
/>
</div>
</>
)
}
function AccountCard({ account }: { account: ApiMailAccount }) {
function AccountCard({
account,
signatures,
}: {
account: ApiMailAccount
signatures: ApiMailSignature[]
}) {
const deleteAccount = useDeleteMailAccount()
const resanitizeBodies = useResanitizeBodies(account.id)
const syncAccount = useSyncMailAccount(account.id)
@ -209,6 +254,7 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
accountId={account.id}
accountEmail={account.email}
identities={identities}
signatures={signatures}
/>
</CardContent>
</Card>
@ -219,6 +265,7 @@ function IdentitiesBlock({
accountId,
accountEmail,
identities,
signatures,
}: {
accountId: string
accountEmail: string
@ -231,6 +278,7 @@ function IdentitiesBlock({
default_signature_id?: string
reply_to_addrs?: string[]
}>
signatures: ApiMailSignature[]
}) {
const createIdentity = useCreateIdentity(accountId)
const updateIdentity = useUpdateIdentity(accountId)
@ -238,6 +286,14 @@ function IdentitiesBlock({
const [showAddForm, setShowAddForm] = useState(false)
const [newIdentity, setNewIdentity] = useState({ email: accountEmail, name: "" })
const signatureOptions = useMemo(
() => [
{ value: NONE_SIGNATURE, label: "Aucune" },
...signatures.map((s) => ({ value: s.id, label: s.name })),
],
[signatures]
)
useEffect(() => {
if (!showAddForm) {
setNewIdentity({ email: accountEmail, name: "" })
@ -250,6 +306,7 @@ function IdentitiesBlock({
email: string
name: string
is_default: boolean
default_signature_id: string
}> = {}
) {
return {
@ -258,7 +315,7 @@ function IdentitiesBlock({
name: patch.name ?? identity.name,
is_default: patch.is_default ?? identity.is_default,
signature_html: identity.signature_html ?? "",
default_signature_id: identity.default_signature_id ?? "",
default_signature_id: patch.default_signature_id ?? identity.default_signature_id ?? "",
reply_to_addrs: identity.reply_to_addrs,
}
}
@ -289,51 +346,83 @@ function IdentitiesBlock({
<p className="text-xs text-muted-foreground">Aucune identité configurée.</p>
) : (
<ul className="space-y-3">
{identities.map((identity) => (
<li key={identity.id} className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="grid flex-1 gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Nom affiché</Label>
<Input
defaultValue={identity.name}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === identity.name) return
updateIdentity.mutate(identityPayload(identity, { name: next }))
}}
/>
{identities.map((identity) => {
const currentSignature =
identity.default_signature_id && identity.default_signature_id !== ""
? identity.default_signature_id
: NONE_SIGNATURE
return (
<li key={identity.id} className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="grid flex-1 gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Nom affiché</Label>
<Input
defaultValue={identity.name}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === identity.name) return
updateIdentity.mutate(identityPayload(identity, { name: next }))
}}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Adresse d&apos;envoi</Label>
<Input
type="email"
defaultValue={identity.email}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === identity.email) return
updateIdentity.mutate(identityPayload(identity, { email: next }))
}}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Signature</Label>
<Select
value={currentSignature}
disabled={updateIdentity.isPending}
onValueChange={(value) =>
updateIdentity.mutate(
identityPayload(identity, {
default_signature_id: value === NONE_SIGNATURE ? "" : value,
})
)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Signature" />
</SelectTrigger>
<SelectContent>
{signatureOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{identity.is_default ? (
<p className="text-xs text-muted-foreground sm:col-span-3">
Identité par défaut
</p>
) : null}
</div>
<div className="space-y-1">
<Label className="text-xs">Adresse d&apos;envoi</Label>
<Input
type="email"
defaultValue={identity.email}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === identity.email) return
updateIdentity.mutate(identityPayload(identity, { email: next }))
}}
/>
</div>
{identity.is_default ? (
<p className="text-xs text-muted-foreground sm:col-span-2">
Identité par défaut
</p>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer l'identité"
onClick={() => deleteIdentity.mutate(identity.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer l'identité"
onClick={() => deleteIdentity.mutate(identity.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</li>
))}
</li>
)
})}
</ul>
)}

View File

@ -7,6 +7,7 @@ import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-p
import { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-providers-panel"
import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel"
import { ApiTokensPanel } from "@/components/gmail/settings/automation/api-tokens-panel"
import { MAIL_SETTINGS_TABS_LIST_CLASS } from "@/lib/mail-chrome-classes"
export function AutomationSettingsSection() {
return (
@ -16,11 +17,11 @@ export function AutomationSettingsSection() {
description="Règles et webhooks pour les événements mail, Drive et contacts — conditions et actions adaptées au déclencheur."
/>
<Tabs defaultValue="rules">
<TabsList className="flex h-auto flex-wrap">
<TabsList className={MAIL_SETTINGS_TABS_LIST_CLASS}>
<TabsTrigger value="rules">Règles</TabsTrigger>
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
<TabsTrigger value="llm">Fournisseurs LLM</TabsTrigger>
<TabsTrigger value="search">Fournisseurs de recherche</TabsTrigger>
<TabsTrigger value="search">Recherche</TabsTrigger>
<TabsTrigger value="tokens">Tokens API</TabsTrigger>
</TabsList>

View File

@ -12,7 +12,7 @@ export function DisplaySettingsSection() {
<>
<SettingsSectionHeader
title="Réglages d'affichage"
description="Densité, thème, type de boîte de réception et volet de lecture."
description="Densité, thème, type de boîte de réception, volet de lecture et défilement de la liste."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<MailSettingsFields variant="page" />

View File

@ -32,6 +32,7 @@ import {
ImapFolderSettingsTree,
NavLabelSettingsCard,
} from "@/components/gmail/settings/nav-item-settings-card"
import { MAIL_SETTINGS_TABS_LIST_CLASS } from "@/lib/mail-chrome-classes"
function SettingsFormHeading({
icon: Icon,
@ -96,7 +97,7 @@ export function LabelsFoldersSettingsSection() {
description="Mêmes réglages que dans la barre latérale : couleur, affichage dans les listes, arborescence, renommage."
/>
<Tabs defaultValue="labels">
<TabsList>
<TabsList className={MAIL_SETTINGS_TABS_LIST_CLASS}>
<TabsTrigger value="labels">Libellés</TabsTrigger>
<TabsTrigger value="folders-global">Dossiers globaux</TabsTrigger>
<TabsTrigger value="folders-account">Dossiers par compte</TabsTrigger>

View File

@ -1,387 +0,0 @@
"use client"
import { useMemo, useState } from "react"
import { PenLine, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import {
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS,
} from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries"
import {
useCreateMailSignature,
useDeleteMailSignature,
useMailSignatures,
useUpdateMailSignature,
} from "@/lib/api/hooks/use-mail-signatures"
import { useUpdateIdentity } from "@/lib/api/hooks/use-identity-mutations"
import type { ApiIdentity, ApiMailSignature } from "@/lib/api/types"
const NONE_SIGNATURE = "__none__"
export function SignaturesSettingsSection() {
const { ready, authenticated } = useAuthReady()
const {
data: signatures = [],
isFetching,
isError,
refetch,
isPending,
} = useMailSignatures()
const showInitialLoad = ready && authenticated && isPending && signatures.length === 0
return (
<>
<SettingsSectionHeader
title="Signatures"
description="Bibliothèque de signatures réutilisables et attribution par identité d'envoi."
/>
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<div className={cn("space-y-6 lg:space-y-0", MAIL_SETTINGS_PAGE_MASONRY_CLASS)}>
<div className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
<SignatureLibrary
signatures={signatures}
showInitialLoad={showInitialLoad}
/>
</div>
<div className={MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS}>
<IdentitySignatureAssignments signatures={signatures} />
</div>
</div>
</>
)
}
function SignatureLibrary({
signatures,
showInitialLoad,
}: {
signatures: ApiMailSignature[]
showInitialLoad: boolean
}) {
const createSignature = useCreateMailSignature()
const updateSignature = useUpdateMailSignature()
const deleteSignature = useDeleteMailSignature()
const [showAddForm, setShowAddForm] = useState(false)
const [draft, setDraft] = useState({ name: "", html: "" })
function handleCreate() {
const name = draft.name.trim()
if (!name) return
createSignature.mutate(
{ name, html: draft.html },
{
onSuccess: () => {
setShowAddForm(false)
setDraft({ name: "", html: "" })
},
}
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<PenLine className="size-4" />
Bibliothèque
</CardTitle>
<CardDescription>
Créez des signatures nommées que vous pourrez réutiliser sur plusieurs identités.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{showInitialLoad ? null : signatures.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucune signature enregistrée.</p>
) : (
<ul className="space-y-3">
{signatures.map((signature) => (
<li
key={signature.id}
className="rounded-lg border border-border p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-2">
<div className="space-y-1">
<Label className="text-xs">Nom</Label>
<Input
defaultValue={signature.name}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === signature.name) return
updateSignature.mutate({
signatureId: signature.id,
name: next,
html: signature.html,
})
}}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer la signature"
onClick={() => deleteSignature.mutate(signature.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<div className="space-y-1">
<Label className="text-xs">Contenu HTML</Label>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
defaultValue={signature.html}
placeholder="<div>…</div>"
onBlur={(e) => {
if (e.target.value === signature.html) return
updateSignature.mutate({
signatureId: signature.id,
name: signature.name,
html: e.target.value,
})
}}
/>
</div>
{signature.html?.trim() ? (
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
<p className="mb-2 text-xs text-muted-foreground">Aperçu</p>
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: signature.html }}
/>
</div>
) : null}
</li>
))}
</ul>
)}
{showAddForm ? (
<div className="rounded-lg border border-border p-3 space-y-3 max-w-2xl">
<div className="space-y-1">
<Label className="text-xs">Nom</Label>
<Input
value={draft.name}
placeholder="Professionnelle"
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Contenu HTML</Label>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
value={draft.html}
placeholder="<div style=&quot;color:#5f6368&quot;>…</div>"
onChange={(e) => setDraft({ ...draft, html: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={createSignature.isPending || !draft.name.trim()}
onClick={handleCreate}
>
Enregistrer
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setShowAddForm(false)
setDraft({ name: "", html: "" })
}}
>
Annuler
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowAddForm(true)}
>
<Plus className="size-3.5 mr-1.5" />
Ajouter une signature
</Button>
)}
</CardContent>
</Card>
)
}
function IdentitySignatureAssignments({
signatures,
}: {
signatures: ApiMailSignature[]
}) {
const { data: accounts = [] } = useMailAccounts()
if (accounts.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Attribution par identité</CardTitle>
<CardDescription>
Ajoutez un compte mail pour configurer les signatures par défaut.
</CardDescription>
</CardHeader>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Attribution par identité</CardTitle>
<CardDescription>
Choisissez la signature insérée par défaut pour chaque adresse d&apos;envoi.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{accounts.map((account) => (
<AccountIdentitySignatures
key={account.id}
accountId={account.id}
accountLabel={`${account.name} · ${account.email}`}
signatures={signatures}
/>
))}
</CardContent>
</Card>
)
}
function AccountIdentitySignatures({
accountId,
accountLabel,
signatures,
}: {
accountId: string
accountLabel: string
signatures: ApiMailSignature[]
}) {
const { data: identities = [] } = useIdentities(accountId)
const updateIdentity = useUpdateIdentity(accountId)
const signatureOptions = useMemo(
() => [
{ value: NONE_SIGNATURE, label: "Aucune" },
...signatures.map((s) => ({ value: s.id, label: s.name })),
],
[signatures]
)
if (identities.length === 0) {
return (
<div className="space-y-1">
<p className="text-sm font-medium">{accountLabel}</p>
<p className="text-xs text-muted-foreground">Aucune identité d&apos;envoi.</p>
</div>
)
}
return (
<div className="space-y-3">
<p className="text-sm font-medium">{accountLabel}</p>
<ul className="space-y-2">
{identities.map((identity) => (
<IdentitySignatureRow
key={identity.id}
identity={identity}
options={signatureOptions}
pending={updateIdentity.isPending}
onAssign={(defaultSignatureId) =>
updateIdentity.mutate({
identityId: identity.id,
email: identity.email,
name: identity.name,
is_default: identity.is_default,
signature_html: identity.signature_html ?? "",
default_signature_id: defaultSignatureId,
reply_to_addrs: identity.reply_to_addrs,
})
}
/>
))}
</ul>
</div>
)
}
function IdentitySignatureRow({
identity,
options,
pending,
onAssign,
}: {
identity: ApiIdentity
options: Array<{ value: string; label: string }>
pending: boolean
onAssign: (defaultSignatureId: string) => void
}) {
const current =
identity.default_signature_id && identity.default_signature_id !== ""
? identity.default_signature_id
: NONE_SIGNATURE
return (
<li className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 rounded-lg border border-border p-3">
<div className="min-w-[10rem] max-w-full flex-1">
<p className="text-sm font-medium">{identity.name}</p>
<p className="text-xs text-muted-foreground break-all">{identity.email}</p>
{identity.is_default ? (
<p className="text-xs text-muted-foreground mt-0.5">Identité par défaut</p>
) : null}
</div>
<div className="min-w-[10rem] max-w-full flex-[1_1_10rem]">
<Label className="text-xs sr-only">Signature par défaut</Label>
<Select
value={current}
disabled={pending}
onValueChange={(value) =>
onAssign(value === NONE_SIGNATURE ? "" : value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Signature" />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</li>
)
}

View File

@ -8,7 +8,7 @@ export function SettingsComingSoon({
description: string
}) {
return (
<Card className="border-dashed bg-muted/20 py-4 shadow-none">
<Card className="mail-settings-card border-dashed border-mail-border bg-mail-surface py-4 shadow-sm dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]">
<CardHeader className="px-4 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
</CardHeader>

View File

@ -0,0 +1,185 @@
"use client"
import { useState } from "react"
import { PenLine, Plus, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
useCreateMailSignature,
useDeleteMailSignature,
useUpdateMailSignature,
} from "@/lib/api/hooks/use-mail-signatures"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import type { ApiMailSignature } from "@/lib/api/types"
export function SignatureLibraryCard({
signatures,
showInitialLoad,
}: {
signatures: ApiMailSignature[]
showInitialLoad: boolean
}) {
const { ready } = useAuthReady()
const createSignature = useCreateMailSignature()
const updateSignature = useUpdateMailSignature()
const deleteSignature = useDeleteMailSignature()
const [showAddForm, setShowAddForm] = useState(false)
const [draft, setDraft] = useState({ name: "", html: "" })
function handleCreate() {
const name = draft.name.trim()
if (!name) return
createSignature.mutate(
{ name, html: draft.html },
{
onSuccess: () => {
setShowAddForm(false)
setDraft({ name: "", html: "" })
},
}
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<PenLine className="size-4" />
Signatures
</CardTitle>
<CardDescription>
Créez des signatures nommées réutilisables sur vos identités d&apos;envoi.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!ready || showInitialLoad ? null : signatures.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucune signature enregistrée.</p>
) : (
<ul className="space-y-3">
{signatures.map((signature) => (
<li
key={signature.id}
className="rounded-lg border border-border p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-2">
<div className="space-y-1">
<Label className="text-xs">Nom</Label>
<Input
defaultValue={signature.name}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === signature.name) return
updateSignature.mutate({
signatureId: signature.id,
name: next,
html: signature.html,
})
}}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer la signature"
onClick={() => deleteSignature.mutate(signature.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<div className="space-y-1">
<Label className="text-xs">Contenu HTML</Label>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
defaultValue={signature.html}
placeholder="<div>…</div>"
onBlur={(e) => {
if (e.target.value === signature.html) return
updateSignature.mutate({
signatureId: signature.id,
name: signature.name,
html: e.target.value,
})
}}
/>
</div>
{signature.html?.trim() ? (
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
<p className="mb-2 text-xs text-muted-foreground">Aperçu</p>
<div
className="prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: signature.html }}
/>
</div>
) : null}
</li>
))}
</ul>
)}
{showAddForm ? (
<div className="rounded-lg border border-border p-3 space-y-3 max-w-2xl">
<div className="space-y-1">
<Label className="text-xs">Nom</Label>
<Input
value={draft.name}
placeholder="Professionnelle"
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Contenu HTML</Label>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
value={draft.html}
placeholder="<div style=&quot;color:#5f6368&quot;>…</div>"
onChange={(e) => setDraft({ ...draft, html: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={createSignature.isPending || !draft.name.trim()}
onClick={handleCreate}
>
Enregistrer
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setShowAddForm(false)
setDraft({ name: "", html: "" })
}}
>
Annuler
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowAddForm(true)}
>
<Plus className="size-3.5 mr-1.5" />
Ajouter une signature
</Button>
)}
</CardContent>
</Card>
)
}

View File

@ -15,7 +15,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 cursor-pointer rounded-[4px] border shadow-xs transition-all outline-none hover:border-foreground/60 hover:shadow-sm focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
'peer border-[1.5px] border-mail-row-checkbox-border bg-background dark:bg-mail-surface-muted data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 cursor-pointer rounded-[4px] shadow-xs transition-all outline-none hover:border-foreground/60 hover:shadow-sm focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}

View File

@ -14,8 +14,13 @@ type UltiMailLogoProps = {
/** Icône extraite du master PNG (pas le SVG VTracer, trop « M Gmail » à petite taille). */
const HEADER_ICON = "/brand/ultimail-header-icon.png"
const STACKED_WORDMARK = "/brand/ultimail-wordmark-stacked.png"
/** Fond transparent, picto couleurs d'origine, texte éclairci (pas de filter CSS invert/hue). */
const STACKED_WORDMARK_DARK = "/brand/ultimail-wordmark-stacked-dark.png"
const DEFAULT_INBOX_HREF = "/mail/inbox"
const STACKED_IMG_CLASS =
"h-[11.25rem] w-auto max-w-none shrink-0 object-contain select-none sm:h-[12rem]"
export function UltiMailLogo({
className,
variant = "horizontal",
@ -63,7 +68,16 @@ export function UltiMailLogo({
width={320}
height={320}
draggable={false}
className="h-[11.25rem] w-auto max-w-none shrink-0 object-contain select-none sm:h-[12rem]"
className={cn(STACKED_IMG_CLASS, "dark:hidden")}
/>
<img
src={STACKED_WORDMARK_DARK}
alt="Ultimail"
width={320}
height={320}
draggable={false}
aria-hidden
className={cn(STACKED_IMG_CLASS, "hidden dark:block")}
/>
</div>
)

View File

@ -0,0 +1,51 @@
"use client"
import { useEffect, type RefObject } from "react"
type UseMailListInfiniteScrollOptions = {
enabled: boolean
sentinelRef: RefObject<HTMLElement | null>
scrollRootRef: RefObject<HTMLElement | null>
hasMore: boolean
isLoadingMore: boolean
onLoadMore: () => void
/** Charge avant le bas visible pour éviter lattente au scroll. */
rootMargin?: string
}
export function useMailListInfiniteScroll({
enabled,
sentinelRef,
scrollRootRef,
hasMore,
isLoadingMore,
onLoadMore,
rootMargin = "480px",
}: UseMailListInfiniteScrollOptions) {
useEffect(() => {
if (!enabled || !hasMore) return
const sentinel = sentinelRef.current
const root = scrollRootRef.current
if (!sentinel || !root) return
const observer = new IntersectionObserver(
([entry]) => {
if (!entry?.isIntersecting || isLoadingMore) return
onLoadMore()
},
{ root, rootMargin, threshold: 0 }
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [
enabled,
hasMore,
isLoadingMore,
onLoadMore,
rootMargin,
sentinelRef,
scrollRootRef,
])
}

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, FilePolicySettings } from "@/lib/admin-settings/org-settings-types"
import type { IntegrationEntry, OrgSettingsState, FilePolicySettings, IdentityProvidersPolicy, IdentityProvider } from "@/lib/admin-settings/org-settings-types"
const INTEGRATION_HREFS: Record<string, string> = {
authentik: "/admin/settings/authentication",
@ -19,6 +19,59 @@ function mergeIntegrations(
}))
}
const DEFAULT_IDENTITY_PROVIDERS: IdentityProvidersPolicy = {
allow_self_enrollment: true,
default_login_source: "",
providers: [],
}
function mergeIdentityProviders(
fromApi: Partial<IdentityProvidersPolicy> | undefined
): IdentityProvidersPolicy {
return {
...DEFAULT_IDENTITY_PROVIDERS,
...fromApi,
providers: (fromApi?.providers ?? []).map((provider) => ({
...provider,
allowed_email_domains: provider.allowed_email_domains ?? [],
allowed_identities: provider.allowed_identities ?? [],
allowed_organizations: provider.allowed_organizations ?? [],
default_groups: provider.default_groups ?? [],
sync_status: provider.sync_status ?? "pending",
oauth: provider.oauth
? { ...provider.oauth, client_secret: provider.oauth.client_secret ?? "" }
: undefined,
ldap: provider.ldap
? { ...provider.ldap, bind_password: provider.ldap.bind_password ?? "" }
: undefined,
saml: provider.saml
? { ...provider.saml, signing_cert: provider.saml.signing_cert ?? "" }
: undefined,
})),
}
}
function mapProviderToApi(provider: IdentityProvider) {
return {
id: provider.id,
name: provider.name,
slug: provider.slug,
type: provider.type,
enabled: provider.enabled,
authentik_pk: provider.authentik_pk,
sync_status: provider.sync_status,
sync_error: provider.sync_error,
last_synced_at: provider.last_synced_at,
allowed_email_domains: provider.allowed_email_domains,
allowed_identities: provider.allowed_identities,
allowed_organizations: provider.allowed_organizations,
default_groups: provider.default_groups,
oauth: provider.oauth,
saml: provider.saml,
ldap: provider.ldap,
}
}
const DEFAULT_FILE_POLICIES: FilePolicySettings = {
max_upload_mib: 512,
allowed_extensions: "",
@ -49,6 +102,7 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
allow_password_fallback: policy.authentik.allow_password_fallback,
default_groups: policy.authentik.default_groups,
},
identityProviders: mergeIdentityProviders(policy.identity_providers),
twoFactor: {
required_for_all: policy.two_factor.required_for_all,
required_for_admins: policy.two_factor.required_for_admins,
@ -90,6 +144,11 @@ export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
allow_password_fallback: state.authentik.allow_password_fallback,
default_groups: state.authentik.default_groups,
},
identity_providers: {
allow_self_enrollment: state.identityProviders.allow_self_enrollment,
default_login_source: state.identityProviders.default_login_source,
providers: state.identityProviders.providers.map(mapProviderToApi),
},
two_factor: {
required_for_all: state.twoFactor.required_for_all,
required_for_admins: state.twoFactor.required_for_admins,

View File

@ -16,6 +16,7 @@ import type {
PluginEntry,
TwoFactorPolicy,
UsageQuotaDefaults,
IdentityProvidersPolicy,
} from "@/lib/admin-settings/org-settings-types"
const DEFAULT_AUTHENTIK: AuthentikSettings = {
@ -28,6 +29,12 @@ const DEFAULT_AUTHENTIK: AuthentikSettings = {
default_groups: "ulti-users",
}
const DEFAULT_IDENTITY_PROVIDERS: IdentityProvidersPolicy = {
allow_self_enrollment: true,
default_login_source: "",
providers: [],
}
const DEFAULT_TWO_FACTOR: TwoFactorPolicy = {
required_for_all: false,
required_for_admins: true,
@ -176,6 +183,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
type OrgSettingsActions = {
setAuthentik: (patch: Partial<AuthentikSettings>) => void
setIdentityProviders: (patch: Partial<IdentityProvidersPolicy>) => void
setTwoFactor: (patch: Partial<TwoFactorPolicy>) => void
setStorageQuotas: (patch: Partial<OrgStorageQuotas>) => void
setUsageQuotas: (patch: Partial<UsageQuotaDefaults>) => void
@ -195,6 +203,7 @@ type OrgSettingsActions = {
toggleIntegration: (id: string, enabled: boolean) => void
hydrateFromApi: (patch: Partial<{
authentik: AuthentikSettings
identityProviders: IdentityProvidersPolicy
twoFactor: TwoFactorPolicy
storageQuotas: OrgStorageQuotas
usageQuotas: UsageQuotaDefaults
@ -213,6 +222,7 @@ type OrgSettingsActions = {
export const useOrgSettingsStore = create<
{
authentik: AuthentikSettings
identityProviders: IdentityProvidersPolicy
twoFactor: TwoFactorPolicy
storageQuotas: OrgStorageQuotas
usageQuotas: UsageQuotaDefaults
@ -230,6 +240,7 @@ export const useOrgSettingsStore = create<
} & OrgSettingsActions
>()((set) => ({
authentik: DEFAULT_AUTHENTIK,
identityProviders: DEFAULT_IDENTITY_PROVIDERS,
twoFactor: DEFAULT_TWO_FACTOR,
storageQuotas: DEFAULT_STORAGE_QUOTAS,
usageQuotas: DEFAULT_USAGE_QUOTAS,
@ -247,6 +258,10 @@ export const useOrgSettingsStore = create<
setAuthentik: (patch) =>
set((s) => ({ authentik: { ...s.authentik, ...patch } })),
setIdentityProviders: (patch) =>
set((s) => ({
identityProviders: { ...s.identityProviders, ...patch },
})),
setTwoFactor: (patch) =>
set((s) => ({ twoFactor: { ...s.twoFactor, ...patch } })),
setStorageQuotas: (patch) =>

View File

@ -10,6 +10,71 @@ export type AuthentikSettings = {
default_groups: string
}
export type IdentityProviderType = "oauth" | "saml" | "ldap"
export type OAuthProviderPreset =
| "google"
| "github"
| "linkedin"
| "microsoft"
| "custom"
export type IdentityProviderOAuth = {
provider: OAuthProviderPreset
client_id: string
client_secret: string
scopes: string
authorization_url?: string
token_url?: string
profile_url?: string
}
export type IdentityProviderSAML = {
metadata_url?: string
metadata_xml?: string
entity_id?: string
sso_url?: string
slo_url?: string
signing_cert?: string
}
export type IdentityProviderLDAP = {
server_uri: string
bind_dn: string
bind_password: string
base_dn: string
user_filter?: string
start_tls: boolean
sync_users?: boolean
}
export type IdentityProviderSyncStatus = "pending" | "synced" | "error"
export type IdentityProvider = {
id: string
name: string
slug: string
type: IdentityProviderType
enabled: boolean
authentik_pk?: number
sync_status: IdentityProviderSyncStatus
sync_error?: string
last_synced_at?: string
allowed_email_domains: string[]
allowed_identities: string[]
allowed_organizations: string[]
default_groups: string[]
oauth?: IdentityProviderOAuth
saml?: IdentityProviderSAML
ldap?: IdentityProviderLDAP
}
export type IdentityProvidersPolicy = {
allow_self_enrollment: boolean
default_login_source?: string
providers: IdentityProvider[]
}
export type TwoFactorPolicy = {
required_for_all: boolean
required_for_admins: boolean
@ -119,6 +184,7 @@ export type IntegrationEntry = {
export type OrgSettingsState = {
authentik: AuthentikSettings
identityProviders: IdentityProvidersPolicy
twoFactor: TwoFactorPolicy
storageQuotas: OrgStorageQuotas
usageQuotas: UsageQuotaDefaults

View File

@ -10,6 +10,60 @@ export type ApiOrgAuthentik = {
default_groups: string
}
export type ApiIdentityProviderOAuth = {
provider: "google" | "github" | "linkedin" | "microsoft" | "custom"
client_id: string
client_secret: string
scopes: string
authorization_url?: string
token_url?: string
profile_url?: string
}
export type ApiIdentityProviderSAML = {
metadata_url?: string
metadata_xml?: string
entity_id?: string
sso_url?: string
slo_url?: string
signing_cert?: string
}
export type ApiIdentityProviderLDAP = {
server_uri: string
bind_dn: string
bind_password: string
base_dn: string
user_filter?: string
start_tls: boolean
sync_users?: boolean
}
export type ApiIdentityProvider = {
id: string
name: string
slug: string
type: "oauth" | "saml" | "ldap"
enabled: boolean
authentik_pk?: number
sync_status: "pending" | "synced" | "error"
sync_error?: string
last_synced_at?: string
allowed_email_domains: string[]
allowed_identities: string[]
allowed_organizations: string[]
default_groups: string[]
oauth?: ApiIdentityProviderOAuth
saml?: ApiIdentityProviderSAML
ldap?: ApiIdentityProviderLDAP
}
export type ApiIdentityProvidersPolicy = {
allow_self_enrollment: boolean
default_login_source?: string
providers: ApiIdentityProvider[]
}
export type ApiOrgTwoFactor = {
required_for_all: boolean
required_for_admins: boolean
@ -116,6 +170,7 @@ export type ApiOrgIntegration = {
export type ApiOrgPolicy = {
authentik: ApiOrgAuthentik
identity_providers: ApiIdentityProvidersPolicy
two_factor: ApiOrgTwoFactor
storage_quotas: ApiOrgStorageQuotas
usage_quotas: ApiOrgUsageQuotas
@ -159,6 +214,10 @@ export type ApiOrgEffective = {
enabled: boolean
public_url: string
}
identity_providers?: {
authentik_public_url: string
oauth_redirect_template: string
}
}
export type ApiOrgEnvVar = {
@ -180,7 +239,7 @@ export type ApiOrgDeployLocked = Record<string, ApiOrgDeployLock>
export type ApiOrgSettingsResponse = {
policy: ApiOrgPolicy
effective: ApiOrgEffective
secrets: Record<string, { configured: boolean }>
secrets: Record<string, { configured: boolean } | Record<string, Record<string, { configured?: boolean }>>>
env_vars: ApiOrgEnvVar[]
deploy_locked: ApiOrgDeployLocked
updated_at: string

View File

@ -3,6 +3,7 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
const AUTH_STORAGE_KEY = "ulti-auth"
@ -50,15 +51,28 @@ export const useAuthStore = create<AuthState>()(
refreshToken: null,
expiresAt: null,
user: null,
login: (accessToken, refreshToken, expiresAt, user = null) =>
set({ accessToken, refreshToken, expiresAt, user }),
logout: () =>
login: (accessToken, refreshToken, expiresAt, user = null) => {
set({ accessToken, refreshToken, expiresAt, user })
useSessionGuardStore.getState().clear()
},
logout: () => {
set({
accessToken: null,
refreshToken: null,
expiresAt: null,
user: null,
}),
})
if (typeof window !== "undefined") {
try {
localStorage.removeItem(AUTH_STORAGE_KEY)
for (const legacy of LEGACY_AUTH_KEYS) {
localStorage.removeItem(legacy)
}
} catch {
/* private mode / quota */
}
}
},
isAuthenticated: () => {
const { accessToken, expiresAt, refreshToken } = get()
if (!accessToken) return false
@ -70,10 +84,18 @@ export const useAuthStore = create<AuthState>()(
{
name: AUTH_STORAGE_KEY,
storage: debouncedPersistJSONStorage,
version: 1,
migrate: (persisted) => {
const state = (persisted as { state?: AuthState }).state
if (state) {
state.accessToken = null
state.refreshToken = null
state.expiresAt = null
}
return persisted as { state: AuthState; version: number }
},
// Tokens stay in httpOnly cookies + in-memory store (via /api/auth/session).
partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
expiresAt: state.expiresAt,
user: state.user,
}),
}

View File

@ -1,6 +1,7 @@
import { useAuthStore } from "./auth-store"
import type { ApiError } from "./types"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized"
import { isSessionExpired } from "@/lib/auth/session-guard-store"
export class OfflineError extends Error {
constructor() {
@ -27,32 +28,6 @@ const DEFAULT_TIMEOUT = 10_000
const DEFAULT_RETRIES = 3
const BASE_DELAY = 1000
async function tryRefreshSession(): Promise<boolean> {
try {
const res = await fetch("/api/auth/session", { credentials: "include" })
if (!res.ok) return false
const data = (await res.json()) as {
authenticated?: boolean
accessToken?: string
refreshToken?: string | null
expiresAt?: number
user?: unknown
}
if (data.authenticated && data.accessToken && data.expiresAt) {
useAuthStore.getState().login(
data.accessToken,
data.refreshToken ?? "",
data.expiresAt,
(data.user as PlatformUser | null | undefined) ?? null
)
return true
}
} catch {
// ignore
}
return false
}
class ApiClient {
constructor(private baseUrl: string) {}
@ -66,11 +41,11 @@ class ApiClient {
return new URL(normalizedPath, normalizedBase)
}
private getHeaders(): HeadersInit {
private async getHeaders(): Promise<HeadersInit> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
const token = useAuthStore.getState().accessToken
const token = await ensureAccessToken()
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
@ -119,7 +94,7 @@ class ApiClient {
try {
const response = await fetch(url.toString(), {
method,
headers: { ...this.getHeaders(), ...opts?.headers },
headers: { ...(await this.getHeaders()), ...opts?.headers },
body: opts?.body ? JSON.stringify(opts.body) : undefined,
signal: controller.signal,
})
@ -139,10 +114,21 @@ class ApiClient {
errorBody?.details
)
if (response.status === 401 && !authRetried) {
authRetried = true
if (await tryRefreshSession()) {
continue
if (response.status === 401) {
if (isSessionExpired()) {
throw err
}
if (!authRetried) {
authRetried = true
const resolution = await handleUnauthorized()
if (resolution === "refreshed") {
continue
}
if (resolution === "offline") {
throw new OfflineError()
}
} else {
await handleUnauthorized({ forceExpired: true })
}
}
@ -187,18 +173,34 @@ class ApiClient {
}
/** GET binary body (inline attachments, exports). */
async getBlob(path: string): Promise<Blob> {
async getBlob(path: string, authRetried = false): Promise<Blob> {
if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new OfflineError()
}
const url = this.resolveUrl(path)
const headers: Record<string, string> = {}
const token = useAuthStore.getState().accessToken
const token = await ensureAccessToken()
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const response = await fetch(url.toString(), { method: "GET", headers })
if (!response.ok) {
if (response.status === 401) {
if (isSessionExpired()) {
throw new ApiRequestError(response.status, "UNKNOWN", response.statusText)
}
if (!authRetried) {
const resolution = await handleUnauthorized()
if (resolution === "refreshed") {
return this.getBlob(path, true)
}
if (resolution === "offline") {
throw new OfflineError()
}
} else {
await handleUnauthorized({ forceExpired: true })
}
}
throw new ApiRequestError(
response.status,
"UNKNOWN",

View File

@ -6,6 +6,8 @@ import { useAuthReady } from '../use-auth-ready'
import type { ApiFolder, ApiLabel, ApiIdentity } from '../types'
export function useFolders(accountId?: string) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ['folders', accountId],
queryFn: async () => {
@ -15,7 +17,7 @@ export function useFolders(accountId?: string) {
)
return Array.isArray(res) ? res : (res.folders ?? [])
},
enabled: !!accountId,
enabled: ready && authenticated && !!accountId,
staleTime: 5 * 60_000,
})
}

View File

@ -0,0 +1,38 @@
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api/client"
import type { ApiOrgSettingsResponse } from "@/lib/api/admin-org-types"
import { ORG_SETTINGS_QUERY_KEY } from "@/lib/api/hooks/use-org-settings"
export function useTestIdentityProvider() {
return useMutation({
mutationFn: (providerID: string) =>
apiClient.post<{ ok: boolean }>(
`/admin/org/identity-providers/${encodeURIComponent(providerID)}/test`
),
})
}
export function useSyncIdentityProvider() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (providerID: string) =>
apiClient.post<ApiOrgSettingsResponse>(
`/admin/org/identity-providers/${encodeURIComponent(providerID)}/sync`
),
onSuccess: (data) => {
queryClient.setQueryData(ORG_SETTINGS_QUERY_KEY, data)
},
})
}
export function useIdentityProviderRedirectURI() {
return useMutation({
mutationFn: (slug: string) =>
apiClient.get<{ slug: string; redirect_uri: string }>(
`/admin/org/identity-providers/redirect-uri/${encodeURIComponent(slug)}`
),
})
}

View File

@ -3,7 +3,7 @@
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client'
import { useAuthReady } from '../use-auth-ready'
import { normalizeListPageSize } from '@/lib/mail-list-page-size'
import { normalizeListPageSize, LIST_PAGE_SIZE } from '@/lib/mail-list-page-size'
import type {
PaginatedResponse,
ApiMessageSummary,
@ -35,34 +35,56 @@ export function unwrapMessages(
}
}
export function messagesQueryKey(
folder: string,
accountId?: string,
page?: number,
pageSize?: number
) {
return ['messages', folder, accountId, page, pageSize] as const
}
export async function fetchMessagesPage(
folder: string,
accountId: string | undefined,
page: number,
pageSize: number
): Promise<PaginatedResponse<ApiMessageSummary>> {
const safePageSize = normalizeListPageSize(pageSize)
const res = await apiClient.get<ApiMessagesPayload>('/mail/messages', {
folder,
account_id: accountId,
page: String(page),
page_size: String(safePageSize),
})
return unwrapMessages(res)
}
export function useMessages(
folder: string,
accountId?: string,
page?: number,
pageSize?: number
) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ['messages', folder, accountId, page, pageSize],
queryFn: async () => {
const safePageSize = normalizeListPageSize(pageSize ?? 50)
const res = await apiClient.get<ApiMessagesPayload>('/mail/messages', {
folder,
account_id: accountId,
page: String(page ?? 1),
page_size: String(safePageSize),
})
return unwrapMessages(res)
},
queryKey: messagesQueryKey(folder, accountId, page, pageSize),
queryFn: () =>
fetchMessagesPage(folder, accountId, page ?? 1, pageSize ?? LIST_PAGE_SIZE),
placeholderData: keepPreviousData,
staleTime: 60_000,
enabled: ready && authenticated,
})
}
export function useMessage(messageId: string | null) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ['message', messageId],
queryFn: () => apiClient.get<ApiMessageFull>(`/mail/messages/${messageId}`),
enabled: !!messageId,
enabled: ready && authenticated && !!messageId,
placeholderData: keepPreviousData,
staleTime: 5 * 60_000,
})
@ -82,6 +104,8 @@ export function unwrapThreadMessages(
}
export function useThread(threadId: string | null) {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ['thread', 'v2', threadId],
queryFn: () =>
@ -89,7 +113,7 @@ export function useThread(threadId: string | null) {
ApiMessageFull[] | { messages?: ApiMessageFull[]; thread_id?: string }
>(`/mail/threads/${threadId}`),
select: unwrapThreadMessages,
enabled: !!threadId,
enabled: ready && authenticated && !!threadId,
})
}
@ -112,6 +136,7 @@ export function useMailAccounts() {
export function useMailSearch(filter: MessageSearchFilter | null) {
const queryClient = useQueryClient()
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: ['mail-search', filter],
@ -172,6 +197,6 @@ export function useMailSearch(filter: MessageSearchFilter | null) {
throw err
}
},
enabled: isMessageSearchFilterActive(filter),
enabled: ready && authenticated && isMessageSearchFilterActive(filter),
})
}

View File

@ -2,6 +2,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../client'
import { useAuthReady } from '../use-auth-ready'
import type { ApiMailSignature, CreateMailSignaturePayload } from '../types'
const SIGNATURES_KEY = ['mail-signatures'] as const
@ -14,10 +15,13 @@ async function fetchSignatures(): Promise<ApiMailSignature[]> {
}
export function useMailSignatures() {
const { ready, authenticated } = useAuthReady()
return useQuery({
queryKey: SIGNATURES_KEY,
queryFn: fetchSignatures,
staleTime: 5 * 60_000,
enabled: ready && authenticated,
})
}

View File

@ -12,6 +12,7 @@ import {
isPreviewThumbQueryKey,
revokePreviewBlobData,
} from "@/lib/api/preview-blob-url"
import { ApiRequestError } from "@/lib/api/client"
const DB_NAME = "ultimail-query-cache"
const STORE_NAME = "query-cache"
@ -64,7 +65,12 @@ function makeQueryClient() {
gcTime: 1000 * 60 * 60 * 24,
staleTime: 1000 * 60 * 5,
networkMode: "offlineFirst",
retry: 3,
retry: (failureCount, error) => {
if (error instanceof ApiRequestError && error.status === 401) {
return false
}
return failureCount < 3
},
},
mutations: {
networkMode: "offlineFirst",

View File

@ -3,6 +3,7 @@
import { useEffect } from "react"
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import type { WsEvent, WsEventType, WsMailPayload } from "./types"
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
import { useAuthStore } from "./auth-store"
export type WsEventListener = (evt: WsEvent) => void
@ -150,12 +151,22 @@ export function useWebSocket() {
}, [queryClient])
useEffect(() => {
if (accessToken) {
wsManager.connect(accessToken)
} else {
let cancelled = false
void (async () => {
const token = accessToken ? await ensureAccessToken() : null
if (cancelled) return
if (token) {
wsManager.connect(token)
} else {
wsManager.disconnect()
}
})()
return () => {
cancelled = true
wsManager.disconnect()
}
return () => wsManager.disconnect()
}, [accessToken])
}

View File

@ -0,0 +1,21 @@
import { useAuthStore } from "@/lib/api/auth-store"
import { fetchSession, applySessionToStore } from "@/lib/auth/session-sync"
let syncPromise: Promise<string | null> | null = null
/** Bearer token comes from httpOnly session cookies — never trust localStorage cache. */
export async function ensureAccessToken(): Promise<string | null> {
if (!syncPromise) {
syncPromise = (async () => {
const data = await fetchSession()
if (data && applySessionToStore(data)) {
return useAuthStore.getState().accessToken
}
useAuthStore.getState().logout()
return null
})().finally(() => {
syncPromise = null
})
}
return syncPromise
}

View File

@ -0,0 +1,79 @@
import { useAuthStore } from "@/lib/api/auth-store"
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
import { fetchSession, tryRefreshSession } from "@/lib/auth/session-sync"
import {
isSessionExpired,
useSessionGuardStore,
} from "@/lib/auth/session-guard-store"
export type UnauthorizedResolution = "refreshed" | "offline" | "expired"
type HandleUnauthorizedOptions = {
/** API still returns 401 after a session refresh attempt. */
forceExpired?: boolean
}
let pending: Promise<UnauthorizedResolution> | null = null
function isBrowserOffline() {
return typeof navigator !== "undefined" && !navigator.onLine
}
function markSessionExpired() {
useAuthStore.getState().logout()
useSessionGuardStore.getState().setExpired()
}
async function resolveUnauthorized(
opts?: HandleUnauthorizedOptions
): Promise<UnauthorizedResolution> {
if (isSessionExpired()) {
return "expired"
}
if (opts?.forceExpired) {
markSessionExpired()
return "expired"
}
if (isBrowserOffline()) {
useSessionGuardStore.getState().setOffline()
return "offline"
}
if (await tryRefreshSession()) {
return "refreshed"
}
const session = await fetchSession()
if (session?.authenticated) {
return "refreshed"
}
if (await ensureAccessToken()) {
return "refreshed"
}
markSessionExpired()
return "expired"
}
/** Verify session after a 401; deduped across concurrent API calls. */
export function handleUnauthorized(
opts?: HandleUnauthorizedOptions
): Promise<UnauthorizedResolution> {
if (isSessionExpired()) {
return Promise.resolve("expired")
}
if (opts?.forceExpired) {
return resolveUnauthorized(opts)
}
if (!pending) {
pending = resolveUnauthorized().finally(() => {
pending = null
})
}
return pending
}

View File

@ -50,10 +50,97 @@ type OidcConfig = {
endSessionEndpoint: string
}
let discoveryCache: { issuer: string; doc: OidcDiscovery; at: number } | null =
null
let discoveryCache: {
discoveryIssuer: string
doc: OidcDiscovery
at: number
} | null = null
const DISCOVERY_TTL_MS = 5 * 60 * 1000
/** Internal origin for server-side OIDC calls (Docker: http://nginx, dev host: http://127.0.0.1). */
function getOidcInternalOrigin(): string | null {
const raw =
process.env.OIDC_DISCOVERY_ORIGIN?.trim() ||
process.env.ULTI_PROXY_ORIGIN?.trim()
if (!raw) return null
try {
return new URL(raw.endsWith("/") ? raw : `${raw}/`).origin
} catch {
return null
}
}
function getOidcPublicOrigin(): string {
try {
return new URL(getPublicOidcConfig().issuer).origin
} catch {
return "http://localhost"
}
}
function issuerWithOrigin(issuer: string, origin: string): string {
try {
const parsed = new URL(issuer)
return `${origin}${parsed.pathname}`
} catch {
return issuer
}
}
function rewriteOrigin(url: string, origin: string): string {
try {
const parsed = new URL(url)
const target = new URL(origin.endsWith("/") ? origin : `${origin}/`)
parsed.protocol = target.protocol
parsed.hostname = target.hostname
parsed.port = target.port
return parsed.toString()
} catch {
return url
}
}
/** Browser redirects must use the public Authentik URL, not Docker-internal hostnames. */
function toPublicEndpoint(
endpoint: string,
internalOrigin: string | null,
publicOrigin: string
): string {
if (!internalOrigin || internalOrigin === publicOrigin) return endpoint
return rewriteOrigin(endpoint, publicOrigin)
}
/**
* When token exchange hits Docker-internal nginx, Authentik must still emit the public
* issuer (localhost) or ultid's ID token verifier rejects the JWT.
*/
export function oidcServerFetchHeaders(): Record<string, string> {
const internalOrigin = getOidcInternalOrigin()
const publicOrigin = getOidcPublicOrigin()
if (!internalOrigin || internalOrigin === publicOrigin) return {}
try {
const publicUrl = new URL(publicOrigin)
const host = publicUrl.host
return {
Host: host,
"X-Forwarded-Host": host,
"X-Forwarded-Proto": publicUrl.protocol.replace(":", ""),
}
} catch {
return {}
}
}
/** Server-side token/logout calls must not use browser-facing localhost from inside Docker. */
function toServerEndpoint(
endpoint: string,
internalOrigin: string | null,
publicOrigin: string
): string {
if (!internalOrigin || internalOrigin === publicOrigin) return endpoint
return rewriteOrigin(endpoint, internalOrigin)
}
export function getPublicOidcConfig(): OidcConfig {
const issuer = trimSlash(
process.env.NEXT_PUBLIC_OIDC_ISSUER ??
@ -77,34 +164,56 @@ export function getPublicOidcConfig(): OidcConfig {
/** Resolve authorize/token URLs from issuer discovery (Authentik uses shared /o/ endpoints). */
export async function resolveOidcConfig(): Promise<OidcConfig> {
const base = getPublicOidcConfig()
const internalOrigin = getOidcInternalOrigin()
const discoveryIssuer = internalOrigin
? issuerWithOrigin(base.issuer, internalOrigin)
: base.issuer
const now = Date.now()
if (
discoveryCache &&
discoveryCache.issuer === base.issuer &&
discoveryCache.discoveryIssuer === discoveryIssuer &&
now - discoveryCache.at < DISCOVERY_TTL_MS
) {
return applyDiscovery(base, discoveryCache.doc)
return applyDiscovery(base, discoveryCache.doc, internalOrigin)
}
const res = await fetch(
`${base.issuer}.well-known/openid-configuration`,
`${discoveryIssuer}.well-known/openid-configuration`,
{ next: { revalidate: 300 } }
)
if (!res.ok) {
throw new Error(`OIDC discovery failed (${res.status}) for ${base.issuer}`)
throw new Error(
`OIDC discovery failed (${res.status}) for ${discoveryIssuer}`
)
}
const doc = (await res.json()) as OidcDiscovery
discoveryCache = { issuer: base.issuer, doc, at: now }
return applyDiscovery(base, doc)
discoveryCache = { discoveryIssuer, doc, at: now }
return applyDiscovery(base, doc, internalOrigin)
}
function applyDiscovery(base: OidcConfig, doc: OidcDiscovery): OidcConfig {
function applyDiscovery(
base: OidcConfig,
doc: OidcDiscovery,
internalOrigin: string | null
): OidcConfig {
const publicOrigin = getOidcPublicOrigin()
return {
...base,
authorizationEndpoint: doc.authorization_endpoint,
tokenEndpoint: doc.token_endpoint,
endSessionEndpoint:
authorizationEndpoint: toPublicEndpoint(
doc.authorization_endpoint,
internalOrigin,
publicOrigin
),
tokenEndpoint: toServerEndpoint(
doc.token_endpoint,
internalOrigin,
publicOrigin
),
endSessionEndpoint: toPublicEndpoint(
doc.end_session_endpoint ?? base.endSessionEndpoint,
internalOrigin,
publicOrigin
),
}
}

View File

@ -0,0 +1,29 @@
"use client"
import { create } from "zustand"
export type SessionGuardStatus = "idle" | "offline" | "expired"
interface SessionGuardState {
status: SessionGuardStatus
setOffline: () => void
setExpired: () => void
clear: () => void
}
export const useSessionGuardStore = create<SessionGuardState>((set, get) => ({
status: "idle",
setOffline: () => {
if (get().status === "expired") return
set({ status: "offline" })
},
setExpired: () => {
if (get().status === "expired") return
set({ status: "expired" })
},
clear: () => set({ status: "idle" }),
}))
export function isSessionExpired() {
return useSessionGuardStore.getState().status === "expired"
}

41
lib/auth/session-sync.ts Normal file
View File

@ -0,0 +1,41 @@
import { useAuthStore } from "@/lib/api/auth-store"
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
export type SessionPayload = {
authenticated?: boolean
accessToken?: string
refreshToken?: string | null
expiresAt?: number
user?: PlatformUser | null
expired?: boolean
}
export async function fetchSession(): Promise<SessionPayload | null> {
try {
const res = await fetch("/api/auth/session", { credentials: "include" })
if (!res.ok) return null
return (await res.json()) as SessionPayload
} catch {
return null
}
}
export function applySessionToStore(data: SessionPayload): boolean {
if (data.authenticated && data.accessToken && data.expiresAt) {
useAuthStore.getState().login(
data.accessToken,
data.refreshToken ?? "",
data.expiresAt,
data.user ?? null
)
useSessionGuardStore.getState().clear()
return true
}
return false
}
export async function tryRefreshSession(): Promise<boolean> {
const data = await fetchSession()
return data !== null && applySessionToStore(data)
}

View File

@ -1,4 +1,6 @@
import type { NextResponse } from "next/server"
import { decodeJwtPayload } from "@/lib/auth/jwt-claims"
import { oidcServerFetchHeaders } from "@/lib/auth/oidc-config"
/** Ultimail session lifetime — independent of short-lived OIDC access tokens. */
export const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 365
@ -18,13 +20,24 @@ export type TokenResponse = {
token_type?: string
}
function sessionCookieSecure(): boolean {
if (process.env.COOKIE_SECURE === "true") return true
if (process.env.COOKIE_SECURE === "false") return false
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ""
try {
return new URL(appUrl).protocol === "https:"
} catch {
return false
}
}
export function sessionCookieOptions() {
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
maxAge: SESSION_MAX_AGE_SEC,
secure: process.env.NODE_ENV === "production",
secure: sessionCookieSecure(),
}
}
@ -32,13 +45,22 @@ export function computeExpiresAt(expiresIn: number): number {
return Date.now() + expiresIn * 1000
}
export function isIdTokenJwtValid(accessToken: string | undefined): boolean {
if (!accessToken) return false
const claims = decodeJwtPayload(accessToken)
const exp = claims?.exp
if (typeof exp !== "number") return false
return Date.now() < exp * 1000
}
export function isAccessTokenValid(
accessToken: string | undefined,
expiresAtRaw: string | undefined
): boolean {
if (!accessToken || !expiresAtRaw) return false
if (!accessToken) return false
const expiresAt = Number(expiresAtRaw)
return Number.isFinite(expiresAt) && Date.now() < expiresAt
if (Number.isFinite(expiresAt) && Date.now() < expiresAt) return true
return isIdTokenJwtValid(accessToken)
}
type OidcTokenConfig = {
@ -59,7 +81,10 @@ export async function exchangeRefreshToken(
})
const res = await fetch(cfg.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
headers: {
"Content-Type": "application/x-www-form-urlencoded",
...oidcServerFetchHeaders(),
},
body,
})
if (!res.ok) {
@ -69,11 +94,10 @@ export async function exchangeRefreshToken(
}
export function resolveBearerToken(tokens: TokenResponse): string {
const bearer = tokens.id_token ?? tokens.access_token
if (!bearer) {
throw new Error("no_token_in_response")
if (!tokens.id_token) {
throw new Error("no_id_token_in_response")
}
return bearer
return tokens.id_token
}
export function applySessionCookies(

View File

@ -0,0 +1,28 @@
import type { DriveShare } from "@/lib/api/types"
import { NC_SHARE_TYPE } from "@/lib/drive/drive-share-types"
export type ShareButtonIcon = "lock" | "users" | "globe"
export function resolveShareButtonIcon(shares: DriveShare[]): ShareButtonIcon {
if (shares.length === 0) return "lock"
const hasPeople = shares.some(
(share) =>
share.share_type === NC_SHARE_TYPE.USER ||
share.share_type === NC_SHARE_TYPE.EMAIL ||
share.share_type === NC_SHARE_TYPE.GROUP
)
if (hasPeople) return "users"
const hasPublicLink = shares.some(
(share) =>
share.share_type === NC_SHARE_TYPE.LINK &&
(share.access_mode === "public" || !share.access_mode)
)
if (hasPublicLink) return "globe"
const hasAnyLink = shares.some((share) => share.share_type === NC_SHARE_TYPE.LINK)
if (hasAnyLink) return "users"
return "lock"
}

View File

@ -53,7 +53,7 @@ export function openPublicShareItem(file: DriveFileInfo, options: OpenPublicShar
? window.location.pathname + window.location.search
: undefined
const mode = canEdit ? "edit" : "view"
router.push(buildPublicShareEditHref(token, file.path, returnTo, mode))
router.push(buildPublicShareEditHref(token, file.path, returnTo, mode, file.name))
return
}

View File

@ -2,7 +2,8 @@ export function buildPublicShareEditHref(
token: string,
filePath: string,
returnTo?: string,
mode: "edit" | "view" = "edit"
mode: "edit" | "view" = "edit",
displayName?: string
): string {
const trimmed = filePath.replace(/^\/+|\/+$/g, "")
const base = `/drive/s/${encodeURIComponent(token)}/edit/${trimmed.split("/").map(encodeURIComponent).join("/")}`
@ -13,6 +14,9 @@ export function buildPublicShareEditHref(
if (mode === "view") {
params.set("mode", "view")
}
if (displayName?.trim()) {
params.set("name", displayName.trim())
}
const qs = params.toString()
return qs ? `${base}?${qs}` : base
}

View File

@ -0,0 +1,16 @@
"use client"
import { useEffect } from "react"
import { SUITE_TITLE_SEP } from "@/lib/suite/page-metadata"
export function useDriveDocumentTitle(titleSegment: string) {
useEffect(() => {
const trimmed = titleSegment.trim()
if (!trimmed) return
const previous = document.title
document.title = `${trimmed}${SUITE_TITLE_SEP}UltiDrive`
return () => {
document.title = previous
}
}, [titleSegment])
}

View File

@ -1,7 +1,8 @@
"use client"
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
import { useAuthStore } from "@/lib/api/auth-store"
import { useActiveAccount } from "@/lib/stores/account-store"
import { useAccountStore, useActiveAccount } from "@/lib/stores/account-store"
/** Identity shown in header avatar / account menu (OIDC user, else active mail account). */
export function useChromeIdentity(): {
@ -9,9 +10,15 @@ export function useChromeIdentity(): {
email: string
firstName: string
} | null {
const authHydrated = usePersistHydrated(useAuthStore)
const accountHydrated = usePersistHydrated(useAccountStore)
const platformUser = useAuthStore((s) => s.user)
const mailAccount = useActiveAccount()
// Keep SSR and first client render identical until persist stores rehydrate.
if (!authHydrated) return null
if (!platformUser && !accountHydrated) return null
if (platformUser) {
return {
name: platformUser.name,

View File

@ -308,6 +308,18 @@ export const MAIL_SETTINGS_MAIN_INSET_CLASS =
export const MAIL_SETTINGS_MAIN_CARD_CLASS =
"flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-mail-surface shadow-sm max-sm:rounded-none max-sm:shadow-none"
/** Liste d'onglets des pages réglages (libellés, automatisations…). */
export const MAIL_SETTINGS_TABS_LIST_CLASS = cn(
"flex h-auto w-fit max-w-full flex-wrap gap-1.5",
"[&_[data-slot=tabs-trigger]]:flex-none",
)
/** Card interne des pages réglages mail (alignée contacts). */
export const MAIL_SETTINGS_CARD_CLASS = cn(
"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)]",
)
/** Masonry 2 colonnes pour sections réglages (affichage, signatures…) en lg+. */
export const MAIL_SETTINGS_PAGE_MASONRY_CLASS = "lg:columns-2 lg:gap-5"
@ -316,5 +328,7 @@ export const MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS =
/** Bloc empilé → card en masonry (variant page affichage). */
export const MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS = cn(
"border-border px-0 py-5 lg:mb-5 lg:break-inside-avoid lg:rounded-xl lg:border lg:px-5 lg:py-5 lg:shadow-sm"
"mail-settings-masonry-section border-border px-0 py-5",
"lg:mb-5 lg:break-inside-avoid lg:rounded-xl lg:border lg:border-mail-border lg:bg-mail-surface lg:px-5 lg:py-5 lg:shadow-sm",
"dark:lg:bg-mail-surface-elevated dark:lg:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
)

View File

@ -4,14 +4,12 @@ import {
Bot,
FolderKanban,
Monitor,
PenLine,
Users,
} from "lucide-react"
export type MailSettingsSectionId =
| "display"
| "accounts"
| "signatures"
| "labels"
| "notifications"
| "automation"
@ -35,17 +33,10 @@ export const MAIL_SETTINGS_NAV: MailSettingsNavItem[] = [
{
id: "accounts",
label: "Comptes mail",
description: "IMAP, SMTP et identités d'envoi",
description: "IMAP, SMTP, identités d'envoi et signatures",
href: "/mail/settings/accounts",
icon: Users,
},
{
id: "signatures",
label: "Signatures",
description: "Bibliothèque et attribution par identité",
href: "/mail/settings/signatures",
icon: PenLine,
},
{
id: "labels",
label: "Libellés et dossiers",
@ -96,10 +87,11 @@ export function resolveMailSettingsSection(
const MAIL_SETTINGS_WIDE_LAYOUT_SECTIONS: MailSettingsSectionId[] = [
"display",
"signatures",
"automation",
]
const MAIL_SETTINGS_LEFT_ALIGNED_SECTIONS: MailSettingsSectionId[] = ["accounts"]
export function isMailSettingsWideLayoutPath(pathname: string | null): boolean {
if (!pathname?.startsWith("/mail/settings")) return false
return MAIL_SETTINGS_NAV.some(
@ -108,3 +100,12 @@ export function isMailSettingsWideLayoutPath(pathname: string | null): boolean {
isMailSettingsNavActive(pathname, item)
)
}
export function isMailSettingsLeftAlignedPath(pathname: string | null): boolean {
if (!pathname?.startsWith("/mail/settings")) return false
return MAIL_SETTINGS_NAV.some(
(item) =>
MAIL_SETTINGS_LEFT_ALIGNED_SECTIONS.includes(item.id) &&
isMailSettingsNavActive(pathname, item)
)
}

View File

@ -47,12 +47,13 @@ export const MAIL_SETTINGS_SEARCH_INDEX: MailSettingsSearchEntry[] = [
entry("display", "inbox-type", "Type de boîte de réception", "important non lus suivis starred tri inbox"),
entry("display", "reading-pane", "Volet de lecture", "split panneau droite aperçu message"),
entry("display", "conversation", "Mode Conversation", "fil discussion thread regrouper messages"),
entry("display", "infinite-scroll", "Scroll infini", "défilement pagination liste messages bureau desktop"),
entry("accounts", "add-account", "Ajouter un compte mail", "imap smtp oauth connecter serveur"),
entry("accounts", "identities", "Identités d'envoi", "alias from expéditeur adresse envoi"),
entry("accounts", "imap", "IMAP", "réception serveur entrant synchronisation"),
entry("accounts", "smtp", "SMTP", "envoi serveur sortant"),
entry("signatures", "signature-library", "Bibliothèque de signatures", "créer modifier supprimer signature html"),
entry("signatures", "signature-assign", "Attribution des signatures", "identité signature par défaut"),
entry("accounts", "signature-library", "Bibliothèque de signatures", "créer modifier supprimer signature html"),
entry("accounts", "signature-assign", "Signature par identité", "identité signature par défaut sélecteur compte mail"),
entry("labels", "labels", "Libellés", "tags couleur étiquettes organisation"),
entry("labels", "folders", "Dossiers", "imap unified unifiés arborescence"),
entry("labels", "unified-folders", "Dossiers unifiés", "cross-comptes organisation partagée"),

View File

@ -28,6 +28,7 @@ type MailSettingsState = {
inboxSort: InboxSortMode
readingPane: ReadingPaneMode
conversationMode: boolean
infiniteScroll: boolean
listPageSize: ListPageSize
desktopNewMail: boolean
desktopMentions: boolean
@ -44,6 +45,7 @@ type MailSettingsActions = {
setInboxSort: (sort: InboxSortMode) => void
setReadingPane: (mode: ReadingPaneMode) => void
setConversationMode: (enabled: boolean) => void
setInfiniteScroll: (enabled: boolean) => void
setListPageSize: (size: ListPageSize) => void
setDesktopNewMail: (enabled: boolean) => void
setDesktopMentions: (enabled: boolean) => void
@ -75,6 +77,7 @@ const defaults: MailSettingsState = {
inboxSort: "default",
readingPane: "none",
conversationMode: true,
infiniteScroll: false,
listPageSize: LIST_PAGE_SIZE,
...defaultNotificationPrefs,
}
@ -94,6 +97,7 @@ export const useMailSettingsStore = create<
setInboxSort: (inboxSort) => set({ inboxSort }),
setReadingPane: (readingPane) => set({ readingPane }),
setConversationMode: (conversationMode) => set({ conversationMode }),
setInfiniteScroll: (infiniteScroll) => set({ infiniteScroll }),
setListPageSize: (listPageSize) => set({ listPageSize }),
setDesktopNewMail: (desktopNewMail) => set({ desktopNewMail }),
setDesktopMentions: (desktopMentions) => set({ desktopMentions }),
@ -122,6 +126,7 @@ export const useMailSettingsStore = create<
inboxSort: s.inboxSort,
readingPane: s.readingPane,
conversationMode: s.conversationMode,
infiniteScroll: s.infiniteScroll,
listPageSize: s.listPageSize,
desktopNewMail: s.desktopNewMail,
desktopMentions: s.desktopMentions,

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@ -32,6 +32,48 @@ function headerIconPath() {
return p
}
/** Wordmark empilé fond transparent : picto couleurs d'origine, texte éclairci (sans invert/hue). */
async function buildStackedDark(originalPath) {
const { data, info } = await sharp(originalPath)
.resize({
width: 800,
height: 800,
fit: "contain",
background: { r: 255, g: 255, b: 255, alpha: 1 },
})
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
if (r >= 235 && g >= 235 && b >= 235) {
data[i + 3] = 0
continue
}
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const chroma = max - min
// Texte / traits neutres sombres → gris clair lisible sur fond dark.
if (chroma < 40 && max < 200) {
data[i] = 245
data[i + 1] = 245
data[i + 2] = 247
}
data[i + 3] = 255
}
const out = join(brandDir, "ultimail-wordmark-stacked-dark.png")
await sharp(Buffer.from(data), {
raw: { width: info.width, height: info.height, channels: 4 },
})
.png({ compressionLevel: 9 })
.toFile(out)
console.log("Wrote ultimail-wordmark-stacked-dark.png")
}
async function writePngJpg(input, outBase, w, h, bg = "#ffffff") {
const resize = {
width: w,
@ -109,6 +151,7 @@ async function main() {
await writePngJpg(headerIcon, "ultimail-mark", 256, 256)
await writePngJpg(original, "ultimail-wordmark-stacked", 800, 800)
await buildStackedDark(original)
await writePngJpg(horizontalBuf, "ultimail-wordmark-horizontal", 1600, 460)
}

File diff suppressed because one or more lines are too long