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_API_URL=/api/v1
NEXT_PUBLIC_WS_URL=ws://localhost/ws NEXT_PUBLIC_WS_URL=ws://localhost/ws
# Cible du proxy Next (optionnel, défaut 127.0.0.1:80) # 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) # OIDC Authentik (blueprints deploy/authentik dans ulti-backend)
NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/ 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 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 # URL publique navigateur (suite nginx) — pas :3000 si tu passes par http://localhost/mail
NEXT_PUBLIC_APP_URL=http://localhost 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 # Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
OIDC_CLIENT_SECRET=changeme OIDC_CLIENT_SECRET=changeme

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
computeExpiresAt, computeExpiresAt,
exchangeRefreshToken, exchangeRefreshToken,
isAccessTokenValid, isAccessTokenValid,
isIdTokenJwtValid,
resolveBearerToken, resolveBearerToken,
} from "@/lib/auth/session" } from "@/lib/auth/session"
@ -40,7 +41,25 @@ export async function GET() {
try { try {
const cfg = await resolveServerOidcConfig() const cfg = await resolveServerOidcConfig()
const tokens = await exchangeRefreshToken(refreshToken, cfg) 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 expiresAt = computeExpiresAt(tokens.expires_in ?? 3600)
const user = platformUserFromToken(bearer) const user = platformUserFromToken(bearer)

View File

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

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next" 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" import { suitePageMetadata } from "@/lib/suite/page-metadata"
type LayoutProps = { type LayoutProps = {
@ -9,7 +9,9 @@ type LayoutProps = {
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> { export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
const { fileId } = await params 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({ return suitePageMetadata({
app: "drive", app: "drive",
titleSegment: name, titleSegment: name,

View File

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

View File

@ -719,6 +719,8 @@ html[data-mail-background]:not([data-mail-background='none'])
.ultimail-app { .ultimail-app {
position: relative; position: relative;
isolation: isolate; isolation: isolate;
--border: var(--mail-border);
--input: var(--mail-border);
} }
/* Lignes de liste */ /* Lignes de liste */
@ -840,8 +842,8 @@ html.dark .ultimail-app {
--muted-foreground: var(--mail-text-muted); --muted-foreground: var(--mail-text-muted);
--accent: var(--mail-nav-hover); --accent: var(--mail-nav-hover);
--accent-foreground: var(--mail-text); --accent-foreground: var(--mail-text);
--border: var(--mail-border-subtle); --border: var(--mail-border);
--input: var(--mail-border-subtle); --input: var(--mail-border);
--ring: 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); background-color: var(--mail-surface-muted);
} }
/* ── Dark : portails Radix & toasts (rendus hors .ultimail-app) ── */ /* Portails Radix (rendus hors .ultimail-app) — bordures/champs alignés sur le gris mail */
html.dark [data-slot='dropdown-menu-content'], :where(
html.dark [data-slot='dropdown-menu-sub-content'], [data-slot='dialog-content'],
html.dark [data-slot='context-menu-content'], [data-slot='alert-dialog-content'],
html.dark [data-slot='context-menu-sub-content'], [data-slot='popover-content'],
html.dark [data-slot='popover-content'], [data-slot='hover-card-content'],
html.dark [data-slot='select-content'], [data-slot='dropdown-menu-content'],
html.dark [data-slot='menubar-content'] { [data-slot='dropdown-menu-sub-content'],
background-color: var(--popover) !important; [data-slot='context-menu-content'],
color: var(--popover-foreground) !important; [data-slot='context-menu-sub-content'],
border-color: var(--border) !important; [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`. */ /* 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-item'][data-highlighted],
html.dark [data-slot='context-menu-sub-trigger']:focus, html.dark [data-slot='context-menu-sub-trigger']:focus,
html.dark [data-slot='context-menu-sub-trigger'][data-state='open'] { html.dark [data-slot='context-menu-sub-trigger'][data-state='open'] {
background-color: var(--accent) !important; background-color: var(--mail-nav-hover) !important;
color: var(--accent-foreground) !important; color: var(--mail-text) !important;
} }
html.dark [data-slot='dropdown-menu-separator'], html.dark [data-slot='dropdown-menu-separator'],
html.dark [data-slot='context-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) */ /* 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; color: var(--foreground) !important;
} }
html.dark .ultimail-app :where([data-slot='checkbox']) { html.dark
background-color: transparent; :where(
border-color: #9aa0a6; .ultimail-app,
} [data-slot='dialog-content'],
[data-slot='popover-content'],
html.dark .ultimail-app :where([data-slot='checkbox'][data-state='checked']) { [data-slot='sheet-content']
)
:where([data-slot='checkbox'][data-state='checked'], [data-slot='checkbox'][data-state='indeterminate']) {
background-color: #1a73e8; background-color: #1a73e8;
border-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; 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 */ /* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */
html.dark .ultimail-app :where(.bg-background) { html.dark .ultimail-app :where(.bg-background) {
background-color: var(--mail-surface-muted) !important; 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']) { html.dark .ultimail-app :where([data-slot='input'], [data-slot='select-trigger'], [data-slot='textarea']) {
background-color: var(--mail-surface-muted) !important; 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; 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 { FirstLaunchSplash } from '@/components/first-launch-splash'
import { QueryProvider } from '@/lib/api/query-provider' import { QueryProvider } from '@/lib/api/query-provider'
import { AuthProvider } from '@/components/auth/auth-provider' import { AuthProvider } from '@/components/auth/auth-provider'
import { SessionGuard } from '@/components/auth/session-guard'
import { MailToaster } from '@/components/gmail/mail-toaster' import { MailToaster } from '@/components/gmail/mail-toaster'
import { suiteRootMetadata } from '@/lib/suite/page-metadata' import { suiteRootMetadata } from '@/lib/suite/page-metadata'
@ -34,6 +35,7 @@ export default function RootLayout({
<ThemeInitScript /> <ThemeInitScript />
<QueryProvider> <QueryProvider>
<AuthProvider> <AuthProvider>
<SessionGuard />
<FirstLaunchSplash>{children}</FirstLaunchSplash> <FirstLaunchSplash>{children}</FirstLaunchSplash>
</AuthProvider> </AuthProvider>
</QueryProvider> </QueryProvider>

View File

@ -16,7 +16,8 @@ import { cn } from "@/lib/utils"
const LOGIN_CARD_CLASS = cn( const LOGIN_CARD_CLASS = cn(
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none", "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() { function LoginContent() {

View File

@ -1,3 +1,4 @@
import { redirect } from "next/navigation"
import { MailSettingsSectionFromSegments } from "@/components/gmail/settings/mail-settings-section-view" import { MailSettingsSectionFromSegments } from "@/components/gmail/settings/mail-settings-section-view"
export default async function MailSettingsSectionPage({ export default async function MailSettingsSectionPage({
@ -6,5 +7,8 @@ export default async function MailSettingsSectionPage({
params: Promise<{ section?: string[] }> params: Promise<{ section?: string[] }>
}) { }) {
const { section } = await params const { section } = await params
if (section?.[0] === "signatures") {
redirect("/mail/settings/accounts")
}
return <MailSettingsSectionFromSegments segments={section} /> 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" "use client"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" 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 { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -22,6 +23,7 @@ export function AuthenticationSection() {
const clientID = clientLocked ? (effective?.client_id ?? authentik.client_id) : authentik.client_id const clientID = clientLocked ? (effective?.client_id ?? authentik.client_id) : authentik.client_id
return ( return (
<div className="space-y-8">
<OrgSettingsSection <OrgSettingsSection
title="Authentification Authentik" title="Authentification Authentik"
description="SSO, provisionnement des comptes Ultimail et groupes par défaut." description="SSO, provisionnement des comptes Ultimail et groupes par défaut."
@ -107,5 +109,8 @@ export function AuthenticationSection() {
</CardContent> </CardContent>
</Card> </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." description="Politiques d'authentification à deux facteurs pour l'organisation."
policySection="two_factor" policySection="two_factor"
> >
<Card> <Card className="gap-3 py-4">
<CardHeader className="pb-3"> <CardHeader className="pb-0">
<CardTitle className="text-sm font-medium">Exigences</CardTitle> <CardTitle className="text-sm font-medium">Exigences</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@ -60,8 +60,8 @@ export function SecuritySection() {
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card className="gap-3 py-4">
<CardHeader className="pb-3"> <CardHeader className="pb-0">
<CardTitle className="text-sm font-medium">Méthodes autorisées</CardTitle> <CardTitle className="text-sm font-medium">Méthodes autorisées</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <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 { usePathname, useRouter } from "next/navigation"
import { useAuthStore, AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS } from "@/lib/api/auth-store" import { useAuthStore, AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS } from "@/lib/api/auth-store"
import { isOidcConfigured } from "@/lib/auth/oidc-config" 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 PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
const REFRESH_LEAD_MS = 5 * 60 * 1000 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 }) { export function AuthProvider({ children }: { children: ReactNode }) {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const login = useAuthStore((s) => s.login)
const logout = useAuthStore((s) => s.logout) const logout = useAuthStore((s) => s.logout)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const [ready, setReady] = useState( const [ready, setReady] = useState(() => !isOidcConfigured())
() => !isOidcConfigured() || canTrustPersistedAuth()
)
const applySession = useCallback( const applySession = useCallback(
(data: SessionPayload) => { (data: SessionPayload) => applySessionToStore(data),
if (data.authenticated && data.accessToken && data.expiresAt) { []
login(
data.accessToken,
data.refreshToken ?? "",
data.expiresAt,
data.user ?? null
)
return true
}
return false
},
[login]
) )
const syncSession = useCallback(async () => { const syncSession = useCallback(async () => {
@ -81,10 +53,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return return
} }
if (canTrustPersistedAuth()) {
setReady(true)
}
const data = await fetchSession() const data = await fetchSession()
if (cancelled) return if (cancelled) return
@ -93,19 +61,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return return
} }
if (data?.authenticated === false || !canTrustPersistedAuth()) { const hadMemoryAuth = useAuthStore.getState().isAuthenticated()
logout() logout()
if (hadMemoryAuth && !isPublicPath(pathname) && !isSessionExpired()) {
useSessionGuardStore.getState().setExpired()
} }
setReady(true) setReady(true)
} }
if (!useAuthStore.persist.hasHydrated()) { if (!useAuthStore.persist.hasHydrated()) {
const unsubHydrate = useAuthStore.persist.onFinishHydration(() => { const unsubHydrate = useAuthStore.persist.onFinishHydration(() => {
if (useAuthStore.getState().isAuthenticated()) {
setReady(true)
}
})
void bootstrap() void bootstrap()
})
return () => { return () => {
cancelled = true cancelled = true
unsubHydrate() unsubHydrate()
@ -116,7 +83,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [applySession, logout]) }, [applySession, logout, pathname])
useEffect(() => { useEffect(() => {
if (!ready || !isOidcConfigured()) return if (!ready || !isOidcConfigured()) return
@ -140,6 +107,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
let cancelled = false let cancelled = false
void syncSession().then((ok) => { void syncSession().then((ok) => {
if (cancelled || ok) return if (cancelled || ok) return
if (useSessionGuardStore.getState().status === "expired") return
const returnTo = encodeURIComponent(pathname) const returnTo = encodeURIComponent(pathname)
router.replace(`/login?returnTo=${returnTo}`) 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" "use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" 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 Link from "next/link"
import { apiClient } from "@/lib/api/client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react" 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 { 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 } function fileNameFromPath(filePath: string): string {
return filePath.split("/").filter(Boolean).pop() ?? filePath
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 renameTargetPath(filePath: string, newName: string): string {
const parent = filePath.replace(/\/[^/]+$/, "") || "/"
function loadDocsApi(documentServerUrl: string): Promise<void> { const base = parent === "/" ? "" : parent
if (window.DocsAPI) return Promise.resolve() return `${base}/${newName}`.replace(/\/+/g, "/") || `/${newName}`
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" />
} }
export function OfficeEditor({ export function OfficeEditor({
@ -127,18 +33,34 @@ export function OfficeEditor({
filePath: string filePath: string
returnTo?: string | null returnTo?: string | null
}) { }) {
const router = useRouter()
const instanceSeq = useRef(0) const instanceSeq = useRef(0)
const setSharePath = useDriveUIStore((s) => s.setSharePath)
const [config, setConfig] = useState<Record<string, unknown> | null>(null) const [config, setConfig] = useState<Record<string, unknown> | null>(null)
const [serverUrl, setServerUrl] = useState("") const [serverUrl, setServerUrl] = useState("")
const [editorId, setEditorId] = useState<string | null>(null) const [editorId, setEditorId] = useState<string | null>(null)
const [error, setError] = 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( const backHref = useMemo(
() => () =>
resolveDriveEditReturnTo(returnTo, filePath, (folderPath) => resolveDriveEditReturnTo(returnTo, displayPath, (folderPath) =>
driveFolderHref("files", folderPath) driveFolderHref("files", folderPath)
), ),
[returnTo, filePath] [returnTo, displayPath]
) )
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
const { rename } = useDriveMutations()
const handleEditorError = useCallback((message: string) => { const handleEditorError = useCallback((message: string) => {
setError(message) setError(message)
}, []) }, [])
@ -155,7 +77,7 @@ export function OfficeEditor({
const res = await apiClient.post<{ const res = await apiClient.post<{
config: Record<string, unknown> config: Record<string, unknown>
serverUrl: string serverUrl: string
}>("/office/session", { path: filePath, mode: "edit" }) }>("/office/session", { path: displayPath, mode: "edit" })
if (cancelled) return if (cancelled) return
instanceSeq.current += 1 instanceSeq.current += 1
setConfig(res.config) setConfig(res.config)
@ -169,7 +91,26 @@ export function OfficeEditor({
return () => { return () => {
cancelled = true 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) { if (error) {
return ( return (
@ -193,15 +134,16 @@ export function OfficeEditor({
return ( return (
<div className="flex h-dvh flex-col"> <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"> <OfficeEditorChrome
<Button variant="ghost" size="sm" asChild> backHref={backHref}
<Link href={backHref}> backLabel="Drive"
<ArrowLeft className="mr-1 h-4 w-4" /> title={title}
Drive onRename={handleRename}
</Link> shares={sharesData?.shares ?? []}
</Button> onShareClick={openShareDialog}
<span className="truncate text-sm font-medium">{filePath.split("/").pop()}</span> showShare
</div> showAccount
/>
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<OnlyOfficeMount <OnlyOfficeMount
editorId={editorId} 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 Link from "next/link"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ArrowLeft } from "lucide-react" 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 { resolvePublicShareEditReturnTo } from "@/lib/drive/public-share-url"
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
type DocEditorInstance = { destroyEditor: () => void } function fileNameFromPath(filePath: string, fallback?: string): string {
const base = filePath.split("/").filter(Boolean).pop()
declare global { return base || fallback || filePath
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" />
} }
export function PublicOfficeEditor({ export function PublicOfficeEditor({
@ -115,12 +21,14 @@ export function PublicOfficeEditor({
password, password,
returnTo, returnTo,
mode = "edit", mode = "edit",
fileDisplayName,
}: { }: {
token: string token: string
filePath: string filePath: string
password?: string password?: string
returnTo?: string | null returnTo?: string | null
mode?: "edit" | "view" mode?: "edit" | "view"
fileDisplayName?: string
}) { }) {
const instanceSeq = useRef(0) const instanceSeq = useRef(0)
const guestId = useRef( const guestId = useRef(
@ -132,6 +40,12 @@ export function PublicOfficeEditor({
const [serverUrl, setServerUrl] = useState("") const [serverUrl, setServerUrl] = useState("")
const [editorId, setEditorId] = useState<string | null>(null) const [editorId, setEditorId] = useState<string | null>(null)
const [error, setError] = 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( const backHref = useMemo(
() => resolvePublicShareEditReturnTo(token, returnTo, filePath), () => resolvePublicShareEditReturnTo(token, returnTo, filePath),
[token, returnTo, filePath] [token, returnTo, filePath]
@ -143,6 +57,7 @@ export function PublicOfficeEditor({
setServerUrl("") setServerUrl("")
setEditorId(null) setEditorId(null)
setError(null) setError(null)
setResolvedMode(mode)
void (async () => { void (async () => {
try { try {
@ -163,12 +78,21 @@ export function PublicOfficeEditor({
const data = (await res.json()) as { const data = (await res.json()) as {
config: Record<string, unknown> config: Record<string, unknown>
serverUrl: string serverUrl: string
mode?: "edit" | "view"
} }
if (cancelled) return if (cancelled) return
instanceSeq.current += 1 instanceSeq.current += 1
setConfig(data.config) setConfig(data.config)
setServerUrl(data.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "") setServerUrl(data.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
setEditorId(`ultidrive-public-editor-${instanceSeq.current}`) 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 { } catch {
if (!cancelled) setError("Impossible de charger léditeur.") if (!cancelled) setError("Impossible de charger léditeur.")
} }
@ -203,21 +127,25 @@ export function PublicOfficeEditor({
return ( return (
<div className="flex h-dvh flex-col"> <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"> <OfficeEditorChrome
<Button variant="ghost" size="sm" asChild> backHref={backHref}
<Link href={backHref}> backLabel="Partage"
<ArrowLeft className="mr-1 h-4 w-4" /> title={title}
Partage trailing={
</Link> resolvedMode === "view" ? (
</Button> <span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
<span className="truncate text-sm font-medium">{filePath.split("/").pop()}</span> Lecture seule
</div> </span>
) : null
}
/>
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<OnlyOfficeMount <OnlyOfficeMount
editorId={editorId} editorId={editorId}
documentServerUrl={serverUrl.replace(/\/$/, "")} documentServerUrl={serverUrl.replace(/\/$/, "")}
config={config} config={config}
onError={handleEditorError} onError={handleEditorError}
scriptId="onlyoffice-docs-api-public"
/> />
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
import dynamic from "next/dynamic" import dynamic from "next/dynamic"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect, useState, type ReactNode } from "react" import { useEffect, useState, type ReactNode } from "react"
import { ChevronRight, Download, FolderOpen, Loader2, Lock } from "lucide-react" import { ChevronRight, Download, FolderOpen, Loader2, Lock } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@ -20,6 +21,11 @@ import type { DriveFileInfo } from "@/lib/api/types"
import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview" import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer" import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes" 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 { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell" import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -206,10 +212,30 @@ export function PublicShareViewPanel({
data: PublicShareView data: PublicShareView
password?: string password?: string
}) { }) {
const router = useRouter()
const file = data.item_type === "file" ? data.file : null const file = data.item_type === "file" ? data.file : null
const files = data.item_type === "folder" ? (data.files ?? []) : [] const files = data.item_type === "folder" ? (data.files ?? []) : []
const rootShareName = usePublicShareRootName(token, path, data.name) const rootShareName = usePublicShareRootName(token, path, data.name)
const sharedByLabel = publicShareOwnerLabel(data) 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 = () => { const downloadCurrent = () => {
if (!file) return if (!file) return
@ -223,6 +249,14 @@ export function PublicShareViewPanel({
anchor.remove() 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 ( return (
<div <div
className={cn( className={cn(

View File

@ -246,6 +246,18 @@ export function EmailListBody({
{listEmails.map((email) => ( {listEmails.map((email) => (
<EmailListRow key={email.id} email={email} {...rowPropsBase} /> <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> </div>
)} )}
</> </>

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import {
import { useSearchParams, useRouter } from "next/navigation" import { useSearchParams, useRouter } from "next/navigation"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills" 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 { import {
useUpdateFlags, useUpdateFlags,
useUpdateLabels, useUpdateLabels,
@ -65,6 +65,7 @@ import {
buildInboxTabBarItems, buildInboxTabBarItems,
} from "@/components/gmail/email-list/email-list-helpers" } from "@/components/gmail/email-list/email-list-helpers"
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh" 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 { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { resolveListRowAttachments } from "@/lib/attachment-display" import { resolveListRowAttachments } from "@/lib/attachment-display"
import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments" import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
@ -183,6 +184,9 @@ export function useEmailListData({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const listPageSize = useMailSettingsStore((s) => s.listPageSize) const listPageSize = useMailSettingsStore((s) => s.listPageSize)
const setListPageSize = useMailSettingsStore((s) => s.setListPageSize) const setListPageSize = useMailSettingsStore((s) => s.setListPageSize)
const infiniteScroll = useMailSettingsStore((s) => s.infiniteScroll)
const isXs = useIsXs()
const touchNav = useTouchNav()
const effectiveApiFolder = useMemo(() => { const effectiveApiFolder = useMemo(() => {
if (isSearchMode) return "__search__" if (isSearchMode) return "__search__"
@ -196,12 +200,21 @@ export function useEmailListData({
return searchParamsToMessageSearchFilter(searchParams, accountId) return searchParamsToMessageSearchFilter(searchParams, accountId)
}, [isSearchMode, 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__" effectiveApiFolder === "__search__" || effectiveApiFolder === "__local__"
? "inbox" ? "inbox"
: effectiveApiFolder, : effectiveApiFolder
const messagesQueryPage = usesApiInfiniteScroll ? 1 : listPage
const messagesQuery = useMessages(
messagesApiFolder,
accountId, accountId,
listPage, messagesQueryPage,
listPageSize listPageSize
) )
@ -451,8 +464,12 @@ export function useEmailListData({
const [labelPickerQuery, setLabelPickerQuery] = useState("") const [labelPickerQuery, setLabelPickerQuery] = useState("")
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets) const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE) const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE)
const isXs = useIsXs() const [accumulatedApiEmails, setAccumulatedApiEmails] = useState<Email[]>([])
const touchNav = useTouchNav() 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 seenEmailIdsRaw = useMailStore((s) => s.seenEmailIds)
const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw]) const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw])
@ -598,6 +615,158 @@ export function useEmailListData({
[listPageSize, setListPageSize, onMailRouteNavigate] [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(() => { const pagedEmails = useMemo(() => {
if (effectiveApiFolder !== "__local__" && !isSearchMode) { if (effectiveApiFolder !== "__local__" && !isSearchMode) {
return displayListEmails return displayListEmails
@ -607,11 +776,74 @@ export function useEmailListData({
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode, listPageSize]) }, [displayListEmails, listPage, effectiveApiFolder, isSearchMode, listPageSize])
const listEmails = useMemo(() => { const listEmails = useMemo(() => {
if (isXs && !isViewMode) { if (!scrollInfiniteList) return pagedEmails
return displayListEmails.slice(0, mobileVisibleCount) if (usesApiInfiniteScroll) {
if (isXs) {
return infiniteScrollSourceEmails.slice(0, mobileVisibleCount)
} }
return pagedEmails return infiniteScrollSourceEmails
}, [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]) const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
@ -674,42 +906,34 @@ export function useEmailListData({
inboxCategoryTabIconsCatalog, inboxCategoryTabIconsCatalog,
]) ])
const prevInfiniteScrollRef = useRef(infiniteScroll)
useEffect(() => { 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) { if (listPage > totalPages) {
onMailRouteNavigate({ page: totalPages }) onMailRouteNavigate({ page: totalPages })
} }
}, [isXs, listPage, totalPages, onMailRouteNavigate]) }, [isXs, infiniteScroll, listPage, totalPages, onMailRouteNavigate])
useEffect(() => { useEffect(() => {
if (isXs && !isViewMode) return if (scrollInfiniteList) return
listViewportRef.current?.scrollTo(0, 0) listViewportRef.current?.scrollTo(0, 0)
}, [listPage, selectedFolder, inboxTab, isXs, isViewMode, listViewportRef]) }, [listPage, selectedFolder, inboxTab, scrollInfiniteList, listViewportRef])
useEffect(() => { useEffect(() => {
if (!isXs) return if (!scrollInfiniteList || usesApiInfiniteScroll) return
setMobileVisibleCount(LIST_PAGE_SIZE) setMobileVisibleCount(LIST_PAGE_SIZE)
listViewportRef.current?.scrollTo(0, 0) listViewportRef.current?.scrollTo(0, 0)
}, [selectedFolder, inboxTab, isXs, listViewportRef]) }, [selectedFolder, inboxTab, scrollInfiniteList, usesApiInfiniteScroll, 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])
const moveTargets = useMoveTargets({ const moveTargets = useMoveTargets({
folderTree: sidebarNav.folderTree, folderTree: sidebarNav.folderTree,
@ -855,6 +1079,11 @@ export function useEmailListData({
notSpamEmailIds, notSpamEmailIds,
recentMoveTargets, recentMoveTargets,
mobileVisibleCount, mobileVisibleCount,
infiniteScroll,
scrollInfiniteList,
hasMoreInfinite,
loadMoreSentinelRef,
isFetchingNextInfinitePage,
isXs, isXs,
touchNav, touchNav,
seenEmailIds, seenEmailIds,
@ -880,6 +1109,10 @@ export function useEmailListData({
listEmails, listEmails,
listMailIndex, listMailIndex,
listRowExtras, listRowExtras,
scrollInfiniteList,
hasMoreInfinite,
loadMoreSentinelRef,
isFetchingNextInfinitePage,
moveTargets, moveTargets,
folderUnreadCounts, folderUnreadCounts,
unseenInTabById, unseenInTabById,

View File

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

View File

@ -197,6 +197,8 @@ export function MailSettingsFields({
const setReadingPane = useMailSettingsStore((s) => s.setReadingPane) const setReadingPane = useMailSettingsStore((s) => s.setReadingPane)
const conversationMode = useMailSettingsStore((s) => s.conversationMode) const conversationMode = useMailSettingsStore((s) => s.conversationMode)
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode) const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
const infiniteScroll = useMailSettingsStore((s) => s.infiniteScroll)
const setInfiniteScroll = useMailSettingsStore((s) => s.setInfiniteScroll)
const activeBackgroundId = normalizeMailBackgroundId(backgroundId) const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
const isPage = variant === "page" 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} {preset.label}
</span> </span>
</button> </button>
@ -308,10 +317,20 @@ export function MailSettingsFields({
))} ))}
</SettingsSection> </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 <section
className={cn( className={cn(
"border-b border-border px-4 py-4", "px-4 py-4",
isPage && cn("border-b border-border", MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS) !isPage && "border-b border-border",
isPage && MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS
)} )}
> >
<SectionHeader title="Fils de discussion" /> <SectionHeader title="Fils de discussion" />

View File

@ -39,7 +39,9 @@ export function QuickSettingsOption({
<span <span
className={cn( className={cn(
"min-w-0 flex-1 text-sm", "min-w-0 flex-1 text-sm",
checked ? "text-[#1a73e8]" : "text-foreground" checked
? "font-bold text-[#1a73e8] dark:text-white"
: "text-foreground"
)} )}
> >
{label} {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" 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"> <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 Configuration rapide
</h1> </h1>
<Button <Button
@ -49,7 +49,7 @@ export function QuickSettingsPanel() {
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<Button <Button
variant="outline" 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 asChild
> >
<Link href="/mail/settings" onClick={() => setOpen(false)}> <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} {preset.label}
</span> </span>
</button> </button>

View File

@ -71,16 +71,16 @@ export function AutomationDomainFilterTab({
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( 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 active
? "border-primary bg-primary/10 text-primary" ? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-muted-foreground hover:bg-muted" : "border-border bg-background text-muted-foreground hover:bg-muted"
)} )}
> >
{domain === "all" ? ( {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> <span>{label}</span>
</button> </button>

View File

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

View File

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

View File

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

View File

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

View File

@ -324,7 +324,7 @@ function NavItemSettingsShell({
<Collapsible <Collapsible
open={open} open={open}
onOpenChange={setOpen} 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 <div
className="flex items-center gap-1 px-3 py-2" className="flex items-center gap-1 px-3 py-2"

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react" import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -12,6 +12,13 @@ import {
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { import {
Card, Card,
CardContent, CardContent,
@ -21,6 +28,7 @@ import {
} from "@/components/ui/card" } from "@/components/ui/card"
import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form" import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form"
import { EditMailAccountForm } from "@/components/gmail/settings/edit-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 { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { import {
useCreateMailAccount, useCreateMailAccount,
@ -34,10 +42,13 @@ import {
useUpdateIdentity, useUpdateIdentity,
useDeleteIdentity, useDeleteIdentity,
} from "@/lib/api/hooks/use-identity-mutations" } 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 { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner" import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useAuthReady } from "@/lib/api/use-auth-ready" 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() { export function AccountsSettingsSection() {
const router = useRouter() const router = useRouter()
@ -45,8 +56,17 @@ export function AccountsSettingsSection() {
const oauthStatus = searchParams.get("oauth") const oauthStatus = searchParams.get("oauth")
const { ready, authenticated } = useAuthReady() const { ready, authenticated } = useAuthReady()
const { data: accounts = [], isFetching, isError, refetch, isPending } = useMailAccounts() const { data: accounts = [], isFetching, isError, refetch, isPending } = useMailAccounts()
const {
data: signatures = [],
isFetching: signaturesFetching,
isError: signaturesError,
refetch: refetchSignatures,
isPending: signaturesPending,
} = useMailSignatures()
const createAccount = useCreateMailAccount() const createAccount = useCreateMailAccount()
const showInitialLoad = ready && authenticated && isPending && accounts.length === 0 const showInitialLoad = ready && authenticated && isPending && accounts.length === 0
const showSignaturesInitialLoad =
ready && authenticated && signaturesPending && signatures.length === 0
useEffect(() => { useEffect(() => {
if (oauthStatus === "success") { if (oauthStatus === "success") {
@ -55,11 +75,19 @@ export function AccountsSettingsSection() {
} }
}, [oauthStatus, refetch, router]) }, [oauthStatus, refetch, router])
const syncFetching = isFetching || signaturesFetching
const syncError = isError || signaturesError
function handleRetry() {
void refetch()
void refetchSignatures()
}
return ( return (
<> <>
<SettingsSectionHeader <SettingsSectionHeader
title="Comptes mail" 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" ? ( {oauthStatus === "success" ? (
<p className="text-sm text-green-600 dark:text-green-500"> <p className="text-sm text-green-600 dark:text-green-500">
@ -72,7 +100,11 @@ export function AccountsSettingsSection() {
{searchParams.get("code") ? ` (${searchParams.get("code")})` : ""}. {searchParams.get("code") ? ` (${searchParams.get("code")})` : ""}.
</p> </p>
) : null} ) : null}
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} /> <SettingsSyncBanner
isFetching={syncFetching}
isError={syncError}
onRetry={handleRetry}
/>
<div className="space-y-6"> <div className="space-y-6">
<AddMailAccountForm <AddMailAccountForm
@ -80,19 +112,32 @@ export function AccountsSettingsSection() {
onSubmit={(payload) => createAccount.mutate(payload)} onSubmit={(payload) => createAccount.mutate(payload)}
/> />
{showInitialLoad ? null : accounts.length === 0 ? ( {!ready || showInitialLoad ? null : accounts.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Aucun compte mail configuré. Ajoutez votre adresse e-mail ci-dessus pour commencer. Aucun compte mail configuré. Ajoutez votre adresse e-mail ci-dessus pour commencer.
</p> </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> </div>
</> </>
) )
} }
function AccountCard({ account }: { account: ApiMailAccount }) { function AccountCard({
account,
signatures,
}: {
account: ApiMailAccount
signatures: ApiMailSignature[]
}) {
const deleteAccount = useDeleteMailAccount() const deleteAccount = useDeleteMailAccount()
const resanitizeBodies = useResanitizeBodies(account.id) const resanitizeBodies = useResanitizeBodies(account.id)
const syncAccount = useSyncMailAccount(account.id) const syncAccount = useSyncMailAccount(account.id)
@ -209,6 +254,7 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
accountId={account.id} accountId={account.id}
accountEmail={account.email} accountEmail={account.email}
identities={identities} identities={identities}
signatures={signatures}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -219,6 +265,7 @@ function IdentitiesBlock({
accountId, accountId,
accountEmail, accountEmail,
identities, identities,
signatures,
}: { }: {
accountId: string accountId: string
accountEmail: string accountEmail: string
@ -231,6 +278,7 @@ function IdentitiesBlock({
default_signature_id?: string default_signature_id?: string
reply_to_addrs?: string[] reply_to_addrs?: string[]
}> }>
signatures: ApiMailSignature[]
}) { }) {
const createIdentity = useCreateIdentity(accountId) const createIdentity = useCreateIdentity(accountId)
const updateIdentity = useUpdateIdentity(accountId) const updateIdentity = useUpdateIdentity(accountId)
@ -238,6 +286,14 @@ function IdentitiesBlock({
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
const [newIdentity, setNewIdentity] = useState({ email: accountEmail, name: "" }) 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(() => { useEffect(() => {
if (!showAddForm) { if (!showAddForm) {
setNewIdentity({ email: accountEmail, name: "" }) setNewIdentity({ email: accountEmail, name: "" })
@ -250,6 +306,7 @@ function IdentitiesBlock({
email: string email: string
name: string name: string
is_default: boolean is_default: boolean
default_signature_id: string
}> = {} }> = {}
) { ) {
return { return {
@ -258,7 +315,7 @@ function IdentitiesBlock({
name: patch.name ?? identity.name, name: patch.name ?? identity.name,
is_default: patch.is_default ?? identity.is_default, is_default: patch.is_default ?? identity.is_default,
signature_html: identity.signature_html ?? "", 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, reply_to_addrs: identity.reply_to_addrs,
} }
} }
@ -289,10 +346,16 @@ function IdentitiesBlock({
<p className="text-xs text-muted-foreground">Aucune identité configurée.</p> <p className="text-xs text-muted-foreground">Aucune identité configurée.</p>
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{identities.map((identity) => ( {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"> <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="flex items-start justify-between gap-2">
<div className="grid flex-1 gap-2 sm:grid-cols-2"> <div className="grid flex-1 gap-2 sm:grid-cols-3">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">Nom affiché</Label> <Label className="text-xs">Nom affiché</Label>
<Input <Input
@ -316,8 +379,33 @@ function IdentitiesBlock({
}} }}
/> />
</div> </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 ? ( {identity.is_default ? (
<p className="text-xs text-muted-foreground sm:col-span-2"> <p className="text-xs text-muted-foreground sm:col-span-3">
Identité par défaut Identité par défaut
</p> </p>
) : null} ) : null}
@ -333,7 +421,8 @@ function IdentitiesBlock({
</Button> </Button>
</div> </div>
</li> </li>
))} )
})}
</ul> </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 { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-providers-panel"
import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel" import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel"
import { ApiTokensPanel } from "@/components/gmail/settings/automation/api-tokens-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() { export function AutomationSettingsSection() {
return ( 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." description="Règles et webhooks pour les événements mail, Drive et contacts — conditions et actions adaptées au déclencheur."
/> />
<Tabs defaultValue="rules"> <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="rules">Règles</TabsTrigger>
<TabsTrigger value="webhooks">Webhooks</TabsTrigger> <TabsTrigger value="webhooks">Webhooks</TabsTrigger>
<TabsTrigger value="llm">Fournisseurs LLM</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> <TabsTrigger value="tokens">Tokens API</TabsTrigger>
</TabsList> </TabsList>

View File

@ -12,7 +12,7 @@ export function DisplaySettingsSection() {
<> <>
<SettingsSectionHeader <SettingsSectionHeader
title="Réglages d'affichage" 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()} /> <SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
<MailSettingsFields variant="page" /> <MailSettingsFields variant="page" />

View File

@ -32,6 +32,7 @@ import {
ImapFolderSettingsTree, ImapFolderSettingsTree,
NavLabelSettingsCard, NavLabelSettingsCard,
} from "@/components/gmail/settings/nav-item-settings-card" } from "@/components/gmail/settings/nav-item-settings-card"
import { MAIL_SETTINGS_TABS_LIST_CLASS } from "@/lib/mail-chrome-classes"
function SettingsFormHeading({ function SettingsFormHeading({
icon: Icon, 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." description="Mêmes réglages que dans la barre latérale : couleur, affichage dans les listes, arborescence, renommage."
/> />
<Tabs defaultValue="labels"> <Tabs defaultValue="labels">
<TabsList> <TabsList className={MAIL_SETTINGS_TABS_LIST_CLASS}>
<TabsTrigger value="labels">Libellés</TabsTrigger> <TabsTrigger value="labels">Libellés</TabsTrigger>
<TabsTrigger value="folders-global">Dossiers globaux</TabsTrigger> <TabsTrigger value="folders-global">Dossiers globaux</TabsTrigger>
<TabsTrigger value="folders-account">Dossiers par compte</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 description: string
}) { }) {
return ( 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"> <CardHeader className="px-4 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle> <CardTitle className="text-sm font-medium">{title}</CardTitle>
</CardHeader> </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 <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( 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, className,
)} )}
{...props} {...props}

View File

@ -14,8 +14,13 @@ type UltiMailLogoProps = {
/** Icône extraite du master PNG (pas le SVG VTracer, trop « M Gmail » à petite taille). */ /** Icône extraite du master PNG (pas le SVG VTracer, trop « M Gmail » à petite taille). */
const HEADER_ICON = "/brand/ultimail-header-icon.png" const HEADER_ICON = "/brand/ultimail-header-icon.png"
const STACKED_WORDMARK = "/brand/ultimail-wordmark-stacked.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 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({ export function UltiMailLogo({
className, className,
variant = "horizontal", variant = "horizontal",
@ -63,7 +68,16 @@ export function UltiMailLogo({
width={320} width={320}
height={320} height={320}
draggable={false} 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> </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 { ApiOrgPolicy, ApiOrgSettingsResponse } from "@/lib/api/admin-org-types"
import type { OrgPolicySectionKey } 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> = { const INTEGRATION_HREFS: Record<string, string> = {
authentik: "/admin/settings/authentication", 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 = { const DEFAULT_FILE_POLICIES: FilePolicySettings = {
max_upload_mib: 512, max_upload_mib: 512,
allowed_extensions: "", allowed_extensions: "",
@ -49,6 +102,7 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
allow_password_fallback: policy.authentik.allow_password_fallback, allow_password_fallback: policy.authentik.allow_password_fallback,
default_groups: policy.authentik.default_groups, default_groups: policy.authentik.default_groups,
}, },
identityProviders: mergeIdentityProviders(policy.identity_providers),
twoFactor: { twoFactor: {
required_for_all: policy.two_factor.required_for_all, required_for_all: policy.two_factor.required_for_all,
required_for_admins: policy.two_factor.required_for_admins, 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, allow_password_fallback: state.authentik.allow_password_fallback,
default_groups: state.authentik.default_groups, 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: { two_factor: {
required_for_all: state.twoFactor.required_for_all, required_for_all: state.twoFactor.required_for_all,
required_for_admins: state.twoFactor.required_for_admins, required_for_admins: state.twoFactor.required_for_admins,

View File

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

View File

@ -10,6 +10,71 @@ export type AuthentikSettings = {
default_groups: string 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 = { export type TwoFactorPolicy = {
required_for_all: boolean required_for_all: boolean
required_for_admins: boolean required_for_admins: boolean
@ -119,6 +184,7 @@ export type IntegrationEntry = {
export type OrgSettingsState = { export type OrgSettingsState = {
authentik: AuthentikSettings authentik: AuthentikSettings
identityProviders: IdentityProvidersPolicy
twoFactor: TwoFactorPolicy twoFactor: TwoFactorPolicy
storageQuotas: OrgStorageQuotas storageQuotas: OrgStorageQuotas
usageQuotas: UsageQuotaDefaults usageQuotas: UsageQuotaDefaults

View File

@ -10,6 +10,60 @@ export type ApiOrgAuthentik = {
default_groups: string 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 = { export type ApiOrgTwoFactor = {
required_for_all: boolean required_for_all: boolean
required_for_admins: boolean required_for_admins: boolean
@ -116,6 +170,7 @@ export type ApiOrgIntegration = {
export type ApiOrgPolicy = { export type ApiOrgPolicy = {
authentik: ApiOrgAuthentik authentik: ApiOrgAuthentik
identity_providers: ApiIdentityProvidersPolicy
two_factor: ApiOrgTwoFactor two_factor: ApiOrgTwoFactor
storage_quotas: ApiOrgStorageQuotas storage_quotas: ApiOrgStorageQuotas
usage_quotas: ApiOrgUsageQuotas usage_quotas: ApiOrgUsageQuotas
@ -159,6 +214,10 @@ export type ApiOrgEffective = {
enabled: boolean enabled: boolean
public_url: string public_url: string
} }
identity_providers?: {
authentik_public_url: string
oauth_redirect_template: string
}
} }
export type ApiOrgEnvVar = { export type ApiOrgEnvVar = {
@ -180,7 +239,7 @@ export type ApiOrgDeployLocked = Record<string, ApiOrgDeployLock>
export type ApiOrgSettingsResponse = { export type ApiOrgSettingsResponse = {
policy: ApiOrgPolicy policy: ApiOrgPolicy
effective: ApiOrgEffective effective: ApiOrgEffective
secrets: Record<string, { configured: boolean }> secrets: Record<string, { configured: boolean } | Record<string, Record<string, { configured?: boolean }>>>
env_vars: ApiOrgEnvVar[] env_vars: ApiOrgEnvVar[]
deploy_locked: ApiOrgDeployLocked deploy_locked: ApiOrgDeployLocked
updated_at: string updated_at: string

View File

@ -3,6 +3,7 @@
import { create } from "zustand" import { create } from "zustand"
import { persist } from "zustand/middleware" import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage" import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
import type { PlatformUser } from "@/lib/auth/jwt-claims" import type { PlatformUser } from "@/lib/auth/jwt-claims"
const AUTH_STORAGE_KEY = "ulti-auth" const AUTH_STORAGE_KEY = "ulti-auth"
@ -50,15 +51,28 @@ export const useAuthStore = create<AuthState>()(
refreshToken: null, refreshToken: null,
expiresAt: null, expiresAt: null,
user: null, user: null,
login: (accessToken, refreshToken, expiresAt, user = null) => login: (accessToken, refreshToken, expiresAt, user = null) => {
set({ accessToken, refreshToken, expiresAt, user }), set({ accessToken, refreshToken, expiresAt, user })
logout: () => useSessionGuardStore.getState().clear()
},
logout: () => {
set({ set({
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
expiresAt: null, expiresAt: null,
user: 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: () => { isAuthenticated: () => {
const { accessToken, expiresAt, refreshToken } = get() const { accessToken, expiresAt, refreshToken } = get()
if (!accessToken) return false if (!accessToken) return false
@ -70,10 +84,18 @@ export const useAuthStore = create<AuthState>()(
{ {
name: AUTH_STORAGE_KEY, name: AUTH_STORAGE_KEY,
storage: debouncedPersistJSONStorage, 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) => ({ partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
expiresAt: state.expiresAt,
user: state.user, user: state.user,
}), }),
} }

View File

@ -1,6 +1,7 @@
import { useAuthStore } from "./auth-store"
import type { ApiError } from "./types" 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 { export class OfflineError extends Error {
constructor() { constructor() {
@ -27,32 +28,6 @@ const DEFAULT_TIMEOUT = 10_000
const DEFAULT_RETRIES = 3 const DEFAULT_RETRIES = 3
const BASE_DELAY = 1000 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 { class ApiClient {
constructor(private baseUrl: string) {} constructor(private baseUrl: string) {}
@ -66,11 +41,11 @@ class ApiClient {
return new URL(normalizedPath, normalizedBase) return new URL(normalizedPath, normalizedBase)
} }
private getHeaders(): HeadersInit { private async getHeaders(): Promise<HeadersInit> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
} }
const token = useAuthStore.getState().accessToken const token = await ensureAccessToken()
if (token) { if (token) {
headers["Authorization"] = `Bearer ${token}` headers["Authorization"] = `Bearer ${token}`
} }
@ -119,7 +94,7 @@ class ApiClient {
try { try {
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method, method,
headers: { ...this.getHeaders(), ...opts?.headers }, headers: { ...(await this.getHeaders()), ...opts?.headers },
body: opts?.body ? JSON.stringify(opts.body) : undefined, body: opts?.body ? JSON.stringify(opts.body) : undefined,
signal: controller.signal, signal: controller.signal,
}) })
@ -139,11 +114,22 @@ class ApiClient {
errorBody?.details errorBody?.details
) )
if (response.status === 401 && !authRetried) { if (response.status === 401) {
if (isSessionExpired()) {
throw err
}
if (!authRetried) {
authRetried = true authRetried = true
if (await tryRefreshSession()) { const resolution = await handleUnauthorized()
if (resolution === "refreshed") {
continue continue
} }
if (resolution === "offline") {
throw new OfflineError()
}
} else {
await handleUnauthorized({ forceExpired: true })
}
} }
if (response.status >= 400 && response.status < 500) { if (response.status >= 400 && response.status < 500) {
@ -187,18 +173,34 @@ class ApiClient {
} }
/** GET binary body (inline attachments, exports). */ /** 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) { if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new OfflineError() throw new OfflineError()
} }
const url = this.resolveUrl(path) const url = this.resolveUrl(path)
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
const token = useAuthStore.getState().accessToken const token = await ensureAccessToken()
if (token) { if (token) {
headers["Authorization"] = `Bearer ${token}` headers["Authorization"] = `Bearer ${token}`
} }
const response = await fetch(url.toString(), { method: "GET", headers }) const response = await fetch(url.toString(), { method: "GET", headers })
if (!response.ok) { 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( throw new ApiRequestError(
response.status, response.status,
"UNKNOWN", "UNKNOWN",

View File

@ -6,6 +6,8 @@ import { useAuthReady } from '../use-auth-ready'
import type { ApiFolder, ApiLabel, ApiIdentity } from '../types' import type { ApiFolder, ApiLabel, ApiIdentity } from '../types'
export function useFolders(accountId?: string) { export function useFolders(accountId?: string) {
const { ready, authenticated } = useAuthReady()
return useQuery({ return useQuery({
queryKey: ['folders', accountId], queryKey: ['folders', accountId],
queryFn: async () => { queryFn: async () => {
@ -15,7 +17,7 @@ export function useFolders(accountId?: string) {
) )
return Array.isArray(res) ? res : (res.folders ?? []) return Array.isArray(res) ? res : (res.folders ?? [])
}, },
enabled: !!accountId, enabled: ready && authenticated && !!accountId,
staleTime: 5 * 60_000, 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 { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client' import { apiClient, OfflineError } from '../client'
import { useAuthReady } from '../use-auth-ready' 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 { import type {
PaginatedResponse, PaginatedResponse,
ApiMessageSummary, 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( export function useMessages(
folder: string, folder: string,
accountId?: string, accountId?: string,
page?: number, page?: number,
pageSize?: number pageSize?: number
) { ) {
const { ready, authenticated } = useAuthReady()
return useQuery({ return useQuery({
queryKey: ['messages', folder, accountId, page, pageSize], queryKey: messagesQueryKey(folder, accountId, page, pageSize),
queryFn: async () => { queryFn: () =>
const safePageSize = normalizeListPageSize(pageSize ?? 50) fetchMessagesPage(folder, accountId, page ?? 1, pageSize ?? LIST_PAGE_SIZE),
const res = await apiClient.get<ApiMessagesPayload>('/mail/messages', {
folder,
account_id: accountId,
page: String(page ?? 1),
page_size: String(safePageSize),
})
return unwrapMessages(res)
},
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 60_000, staleTime: 60_000,
enabled: ready && authenticated,
}) })
} }
export function useMessage(messageId: string | null) { export function useMessage(messageId: string | null) {
const { ready, authenticated } = useAuthReady()
return useQuery({ return useQuery({
queryKey: ['message', messageId], queryKey: ['message', messageId],
queryFn: () => apiClient.get<ApiMessageFull>(`/mail/messages/${messageId}`), queryFn: () => apiClient.get<ApiMessageFull>(`/mail/messages/${messageId}`),
enabled: !!messageId, enabled: ready && authenticated && !!messageId,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}) })
@ -82,6 +104,8 @@ export function unwrapThreadMessages(
} }
export function useThread(threadId: string | null) { export function useThread(threadId: string | null) {
const { ready, authenticated } = useAuthReady()
return useQuery({ return useQuery({
queryKey: ['thread', 'v2', threadId], queryKey: ['thread', 'v2', threadId],
queryFn: () => queryFn: () =>
@ -89,7 +113,7 @@ export function useThread(threadId: string | null) {
ApiMessageFull[] | { messages?: ApiMessageFull[]; thread_id?: string } ApiMessageFull[] | { messages?: ApiMessageFull[]; thread_id?: string }
>(`/mail/threads/${threadId}`), >(`/mail/threads/${threadId}`),
select: unwrapThreadMessages, select: unwrapThreadMessages,
enabled: !!threadId, enabled: ready && authenticated && !!threadId,
}) })
} }
@ -112,6 +136,7 @@ export function useMailAccounts() {
export function useMailSearch(filter: MessageSearchFilter | null) { export function useMailSearch(filter: MessageSearchFilter | null) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { ready, authenticated } = useAuthReady()
return useQuery({ return useQuery({
queryKey: ['mail-search', filter], queryKey: ['mail-search', filter],
@ -172,6 +197,6 @@ export function useMailSearch(filter: MessageSearchFilter | null) {
throw err 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '../client' import { apiClient } from '../client'
import { useAuthReady } from '../use-auth-ready'
import type { ApiMailSignature, CreateMailSignaturePayload } from '../types' import type { ApiMailSignature, CreateMailSignaturePayload } from '../types'
const SIGNATURES_KEY = ['mail-signatures'] as const const SIGNATURES_KEY = ['mail-signatures'] as const
@ -14,10 +15,13 @@ async function fetchSignatures(): Promise<ApiMailSignature[]> {
} }
export function useMailSignatures() { export function useMailSignatures() {
const { ready, authenticated } = useAuthReady()
return useQuery({ return useQuery({
queryKey: SIGNATURES_KEY, queryKey: SIGNATURES_KEY,
queryFn: fetchSignatures, queryFn: fetchSignatures,
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
enabled: ready && authenticated,
}) })
} }

View File

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

View File

@ -3,6 +3,7 @@
import { useEffect } from "react" import { useEffect } from "react"
import { useQueryClient, type QueryClient } from "@tanstack/react-query" import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import type { WsEvent, WsEventType, WsMailPayload } from "./types" import type { WsEvent, WsEventType, WsMailPayload } from "./types"
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
import { useAuthStore } from "./auth-store" import { useAuthStore } from "./auth-store"
export type WsEventListener = (evt: WsEvent) => void export type WsEventListener = (evt: WsEvent) => void
@ -150,12 +151,22 @@ export function useWebSocket() {
}, [queryClient]) }, [queryClient])
useEffect(() => { useEffect(() => {
if (accessToken) { let cancelled = false
wsManager.connect(accessToken)
void (async () => {
const token = accessToken ? await ensureAccessToken() : null
if (cancelled) return
if (token) {
wsManager.connect(token)
} else { } else {
wsManager.disconnect() wsManager.disconnect()
} }
return () => wsManager.disconnect() })()
return () => {
cancelled = true
wsManager.disconnect()
}
}, [accessToken]) }, [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 endSessionEndpoint: string
} }
let discoveryCache: { issuer: string; doc: OidcDiscovery; at: number } | null = let discoveryCache: {
null discoveryIssuer: string
doc: OidcDiscovery
at: number
} | null = null
const DISCOVERY_TTL_MS = 5 * 60 * 1000 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 { export function getPublicOidcConfig(): OidcConfig {
const issuer = trimSlash( const issuer = trimSlash(
process.env.NEXT_PUBLIC_OIDC_ISSUER ?? 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). */ /** Resolve authorize/token URLs from issuer discovery (Authentik uses shared /o/ endpoints). */
export async function resolveOidcConfig(): Promise<OidcConfig> { export async function resolveOidcConfig(): Promise<OidcConfig> {
const base = getPublicOidcConfig() const base = getPublicOidcConfig()
const internalOrigin = getOidcInternalOrigin()
const discoveryIssuer = internalOrigin
? issuerWithOrigin(base.issuer, internalOrigin)
: base.issuer
const now = Date.now() const now = Date.now()
if ( if (
discoveryCache && discoveryCache &&
discoveryCache.issuer === base.issuer && discoveryCache.discoveryIssuer === discoveryIssuer &&
now - discoveryCache.at < DISCOVERY_TTL_MS now - discoveryCache.at < DISCOVERY_TTL_MS
) { ) {
return applyDiscovery(base, discoveryCache.doc) return applyDiscovery(base, discoveryCache.doc, internalOrigin)
} }
const res = await fetch( const res = await fetch(
`${base.issuer}.well-known/openid-configuration`, `${discoveryIssuer}.well-known/openid-configuration`,
{ next: { revalidate: 300 } } { next: { revalidate: 300 } }
) )
if (!res.ok) { 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 const doc = (await res.json()) as OidcDiscovery
discoveryCache = { issuer: base.issuer, doc, at: now } discoveryCache = { discoveryIssuer, doc, at: now }
return applyDiscovery(base, doc) 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 { return {
...base, ...base,
authorizationEndpoint: doc.authorization_endpoint, authorizationEndpoint: toPublicEndpoint(
tokenEndpoint: doc.token_endpoint, doc.authorization_endpoint,
endSessionEndpoint: internalOrigin,
publicOrigin
),
tokenEndpoint: toServerEndpoint(
doc.token_endpoint,
internalOrigin,
publicOrigin
),
endSessionEndpoint: toPublicEndpoint(
doc.end_session_endpoint ?? base.endSessionEndpoint, 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 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. */ /** Ultimail session lifetime — independent of short-lived OIDC access tokens. */
export const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 365 export const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 365
@ -18,13 +20,24 @@ export type TokenResponse = {
token_type?: string 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() { export function sessionCookieOptions() {
return { return {
httpOnly: true, httpOnly: true,
sameSite: "lax" as const, sameSite: "lax" as const,
path: "/", path: "/",
maxAge: SESSION_MAX_AGE_SEC, 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 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( export function isAccessTokenValid(
accessToken: string | undefined, accessToken: string | undefined,
expiresAtRaw: string | undefined expiresAtRaw: string | undefined
): boolean { ): boolean {
if (!accessToken || !expiresAtRaw) return false if (!accessToken) return false
const expiresAt = Number(expiresAtRaw) 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 = { type OidcTokenConfig = {
@ -59,7 +81,10 @@ export async function exchangeRefreshToken(
}) })
const res = await fetch(cfg.tokenEndpoint, { const res = await fetch(cfg.tokenEndpoint, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: {
"Content-Type": "application/x-www-form-urlencoded",
...oidcServerFetchHeaders(),
},
body, body,
}) })
if (!res.ok) { if (!res.ok) {
@ -69,11 +94,10 @@ export async function exchangeRefreshToken(
} }
export function resolveBearerToken(tokens: TokenResponse): string { export function resolveBearerToken(tokens: TokenResponse): string {
const bearer = tokens.id_token ?? tokens.access_token if (!tokens.id_token) {
if (!bearer) { throw new Error("no_id_token_in_response")
throw new Error("no_token_in_response")
} }
return bearer return tokens.id_token
} }
export function applySessionCookies( 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 ? window.location.pathname + window.location.search
: undefined : undefined
const mode = canEdit ? "edit" : "view" const mode = canEdit ? "edit" : "view"
router.push(buildPublicShareEditHref(token, file.path, returnTo, mode)) router.push(buildPublicShareEditHref(token, file.path, returnTo, mode, file.name))
return return
} }

View File

@ -2,7 +2,8 @@ export function buildPublicShareEditHref(
token: string, token: string,
filePath: string, filePath: string,
returnTo?: string, returnTo?: string,
mode: "edit" | "view" = "edit" mode: "edit" | "view" = "edit",
displayName?: string
): string { ): string {
const trimmed = filePath.replace(/^\/+|\/+$/g, "") const trimmed = filePath.replace(/^\/+|\/+$/g, "")
const base = `/drive/s/${encodeURIComponent(token)}/edit/${trimmed.split("/").map(encodeURIComponent).join("/")}` const base = `/drive/s/${encodeURIComponent(token)}/edit/${trimmed.split("/").map(encodeURIComponent).join("/")}`
@ -13,6 +14,9 @@ export function buildPublicShareEditHref(
if (mode === "view") { if (mode === "view") {
params.set("mode", "view") params.set("mode", "view")
} }
if (displayName?.trim()) {
params.set("name", displayName.trim())
}
const qs = params.toString() const qs = params.toString()
return qs ? `${base}?${qs}` : base 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" "use client"
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
import { useAuthStore } from "@/lib/api/auth-store" 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). */ /** Identity shown in header avatar / account menu (OIDC user, else active mail account). */
export function useChromeIdentity(): { export function useChromeIdentity(): {
@ -9,9 +10,15 @@ export function useChromeIdentity(): {
email: string email: string
firstName: string firstName: string
} | null { } | null {
const authHydrated = usePersistHydrated(useAuthStore)
const accountHydrated = usePersistHydrated(useAccountStore)
const platformUser = useAuthStore((s) => s.user) const platformUser = useAuthStore((s) => s.user)
const mailAccount = useActiveAccount() 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) { if (platformUser) {
return { return {
name: platformUser.name, name: platformUser.name,

View File

@ -308,6 +308,18 @@ export const MAIL_SETTINGS_MAIN_INSET_CLASS =
export const MAIL_SETTINGS_MAIN_CARD_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" "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+. */ /** Masonry 2 colonnes pour sections réglages (affichage, signatures…) en lg+. */
export const MAIL_SETTINGS_PAGE_MASONRY_CLASS = "lg:columns-2 lg:gap-5" 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). */ /** Bloc empilé → card en masonry (variant page affichage). */
export const MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS = cn( 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, Bot,
FolderKanban, FolderKanban,
Monitor, Monitor,
PenLine,
Users, Users,
} from "lucide-react" } from "lucide-react"
export type MailSettingsSectionId = export type MailSettingsSectionId =
| "display" | "display"
| "accounts" | "accounts"
| "signatures"
| "labels" | "labels"
| "notifications" | "notifications"
| "automation" | "automation"
@ -35,17 +33,10 @@ export const MAIL_SETTINGS_NAV: MailSettingsNavItem[] = [
{ {
id: "accounts", id: "accounts",
label: "Comptes mail", label: "Comptes mail",
description: "IMAP, SMTP et identités d'envoi", description: "IMAP, SMTP, identités d'envoi et signatures",
href: "/mail/settings/accounts", href: "/mail/settings/accounts",
icon: Users, icon: Users,
}, },
{
id: "signatures",
label: "Signatures",
description: "Bibliothèque et attribution par identité",
href: "/mail/settings/signatures",
icon: PenLine,
},
{ {
id: "labels", id: "labels",
label: "Libellés et dossiers", label: "Libellés et dossiers",
@ -96,10 +87,11 @@ export function resolveMailSettingsSection(
const MAIL_SETTINGS_WIDE_LAYOUT_SECTIONS: MailSettingsSectionId[] = [ const MAIL_SETTINGS_WIDE_LAYOUT_SECTIONS: MailSettingsSectionId[] = [
"display", "display",
"signatures",
"automation", "automation",
] ]
const MAIL_SETTINGS_LEFT_ALIGNED_SECTIONS: MailSettingsSectionId[] = ["accounts"]
export function isMailSettingsWideLayoutPath(pathname: string | null): boolean { export function isMailSettingsWideLayoutPath(pathname: string | null): boolean {
if (!pathname?.startsWith("/mail/settings")) return false if (!pathname?.startsWith("/mail/settings")) return false
return MAIL_SETTINGS_NAV.some( return MAIL_SETTINGS_NAV.some(
@ -108,3 +100,12 @@ export function isMailSettingsWideLayoutPath(pathname: string | null): boolean {
isMailSettingsNavActive(pathname, item) 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", "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", "reading-pane", "Volet de lecture", "split panneau droite aperçu message"),
entry("display", "conversation", "Mode Conversation", "fil discussion thread regrouper messages"), 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", "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", "identities", "Identités d'envoi", "alias from expéditeur adresse envoi"),
entry("accounts", "imap", "IMAP", "réception serveur entrant synchronisation"), entry("accounts", "imap", "IMAP", "réception serveur entrant synchronisation"),
entry("accounts", "smtp", "SMTP", "envoi serveur sortant"), entry("accounts", "smtp", "SMTP", "envoi serveur sortant"),
entry("signatures", "signature-library", "Bibliothèque de signatures", "créer modifier supprimer signature html"), entry("accounts", "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-assign", "Signature par identité", "identité signature par défaut sélecteur compte mail"),
entry("labels", "labels", "Libellés", "tags couleur étiquettes organisation"), entry("labels", "labels", "Libellés", "tags couleur étiquettes organisation"),
entry("labels", "folders", "Dossiers", "imap unified unifiés arborescence"), entry("labels", "folders", "Dossiers", "imap unified unifiés arborescence"),
entry("labels", "unified-folders", "Dossiers unifiés", "cross-comptes organisation partagée"), entry("labels", "unified-folders", "Dossiers unifiés", "cross-comptes organisation partagée"),

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@ -32,6 +32,48 @@ function headerIconPath() {
return p 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") { async function writePngJpg(input, outBase, w, h, bg = "#ffffff") {
const resize = { const resize = {
width: w, width: w,
@ -109,6 +151,7 @@ async function main() {
await writePngJpg(headerIcon, "ultimail-mark", 256, 256) await writePngJpg(headerIcon, "ultimail-mark", 256, 256)
await writePngJpg(original, "ultimail-wordmark-stacked", 800, 800) await writePngJpg(original, "ultimail-wordmark-stacked", 800, 800)
await buildStackedDark(original)
await writePngJpg(horizontalBuf, "ultimail-wordmark-horizontal", 1600, 460) await writePngJpg(horizontalBuf, "ultimail-wordmark-horizontal", 1600, 460)
} }

File diff suppressed because one or more lines are too long