feat(auth): enhance session management and identity provider settings
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
f44dadc453
commit
5304790ed5
@ -2,7 +2,8 @@
|
||||
NEXT_PUBLIC_API_URL=/api/v1
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost/ws
|
||||
# Cible du proxy Next (optionnel, défaut 127.0.0.1:80)
|
||||
# ULTI_PROXY_ORIGIN=http://127.0.0.1
|
||||
# Sert aussi aux appels OIDC serveur (discovery/token) — Docker: http://nginx
|
||||
# ULTI_PROXY_ORIGIN=http://127.0.0.1:80
|
||||
|
||||
# OIDC Authentik (blueprints deploy/authentik dans ulti-backend)
|
||||
NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/
|
||||
@ -10,6 +11,8 @@ NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
|
||||
# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
|
||||
# URL publique navigateur (suite nginx) — pas :3000 si tu passes par http://localhost/mail
|
||||
NEXT_PUBLIC_APP_URL=http://localhost
|
||||
# Cookies session Secure (auto: true seulement si NEXT_PUBLIC_APP_URL est https://)
|
||||
# COOKIE_SECURE=false
|
||||
|
||||
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
|
||||
OIDC_CLIENT_SECRET=changeme
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { NextResponse } from "next/server"
|
||||
import { resolveServerOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config"
|
||||
import {
|
||||
resolveServerOidcConfig,
|
||||
getAppOrigin,
|
||||
oidcServerFetchHeaders,
|
||||
} from "@/lib/auth/oidc-config"
|
||||
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
|
||||
import {
|
||||
applySessionCookies,
|
||||
@ -75,7 +79,10 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const res = await fetch(cfg.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
...oidcServerFetchHeaders(),
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
||||
@ -3,6 +3,7 @@ import { NextResponse } from "next/server"
|
||||
import { createPkcePair, randomString } from "@/lib/auth/pkce"
|
||||
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
|
||||
import { resolveOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config"
|
||||
import { sessionCookieOptions } from "@/lib/auth/session"
|
||||
|
||||
const PKCE_COOKIE = "ulti_pkce_verifier"
|
||||
const STATE_COOKIE = "ulti_oauth_state"
|
||||
@ -12,11 +13,8 @@ const COOKIE_MAX_AGE = 600
|
||||
|
||||
function oauthCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
...sessionCookieOptions(),
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
computeExpiresAt,
|
||||
exchangeRefreshToken,
|
||||
isAccessTokenValid,
|
||||
isIdTokenJwtValid,
|
||||
resolveBearerToken,
|
||||
} from "@/lib/auth/session"
|
||||
|
||||
@ -40,7 +41,25 @@ export async function GET() {
|
||||
try {
|
||||
const cfg = await resolveServerOidcConfig()
|
||||
const tokens = await exchangeRefreshToken(refreshToken, cfg)
|
||||
const bearer = resolveBearerToken(tokens)
|
||||
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = resolveBearerToken(tokens)
|
||||
} catch {
|
||||
if (accessToken && isIdTokenJwtValid(accessToken)) {
|
||||
const expiresAt = Number(expiresAtRaw) || computeExpiresAt(3600)
|
||||
const user = platformUserFromToken(accessToken)
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
accessToken,
|
||||
refreshToken: refreshToken ?? null,
|
||||
expiresAt,
|
||||
user,
|
||||
})
|
||||
}
|
||||
return NextResponse.json({ authenticated: false, expired: true })
|
||||
}
|
||||
|
||||
const expiresAt = computeExpiresAt(tokens.expires_in ?? 3600)
|
||||
const user = platformUserFromToken(bearer)
|
||||
|
||||
|
||||
@ -2,13 +2,11 @@
|
||||
|
||||
import { useEffect, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useAuthStore } from "@/lib/api/auth-store"
|
||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||
import { applySessionToStore } from "@/lib/auth/session-sync"
|
||||
|
||||
function AuthCompleteInner() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
|
||||
const accountNotice = searchParams.get("accountNotice")
|
||||
|
||||
@ -23,20 +21,9 @@ function AuthCompleteInner() {
|
||||
accessToken?: string
|
||||
refreshToken?: string | null
|
||||
expiresAt?: number
|
||||
user?: PlatformUser | null
|
||||
user?: { sub: string; email: string; name: string } | null
|
||||
}
|
||||
if (
|
||||
data.authenticated &&
|
||||
data.accessToken &&
|
||||
data.expiresAt &&
|
||||
!cancelled
|
||||
) {
|
||||
login(
|
||||
data.accessToken,
|
||||
data.refreshToken ?? "",
|
||||
data.expiresAt,
|
||||
data.user ?? null
|
||||
)
|
||||
if (applySessionToStore(data) && !cancelled) {
|
||||
if (accountNotice === "same") {
|
||||
sessionStorage.setItem("ulti_account_notice", "same")
|
||||
}
|
||||
@ -55,7 +42,7 @@ function AuthCompleteInner() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [accountNotice, login, returnTo, router])
|
||||
}, [accountNotice, returnTo, router])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next"
|
||||
import { displayFileName } from "@/lib/drive/display-file-name"
|
||||
import { displayFileBaseName } from "@/lib/drive/display-file-name"
|
||||
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||
|
||||
type LayoutProps = {
|
||||
@ -9,7 +9,9 @@ type LayoutProps = {
|
||||
|
||||
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
|
||||
const { fileId } = await params
|
||||
const name = displayFileName(decodeURIComponent(fileId))
|
||||
const raw = decodeURIComponent(fileId)
|
||||
const baseName = raw.split("/").filter(Boolean).pop() ?? raw
|
||||
const name = displayFileBaseName(baseName)
|
||||
return suitePageMetadata({
|
||||
app: "drive",
|
||||
titleSegment: name,
|
||||
|
||||
@ -13,6 +13,7 @@ export default function PublicShareEditPage() {
|
||||
const filePath = filePathFromPublicEditSegments(token, pathSegments)
|
||||
const returnTo = searchParams.get("returnTo")
|
||||
const mode = searchParams.get("mode") === "view" ? "view" : "edit"
|
||||
const fileDisplayName = searchParams.get("name") ?? undefined
|
||||
const [password] = useState<string | undefined>(() => {
|
||||
if (typeof window === "undefined") return undefined
|
||||
return sessionStorage.getItem(`public-share-pw:${token}`) ?? undefined
|
||||
@ -25,6 +26,7 @@ export default function PublicShareEditPage() {
|
||||
password={password}
|
||||
returnTo={returnTo}
|
||||
mode={mode}
|
||||
fileDisplayName={fileDisplayName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
137
app/globals.css
137
app/globals.css
@ -719,6 +719,8 @@ html[data-mail-background]:not([data-mail-background='none'])
|
||||
.ultimail-app {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
--border: var(--mail-border);
|
||||
--input: var(--mail-border);
|
||||
}
|
||||
|
||||
/* Lignes de liste */
|
||||
@ -840,8 +842,8 @@ html.dark .ultimail-app {
|
||||
--muted-foreground: var(--mail-text-muted);
|
||||
--accent: var(--mail-nav-hover);
|
||||
--accent-foreground: var(--mail-text);
|
||||
--border: var(--mail-border-subtle);
|
||||
--input: var(--mail-border-subtle);
|
||||
--border: var(--mail-border);
|
||||
--input: var(--mail-border);
|
||||
--ring: var(--mail-border);
|
||||
}
|
||||
|
||||
@ -917,17 +919,73 @@ html.dark .ultimail-app :where(.tiptap code, .tiptap pre) {
|
||||
background-color: var(--mail-surface-muted);
|
||||
}
|
||||
|
||||
/* ── Dark : portails Radix & toasts (rendus hors .ultimail-app) ── */
|
||||
html.dark [data-slot='dropdown-menu-content'],
|
||||
html.dark [data-slot='dropdown-menu-sub-content'],
|
||||
html.dark [data-slot='context-menu-content'],
|
||||
html.dark [data-slot='context-menu-sub-content'],
|
||||
html.dark [data-slot='popover-content'],
|
||||
html.dark [data-slot='select-content'],
|
||||
html.dark [data-slot='menubar-content'] {
|
||||
background-color: var(--popover) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
border-color: var(--border) !important;
|
||||
/* Portails Radix (rendus hors .ultimail-app) — bordures/champs alignés sur le gris mail */
|
||||
:where(
|
||||
[data-slot='dialog-content'],
|
||||
[data-slot='alert-dialog-content'],
|
||||
[data-slot='popover-content'],
|
||||
[data-slot='hover-card-content'],
|
||||
[data-slot='dropdown-menu-content'],
|
||||
[data-slot='dropdown-menu-sub-content'],
|
||||
[data-slot='context-menu-content'],
|
||||
[data-slot='context-menu-sub-content'],
|
||||
[data-slot='select-content'],
|
||||
[data-slot='menubar-content'],
|
||||
[data-slot='menubar-sub-content'],
|
||||
[data-slot='tooltip-content'],
|
||||
[data-slot='sheet-content'],
|
||||
[data-slot='drawer-content']
|
||||
) {
|
||||
--border: var(--mail-border);
|
||||
--input: var(--mail-border);
|
||||
}
|
||||
|
||||
/* ── Dark : portails Radix (rendus hors .ultimail-app) ── */
|
||||
html.dark :where(
|
||||
[data-slot='dialog-content'],
|
||||
[data-slot='alert-dialog-content'],
|
||||
[data-slot='popover-content'],
|
||||
[data-slot='hover-card-content'],
|
||||
[data-slot='dropdown-menu-content'],
|
||||
[data-slot='dropdown-menu-sub-content'],
|
||||
[data-slot='context-menu-content'],
|
||||
[data-slot='context-menu-sub-content'],
|
||||
[data-slot='select-content'],
|
||||
[data-slot='menubar-content'],
|
||||
[data-slot='menubar-sub-content'],
|
||||
[data-slot='tooltip-content'],
|
||||
[data-slot='sheet-content'],
|
||||
[data-slot='drawer-content']
|
||||
) {
|
||||
--background: var(--mail-surface-elevated);
|
||||
--foreground: var(--mail-text);
|
||||
--popover: var(--mail-surface-elevated);
|
||||
--popover-foreground: var(--mail-text);
|
||||
--card: var(--mail-surface-elevated);
|
||||
--card-foreground: var(--mail-text);
|
||||
--secondary: var(--mail-surface-muted);
|
||||
--secondary-foreground: var(--mail-text);
|
||||
--muted: var(--mail-surface-muted);
|
||||
--muted-foreground: var(--mail-text-muted);
|
||||
--accent: var(--mail-nav-hover);
|
||||
--accent-foreground: var(--mail-text);
|
||||
--border: var(--mail-border);
|
||||
--input: var(--mail-border);
|
||||
--ring: var(--mail-border);
|
||||
background-color: var(--mail-surface-elevated) !important;
|
||||
color: var(--mail-text) !important;
|
||||
border-color: var(--mail-border) !important;
|
||||
}
|
||||
|
||||
html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content'])
|
||||
:where([data-slot='input'], textarea, [data-slot='select-trigger']) {
|
||||
background-color: var(--mail-surface-muted) !important;
|
||||
border-color: var(--mail-border) !important;
|
||||
color: var(--mail-text) !important;
|
||||
}
|
||||
|
||||
html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) fieldset.border {
|
||||
border-color: var(--mail-border) !important;
|
||||
}
|
||||
|
||||
/* Drive / Contacts : menus portés — gris mail, pas le noir `popover`. */
|
||||
@ -960,13 +1018,13 @@ html.dark [data-slot='context-menu-item']:focus,
|
||||
html.dark [data-slot='context-menu-item'][data-highlighted],
|
||||
html.dark [data-slot='context-menu-sub-trigger']:focus,
|
||||
html.dark [data-slot='context-menu-sub-trigger'][data-state='open'] {
|
||||
background-color: var(--accent) !important;
|
||||
color: var(--accent-foreground) !important;
|
||||
background-color: var(--mail-nav-hover) !important;
|
||||
color: var(--mail-text) !important;
|
||||
}
|
||||
|
||||
html.dark [data-slot='dropdown-menu-separator'],
|
||||
html.dark [data-slot='context-menu-separator'] {
|
||||
background-color: var(--border) !important;
|
||||
background-color: var(--mail-border-subtle) !important;
|
||||
}
|
||||
|
||||
/* Recherche avancée — champs (sheet xs + panneau dropdown desktop) */
|
||||
@ -1012,12 +1070,14 @@ html.dark .ultimail-app :where(.text-\[#0f172a\], .text-\[#0b57d0\]) {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
html.dark .ultimail-app :where([data-slot='checkbox']) {
|
||||
background-color: transparent;
|
||||
border-color: #9aa0a6;
|
||||
}
|
||||
|
||||
html.dark .ultimail-app :where([data-slot='checkbox'][data-state='checked']) {
|
||||
html.dark
|
||||
:where(
|
||||
.ultimail-app,
|
||||
[data-slot='dialog-content'],
|
||||
[data-slot='popover-content'],
|
||||
[data-slot='sheet-content']
|
||||
)
|
||||
:where([data-slot='checkbox'][data-state='checked'], [data-slot='checkbox'][data-state='indeterminate']) {
|
||||
background-color: #1a73e8;
|
||||
border-color: #1a73e8;
|
||||
}
|
||||
@ -1121,6 +1181,37 @@ html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] .
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Réglages mail : cartes cohérentes en dark mode (fond + bordure plus visible) */
|
||||
html.dark [data-mail-settings-main] {
|
||||
--border: var(--mail-border);
|
||||
}
|
||||
|
||||
html.dark [data-mail-settings-main] :where(
|
||||
.mail-settings-card,
|
||||
[data-slot='card'],
|
||||
[class*='rounded-lg'].border,
|
||||
[class*='rounded-xl'].border,
|
||||
[class*='rounded-md'].border
|
||||
):not(.mail-settings-masonry-section) {
|
||||
background-color: var(--mail-surface-elevated) !important;
|
||||
border-color: var(--mail-border) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
html.dark [data-mail-settings-main] .mail-settings-masonry-section {
|
||||
background-color: var(--mail-surface-elevated) !important;
|
||||
border-color: var(--mail-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark [data-mail-settings-main] :where(
|
||||
[class*='rounded-lg'][class*='border-dashed'],
|
||||
[class*='rounded-xl'][class*='border-dashed'],
|
||||
[class*='rounded-md'][class*='border-dashed']
|
||||
) {
|
||||
border-color: color-mix(in srgb, var(--mail-border) 72%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */
|
||||
html.dark .ultimail-app :where(.bg-background) {
|
||||
background-color: var(--mail-surface-muted) !important;
|
||||
@ -1132,6 +1223,6 @@ html.dark .ultimail-app :where(.bg-muted\/10, .bg-muted\/20, .bg-muted\/30, .bg-
|
||||
|
||||
html.dark .ultimail-app :where([data-slot='input'], [data-slot='select-trigger'], [data-slot='textarea']) {
|
||||
background-color: var(--mail-surface-muted) !important;
|
||||
border-color: var(--mail-border-subtle) !important;
|
||||
border-color: var(--mail-border) !important;
|
||||
color: var(--mail-text) !important;
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { ThemeInitScript } from '@/components/theme-init-script'
|
||||
import { FirstLaunchSplash } from '@/components/first-launch-splash'
|
||||
import { QueryProvider } from '@/lib/api/query-provider'
|
||||
import { AuthProvider } from '@/components/auth/auth-provider'
|
||||
import { SessionGuard } from '@/components/auth/session-guard'
|
||||
import { MailToaster } from '@/components/gmail/mail-toaster'
|
||||
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
|
||||
|
||||
@ -34,6 +35,7 @@ export default function RootLayout({
|
||||
<ThemeInitScript />
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<SessionGuard />
|
||||
<FirstLaunchSplash>{children}</FirstLaunchSplash>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
|
||||
@ -16,7 +16,8 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
const LOGIN_CARD_CLASS = cn(
|
||||
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none",
|
||||
"sm:gap-5 sm:bg-card sm:px-8 sm:py-8 sm:text-card-foreground sm:shadow-none"
|
||||
"sm:gap-5 sm:bg-card sm:dark:bg-mail-surface-elevated sm:px-8 sm:py-8",
|
||||
"sm:text-card-foreground sm:dark:text-mail-text sm:shadow-none"
|
||||
)
|
||||
|
||||
function LoginContent() {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { MailSettingsSectionFromSegments } from "@/components/gmail/settings/mail-settings-section-view"
|
||||
|
||||
export default async function MailSettingsSectionPage({
|
||||
@ -6,5 +7,8 @@ export default async function MailSettingsSectionPage({
|
||||
params: Promise<{ section?: string[] }>
|
||||
}) {
|
||||
const { section } = await params
|
||||
if (section?.[0] === "signatures") {
|
||||
redirect("/mail/settings/accounts")
|
||||
}
|
||||
return <MailSettingsSectionFromSegments segments={section} />
|
||||
}
|
||||
|
||||
95
components/admin/settings/guides/identity-provider-guides.ts
Normal file
95
components/admin/settings/guides/identity-provider-guides.ts
Normal 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
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { IdentityProvidersSection } from "@/components/admin/settings/sections/identity-providers-section"
|
||||
import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint"
|
||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@ -22,6 +23,7 @@ export function AuthenticationSection() {
|
||||
const clientID = clientLocked ? (effective?.client_id ?? authentik.client_id) : authentik.client_id
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<OrgSettingsSection
|
||||
title="Authentification Authentik"
|
||||
description="SSO, provisionnement des comptes Ultimail et groupes par défaut."
|
||||
@ -107,5 +109,8 @@ export function AuthenticationSection() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OrgSettingsSection>
|
||||
|
||||
<IdentityProvidersSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'accès</p>
|
||||
<div>
|
||||
<Label>Domaines email autorisés</Label>
|
||||
<Textarea
|
||||
className="mt-1 min-h-20"
|
||||
placeholder="company.com 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>
|
||||
)
|
||||
}
|
||||
@ -31,8 +31,8 @@ export function SecuritySection() {
|
||||
description="Politiques d'authentification à deux facteurs pour l'organisation."
|
||||
policySection="two_factor"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<Card className="gap-3 py-4">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-medium">Exigences</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@ -60,8 +60,8 @@ export function SecuritySection() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<Card className="gap-3 py-4">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-sm font-medium">Méthodes autorisées</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
|
||||
@ -4,7 +4,15 @@ import { useCallback, useEffect, useState, type ReactNode } from "react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useAuthStore, AUTH_STORAGE_KEY, LEGACY_AUTH_KEYS } from "@/lib/api/auth-store"
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc-config"
|
||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||
import {
|
||||
fetchSession,
|
||||
applySessionToStore,
|
||||
type SessionPayload,
|
||||
} from "@/lib/auth/session-sync"
|
||||
import {
|
||||
isSessionExpired,
|
||||
useSessionGuardStore,
|
||||
} from "@/lib/auth/session-guard-store"
|
||||
|
||||
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
|
||||
const REFRESH_LEAD_MS = 5 * 60 * 1000
|
||||
@ -17,52 +25,16 @@ function isPublicPath(pathname: string) {
|
||||
)
|
||||
}
|
||||
|
||||
type SessionPayload = {
|
||||
authenticated?: boolean
|
||||
accessToken?: string
|
||||
refreshToken?: string | null
|
||||
expiresAt?: number
|
||||
user?: PlatformUser | null
|
||||
}
|
||||
|
||||
async function fetchSession(): Promise<SessionPayload | null> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", { credentials: "include" })
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as SessionPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function canTrustPersistedAuth() {
|
||||
return useAuthStore.persist.hasHydrated() && useAuthStore.getState().isAuthenticated()
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const [ready, setReady] = useState(
|
||||
() => !isOidcConfigured() || canTrustPersistedAuth()
|
||||
)
|
||||
const [ready, setReady] = useState(() => !isOidcConfigured())
|
||||
|
||||
const applySession = useCallback(
|
||||
(data: SessionPayload) => {
|
||||
if (data.authenticated && data.accessToken && data.expiresAt) {
|
||||
login(
|
||||
data.accessToken,
|
||||
data.refreshToken ?? "",
|
||||
data.expiresAt,
|
||||
data.user ?? null
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[login]
|
||||
(data: SessionPayload) => applySessionToStore(data),
|
||||
[]
|
||||
)
|
||||
|
||||
const syncSession = useCallback(async () => {
|
||||
@ -81,10 +53,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (canTrustPersistedAuth()) {
|
||||
setReady(true)
|
||||
}
|
||||
|
||||
const data = await fetchSession()
|
||||
if (cancelled) return
|
||||
|
||||
@ -93,19 +61,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data?.authenticated === false || !canTrustPersistedAuth()) {
|
||||
const hadMemoryAuth = useAuthStore.getState().isAuthenticated()
|
||||
logout()
|
||||
if (hadMemoryAuth && !isPublicPath(pathname) && !isSessionExpired()) {
|
||||
useSessionGuardStore.getState().setExpired()
|
||||
}
|
||||
setReady(true)
|
||||
}
|
||||
|
||||
if (!useAuthStore.persist.hasHydrated()) {
|
||||
const unsubHydrate = useAuthStore.persist.onFinishHydration(() => {
|
||||
if (useAuthStore.getState().isAuthenticated()) {
|
||||
setReady(true)
|
||||
}
|
||||
})
|
||||
void bootstrap()
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubHydrate()
|
||||
@ -116,7 +83,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applySession, logout])
|
||||
}, [applySession, logout, pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !isOidcConfigured()) return
|
||||
@ -140,6 +107,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
let cancelled = false
|
||||
void syncSession().then((ok) => {
|
||||
if (cancelled || ok) return
|
||||
if (useSessionGuardStore.getState().status === "expired") return
|
||||
const returnTo = encodeURIComponent(pathname)
|
||||
router.replace(`/login?returnTo=${returnTo}`)
|
||||
})
|
||||
|
||||
99
components/auth/session-guard.tsx
Normal file
99
components/auth/session-guard.tsx
Normal 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 été déconnecté</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Votre session a expiré ou n'est plus valide. Reconnectez-vous
|
||||
pour continuer à utiliser Ultimail.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction asChild>
|
||||
<a href={loginHref}>Se reconnecter</a>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
43
components/drive/editor-account-button.tsx
Normal file
43
components/drive/editor-account-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
components/drive/office-editor-chrome.tsx
Normal file
93
components/drive/office-editor-chrome.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
110
components/drive/office-editor-inline-title.tsx
Normal file
110
components/drive/office-editor-inline-title.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,123 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
|
||||
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
|
||||
import { useDriveMutations, useDriveShares } from "@/lib/api/hooks/use-drive-queries"
|
||||
import { displayFileBaseName } from "@/lib/drive/display-file-name"
|
||||
import { resolveRenameName } from "@/lib/drive/drive-default-name"
|
||||
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
|
||||
import { resolveDriveEditReturnTo } from "@/lib/drive/drive-url"
|
||||
import { buildDriveEditHref, resolveDriveEditReturnTo } from "@/lib/drive/drive-url"
|
||||
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
|
||||
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
||||
|
||||
type DocEditorInstance = { destroyEditor: () => void }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DocsAPI?: {
|
||||
DocEditor: new (id: string, config: Record<string, unknown>) => DocEditorInstance
|
||||
}
|
||||
DocEditor?: { instances: Record<string, DocEditorInstance | undefined> }
|
||||
}
|
||||
function fileNameFromPath(filePath: string): string {
|
||||
return filePath.split("/").filter(Boolean).pop() ?? filePath
|
||||
}
|
||||
|
||||
let docsApiLoad: Promise<void> | null = null
|
||||
|
||||
function loadDocsApi(documentServerUrl: string): Promise<void> {
|
||||
if (window.DocsAPI) return Promise.resolve()
|
||||
if (docsApiLoad) return docsApiLoad
|
||||
|
||||
const base = documentServerUrl.replace(/\/$/, "") + "/"
|
||||
docsApiLoad = new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script")
|
||||
script.id = "onlyoffice-docs-api"
|
||||
script.src = `${base}web-apps/apps/api/documents/api.js`
|
||||
script.async = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => {
|
||||
docsApiLoad = null
|
||||
reject(new Error(`Error load DocsAPI from ${base}`))
|
||||
}
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
|
||||
return docsApiLoad
|
||||
}
|
||||
|
||||
function destroyDocEditor(id: string) {
|
||||
const inst = window.DocEditor?.instances?.[id]
|
||||
if (inst) {
|
||||
try {
|
||||
inst.destroyEditor()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
delete window.DocEditor!.instances[id]
|
||||
}
|
||||
document.getElementById(id)?.replaceChildren()
|
||||
}
|
||||
|
||||
function OnlyOfficeMount({
|
||||
editorId,
|
||||
documentServerUrl,
|
||||
config,
|
||||
onError,
|
||||
}: {
|
||||
editorId: string
|
||||
documentServerUrl: string
|
||||
config: Record<string, unknown>
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
const configJson = JSON.stringify(config)
|
||||
const onErrorRef = useRef(onError)
|
||||
onErrorRef.current = onError
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const id = editorId
|
||||
const parsed = JSON.parse(configJson) as Record<string, unknown>
|
||||
const editorConfig: Record<string, unknown> = {
|
||||
type: "desktop",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
events: {
|
||||
onDocumentReady: () => {
|
||||
/* loaded */
|
||||
},
|
||||
onError: (event: { data?: { errorCode?: number; errorDescription?: string } }) => {
|
||||
const code = event?.data?.errorCode
|
||||
const desc = event?.data?.errorDescription
|
||||
const msg =
|
||||
desc ||
|
||||
(code != null ? `OnlyOffice error ${code}` : "Erreur OnlyOffice.")
|
||||
onErrorRef.current(msg)
|
||||
},
|
||||
},
|
||||
...parsed,
|
||||
}
|
||||
|
||||
void loadDocsApi(documentServerUrl)
|
||||
.then(() => {
|
||||
if (cancelled) return
|
||||
if (!window.DocsAPI) throw new Error("DocsAPI is not defined")
|
||||
destroyDocEditor(id)
|
||||
if (!window.DocEditor) window.DocEditor = { instances: {} }
|
||||
const editor = new window.DocsAPI.DocEditor(id, editorConfig)
|
||||
window.DocEditor.instances[id] = editor
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
onErrorRef.current(
|
||||
err instanceof Error ? err.message : "Impossible de charger OnlyOffice.",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
destroyDocEditor(id)
|
||||
}
|
||||
}, [editorId, documentServerUrl, configJson])
|
||||
|
||||
return <div id={editorId} className="h-full w-full min-h-0" />
|
||||
function renameTargetPath(filePath: string, newName: string): string {
|
||||
const parent = filePath.replace(/\/[^/]+$/, "") || "/"
|
||||
const base = parent === "/" ? "" : parent
|
||||
return `${base}/${newName}`.replace(/\/+/g, "/") || `/${newName}`
|
||||
}
|
||||
|
||||
export function OfficeEditor({
|
||||
@ -127,18 +33,34 @@ export function OfficeEditor({
|
||||
filePath: string
|
||||
returnTo?: string | null
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const instanceSeq = useRef(0)
|
||||
const setSharePath = useDriveUIStore((s) => s.setSharePath)
|
||||
const [config, setConfig] = useState<Record<string, unknown> | null>(null)
|
||||
const [serverUrl, setServerUrl] = useState("")
|
||||
const [editorId, setEditorId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [displayPath, setDisplayPath] = useState(filePath)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayPath(filePath)
|
||||
}, [filePath])
|
||||
|
||||
const fileName = fileNameFromPath(displayPath)
|
||||
const title = displayFileBaseName(fileName)
|
||||
useDriveDocumentTitle(title)
|
||||
|
||||
const backHref = useMemo(
|
||||
() =>
|
||||
resolveDriveEditReturnTo(returnTo, filePath, (folderPath) =>
|
||||
resolveDriveEditReturnTo(returnTo, displayPath, (folderPath) =>
|
||||
driveFolderHref("files", folderPath)
|
||||
),
|
||||
[returnTo, filePath]
|
||||
[returnTo, displayPath]
|
||||
)
|
||||
|
||||
const { data: sharesData } = useDriveShares(displayPath, Boolean(displayPath))
|
||||
const { rename } = useDriveMutations()
|
||||
|
||||
const handleEditorError = useCallback((message: string) => {
|
||||
setError(message)
|
||||
}, [])
|
||||
@ -155,7 +77,7 @@ export function OfficeEditor({
|
||||
const res = await apiClient.post<{
|
||||
config: Record<string, unknown>
|
||||
serverUrl: string
|
||||
}>("/office/session", { path: filePath, mode: "edit" })
|
||||
}>("/office/session", { path: displayPath, mode: "edit" })
|
||||
if (cancelled) return
|
||||
instanceSeq.current += 1
|
||||
setConfig(res.config)
|
||||
@ -169,7 +91,26 @@ export function OfficeEditor({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [filePath])
|
||||
}, [displayPath])
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (input: string) => {
|
||||
const newName = resolveRenameName(
|
||||
{ name: fileName, type: "file" },
|
||||
input
|
||||
)
|
||||
if (displayFileBaseName(fileName) === input.trim()) return
|
||||
await rename.mutateAsync({ path: displayPath, new_name: newName })
|
||||
const nextPath = renameTargetPath(displayPath, newName)
|
||||
setDisplayPath(nextPath)
|
||||
router.replace(buildDriveEditHref(nextPath, returnTo ?? undefined))
|
||||
},
|
||||
[displayPath, fileName, rename, returnTo, router]
|
||||
)
|
||||
|
||||
const openShareDialog = useCallback(() => {
|
||||
setSharePath(displayPath, "file")
|
||||
}, [displayPath, setSharePath])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@ -193,15 +134,16 @@ export function OfficeEditor({
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Drive
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="truncate text-sm font-medium">{filePath.split("/").pop()}</span>
|
||||
</div>
|
||||
<OfficeEditorChrome
|
||||
backHref={backHref}
|
||||
backLabel="Drive"
|
||||
title={title}
|
||||
onRename={handleRename}
|
||||
shares={sharesData?.shares ?? []}
|
||||
onShareClick={openShareDialog}
|
||||
showShare
|
||||
showAccount
|
||||
/>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<OnlyOfficeMount
|
||||
editorId={editorId}
|
||||
|
||||
117
components/drive/onlyoffice-mount.tsx
Normal file
117
components/drive/onlyoffice-mount.tsx
Normal 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" />
|
||||
}
|
||||
@ -4,109 +4,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { OnlyOfficeMount } from "@/components/drive/onlyoffice-mount"
|
||||
import { OfficeEditorChrome } from "@/components/drive/office-editor-chrome"
|
||||
import { displayFileBaseName } from "@/lib/drive/display-file-name"
|
||||
import { resolvePublicShareEditReturnTo } from "@/lib/drive/public-share-url"
|
||||
import { useDriveDocumentTitle } from "@/lib/drive/use-drive-document-title"
|
||||
|
||||
type DocEditorInstance = { destroyEditor: () => void }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DocsAPI?: {
|
||||
DocEditor: new (id: string, config: Record<string, unknown>) => DocEditorInstance
|
||||
}
|
||||
DocEditor?: { instances: Record<string, DocEditorInstance | undefined> }
|
||||
}
|
||||
}
|
||||
|
||||
let docsApiLoad: Promise<void> | null = null
|
||||
|
||||
function loadDocsApi(documentServerUrl: string): Promise<void> {
|
||||
if (window.DocsAPI) return Promise.resolve()
|
||||
if (docsApiLoad) return docsApiLoad
|
||||
const base = documentServerUrl.replace(/\/$/, "") + "/"
|
||||
docsApiLoad = new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script")
|
||||
script.id = "onlyoffice-docs-api-public"
|
||||
script.src = `${base}web-apps/apps/api/documents/api.js`
|
||||
script.async = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => {
|
||||
docsApiLoad = null
|
||||
reject(new Error(`Error load DocsAPI from ${base}`))
|
||||
}
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
return docsApiLoad
|
||||
}
|
||||
|
||||
function destroyDocEditor(id: string) {
|
||||
const inst = window.DocEditor?.instances?.[id]
|
||||
if (inst) {
|
||||
try {
|
||||
inst.destroyEditor()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
document.getElementById(id)?.replaceChildren()
|
||||
}
|
||||
|
||||
function OnlyOfficeMount({
|
||||
editorId,
|
||||
documentServerUrl,
|
||||
config,
|
||||
onError,
|
||||
}: {
|
||||
editorId: string
|
||||
documentServerUrl: string
|
||||
config: Record<string, unknown>
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
const configJson = JSON.stringify(config)
|
||||
const onErrorRef = useRef(onError)
|
||||
onErrorRef.current = onError
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const id = editorId
|
||||
const parsed = JSON.parse(configJson) as Record<string, unknown>
|
||||
const editorConfig: Record<string, unknown> = {
|
||||
type: "desktop",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
events: {
|
||||
onError: (event: { data?: { errorDescription?: string; errorCode?: number } }) => {
|
||||
const msg =
|
||||
event?.data?.errorDescription ||
|
||||
(event?.data?.errorCode != null
|
||||
? `OnlyOffice error ${event.data.errorCode}`
|
||||
: "Erreur OnlyOffice.")
|
||||
onErrorRef.current(msg)
|
||||
},
|
||||
},
|
||||
...parsed,
|
||||
}
|
||||
void loadDocsApi(documentServerUrl)
|
||||
.then(() => {
|
||||
if (cancelled || !window.DocsAPI) return
|
||||
destroyDocEditor(id)
|
||||
if (!window.DocEditor) window.DocEditor = { instances: {} }
|
||||
const editor = new window.DocsAPI.DocEditor(id, editorConfig)
|
||||
window.DocEditor.instances[id] = editor
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
onErrorRef.current(
|
||||
err instanceof Error ? err.message : "Impossible de charger OnlyOffice."
|
||||
)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
destroyDocEditor(id)
|
||||
}
|
||||
}, [editorId, documentServerUrl, configJson])
|
||||
|
||||
return <div id={editorId} className="h-full w-full min-h-0" />
|
||||
function fileNameFromPath(filePath: string, fallback?: string): string {
|
||||
const base = filePath.split("/").filter(Boolean).pop()
|
||||
return base || fallback || filePath
|
||||
}
|
||||
|
||||
export function PublicOfficeEditor({
|
||||
@ -115,12 +21,14 @@ export function PublicOfficeEditor({
|
||||
password,
|
||||
returnTo,
|
||||
mode = "edit",
|
||||
fileDisplayName,
|
||||
}: {
|
||||
token: string
|
||||
filePath: string
|
||||
password?: string
|
||||
returnTo?: string | null
|
||||
mode?: "edit" | "view"
|
||||
fileDisplayName?: string
|
||||
}) {
|
||||
const instanceSeq = useRef(0)
|
||||
const guestId = useRef(
|
||||
@ -132,6 +40,12 @@ export function PublicOfficeEditor({
|
||||
const [serverUrl, setServerUrl] = useState("")
|
||||
const [editorId, setEditorId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [resolvedMode, setResolvedMode] = useState<"edit" | "view">(mode)
|
||||
|
||||
const fileName = fileDisplayName || fileNameFromPath(filePath)
|
||||
const title = displayFileBaseName(fileName)
|
||||
useDriveDocumentTitle(title)
|
||||
|
||||
const backHref = useMemo(
|
||||
() => resolvePublicShareEditReturnTo(token, returnTo, filePath),
|
||||
[token, returnTo, filePath]
|
||||
@ -143,6 +57,7 @@ export function PublicOfficeEditor({
|
||||
setServerUrl("")
|
||||
setEditorId(null)
|
||||
setError(null)
|
||||
setResolvedMode(mode)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
@ -163,12 +78,21 @@ export function PublicOfficeEditor({
|
||||
const data = (await res.json()) as {
|
||||
config: Record<string, unknown>
|
||||
serverUrl: string
|
||||
mode?: "edit" | "view"
|
||||
}
|
||||
if (cancelled) return
|
||||
instanceSeq.current += 1
|
||||
setConfig(data.config)
|
||||
setServerUrl(data.serverUrl || process.env.NEXT_PUBLIC_ONLYOFFICE_URL || "")
|
||||
setEditorId(`ultidrive-public-editor-${instanceSeq.current}`)
|
||||
if (data.mode === "edit" || data.mode === "view") {
|
||||
setResolvedMode(data.mode)
|
||||
} else {
|
||||
const editorConfig = data.config?.editorConfig as { mode?: string } | undefined
|
||||
if (editorConfig?.mode === "edit" || editorConfig?.mode === "view") {
|
||||
setResolvedMode(editorConfig.mode)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError("Impossible de charger l’éditeur.")
|
||||
}
|
||||
@ -203,21 +127,25 @@ export function PublicOfficeEditor({
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-3 ultidrive-editor-chrome">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Partage
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="truncate text-sm font-medium">{filePath.split("/").pop()}</span>
|
||||
</div>
|
||||
<OfficeEditorChrome
|
||||
backHref={backHref}
|
||||
backLabel="Partage"
|
||||
title={title}
|
||||
trailing={
|
||||
resolvedMode === "view" ? (
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||
Lecture seule
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<OnlyOfficeMount
|
||||
editorId={editorId}
|
||||
documentServerUrl={serverUrl.replace(/\/$/, "")}
|
||||
config={config}
|
||||
onError={handleEditorError}
|
||||
scriptId="onlyoffice-docs-api-public"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import { ChevronRight, Download, FolderOpen, Loader2, Lock } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@ -20,6 +21,11 @@ import type { DriveFileInfo } from "@/lib/api/types"
|
||||
import { drivePreviewKind, isSvgFile } from "@/lib/drive/drive-preview"
|
||||
import { SvgPreviewViewer } from "@/components/drive/svg-preview-viewer"
|
||||
import { PUBLIC_SHARE_INSET_X } from "@/lib/drive/drive-chrome-classes"
|
||||
import { shouldOpenInOnlyOffice } from "@/lib/drive/drive-preview"
|
||||
import {
|
||||
sharePermCanEdit,
|
||||
} from "@/lib/drive/drive-share-permissions"
|
||||
import { buildPublicShareEditHref } from "@/lib/drive/public-share-url"
|
||||
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
||||
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -206,10 +212,30 @@ export function PublicShareViewPanel({
|
||||
data: PublicShareView
|
||||
password?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const file = data.item_type === "file" ? data.file : null
|
||||
const files = data.item_type === "folder" ? (data.files ?? []) : []
|
||||
const rootShareName = usePublicShareRootName(token, path, data.name)
|
||||
const sharedByLabel = publicShareOwnerLabel(data)
|
||||
const permissions = data.permissions ?? 1
|
||||
const canEdit = sharePermCanEdit(permissions)
|
||||
|
||||
useEffect(() => {
|
||||
if (!file || !shouldOpenInOnlyOffice(file)) return
|
||||
const returnTo =
|
||||
typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined
|
||||
router.replace(
|
||||
buildPublicShareEditHref(
|
||||
token,
|
||||
file.path,
|
||||
returnTo,
|
||||
canEdit ? "edit" : "view",
|
||||
file.name
|
||||
)
|
||||
)
|
||||
}, [canEdit, file, router, token])
|
||||
|
||||
const downloadCurrent = () => {
|
||||
if (!file) return
|
||||
@ -223,6 +249,14 @@ export function PublicShareViewPanel({
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
if (file && shouldOpenInOnlyOffice(file)) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@ -246,6 +246,18 @@ export function EmailListBody({
|
||||
{listEmails.map((email) => (
|
||||
<EmailListRow key={email.id} email={email} {...rowPropsBase} />
|
||||
))}
|
||||
{data.scrollInfiniteList && data.hasMoreInfinite ? (
|
||||
<div
|
||||
ref={data.loadMoreSentinelRef}
|
||||
className="h-px w-full shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{data.isFetchingNextInfinitePage ? (
|
||||
<div className="flex justify-center py-3 text-xs text-muted-foreground">
|
||||
Chargement…
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -107,6 +107,7 @@ export function EmailListLayout({
|
||||
listPageSize: data.listPageSize,
|
||||
paginationRangeStart: data.paginationRangeStart,
|
||||
paginationRangeEnd: data.paginationRangeEnd,
|
||||
infiniteScroll: data.infiniteScroll,
|
||||
onListPageSizeChange: data.handleListPageSizeChange,
|
||||
openMailIndex: reading.openMailIndex,
|
||||
goListPrevPage: reading.goListPrevPage,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Icon } from "@iconify/react"
|
||||
import {
|
||||
Archive,
|
||||
@ -129,6 +130,7 @@ export type EmailListToolbarProps = {
|
||||
listPageSize: number
|
||||
paginationRangeStart: number
|
||||
paginationRangeEnd: number
|
||||
infiniteScroll: boolean
|
||||
onListPageSizeChange: (size: ListPageSize) => void
|
||||
openMailIndex: number
|
||||
goListPrevPage: () => void
|
||||
@ -216,6 +218,7 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
|
||||
listPageSize,
|
||||
paginationRangeStart,
|
||||
paginationRangeEnd,
|
||||
infiniteScroll,
|
||||
onListPageSizeChange,
|
||||
openMailIndex,
|
||||
goListPrevPage,
|
||||
@ -249,6 +252,9 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
|
||||
part = "all",
|
||||
} = props
|
||||
|
||||
const [countsMounted, setCountsMounted] = useState(false)
|
||||
useEffect(() => setCountsMounted(true), [])
|
||||
|
||||
const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS
|
||||
|
||||
const openMailToolbar = (showBack: boolean) => (
|
||||
@ -611,8 +617,16 @@ const mailPaginationControls = (mode: "list" | "view") => {
|
||||
{mobileFolderLabel}
|
||||
</h1>
|
||||
<p className="text-xs text-[#5f6368] leading-snug">
|
||||
{displayListEmails.length} message{displayListEmails.length !== 1 ? "s" : ""}
|
||||
{mobileUnreadCount > 0 && ` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`}
|
||||
{countsMounted ? (
|
||||
<>
|
||||
{displayListEmails.length} message
|
||||
{displayListEmails.length !== 1 ? "s" : ""}
|
||||
{mobileUnreadCount > 0 &&
|
||||
` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`}
|
||||
</>
|
||||
) : (
|
||||
"…"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@ -1084,7 +1098,7 @@ const mailPaginationControls = (mode: "list" | "view") => {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{listToolbarMode ? mailPaginationControls("list") : null}
|
||||
{listToolbarMode && !infiniteScroll ? mailPaginationControls("list") : null}
|
||||
{!splitView && !listToolbarMode ? mailPaginationControls("view") : null}
|
||||
</div>
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
import { useSearchParams, useRouter } from "next/navigation"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { buildLabelTextToNavColorClass } from "@/components/gmail/mail-label-pills"
|
||||
import { useMessages, useMailSearch } from "@/lib/api/hooks/use-mail-queries"
|
||||
import { useMessages, useMailSearch, fetchMessagesPage, messagesQueryKey } from "@/lib/api/hooks/use-mail-queries"
|
||||
import {
|
||||
useUpdateFlags,
|
||||
useUpdateLabels,
|
||||
@ -65,6 +65,7 @@ import {
|
||||
buildInboxTabBarItems,
|
||||
} from "@/components/gmail/email-list/email-list-helpers"
|
||||
import { useMailListPullRefresh } from "@/hooks/use-mail-list-pull-refresh"
|
||||
import { useMailListInfiniteScroll } from "@/hooks/use-mail-list-infinite-scroll"
|
||||
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
|
||||
import { resolveListRowAttachments } from "@/lib/attachment-display"
|
||||
import { useListMessageAttachments } from "@/lib/api/hooks/use-list-message-attachments"
|
||||
@ -183,6 +184,9 @@ export function useEmailListData({
|
||||
const queryClient = useQueryClient()
|
||||
const listPageSize = useMailSettingsStore((s) => s.listPageSize)
|
||||
const setListPageSize = useMailSettingsStore((s) => s.setListPageSize)
|
||||
const infiniteScroll = useMailSettingsStore((s) => s.infiniteScroll)
|
||||
const isXs = useIsXs()
|
||||
const touchNav = useTouchNav()
|
||||
|
||||
const effectiveApiFolder = useMemo(() => {
|
||||
if (isSearchMode) return "__search__"
|
||||
@ -196,12 +200,21 @@ export function useEmailListData({
|
||||
return searchParamsToMessageSearchFilter(searchParams, accountId)
|
||||
}, [isSearchMode, searchParams, accountId])
|
||||
|
||||
const messagesQuery = useMessages(
|
||||
const scrollInfiniteList = (isXs || infiniteScroll) && !isViewMode
|
||||
const usesApiInfiniteScroll =
|
||||
scrollInfiniteList &&
|
||||
effectiveApiFolder !== "__local__" &&
|
||||
!isSearchMode
|
||||
const messagesApiFolder =
|
||||
effectiveApiFolder === "__search__" || effectiveApiFolder === "__local__"
|
||||
? "inbox"
|
||||
: effectiveApiFolder,
|
||||
: effectiveApiFolder
|
||||
const messagesQueryPage = usesApiInfiniteScroll ? 1 : listPage
|
||||
|
||||
const messagesQuery = useMessages(
|
||||
messagesApiFolder,
|
||||
accountId,
|
||||
listPage,
|
||||
messagesQueryPage,
|
||||
listPageSize
|
||||
)
|
||||
|
||||
@ -451,8 +464,12 @@ export function useEmailListData({
|
||||
const [labelPickerQuery, setLabelPickerQuery] = useState("")
|
||||
const recentMoveTargets = useMailStore((s) => s.recentMoveTargets)
|
||||
const [mobileVisibleCount, setMobileVisibleCount] = useState(LIST_PAGE_SIZE)
|
||||
const isXs = useIsXs()
|
||||
const touchNav = useTouchNav()
|
||||
const [accumulatedApiEmails, setAccumulatedApiEmails] = useState<Email[]>([])
|
||||
const [loadedApiPage, setLoadedApiPage] = useState(1)
|
||||
const [isFetchingNextInfinitePage, setIsFetchingNextInfinitePage] =
|
||||
useState(false)
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null)
|
||||
const infiniteListContextRef = useRef("")
|
||||
|
||||
const seenEmailIdsRaw = useMailStore((s) => s.seenEmailIds)
|
||||
const seenEmailIds = useMemo(() => new Set(seenEmailIdsRaw), [seenEmailIdsRaw])
|
||||
@ -598,6 +615,158 @@ export function useEmailListData({
|
||||
[listPageSize, setListPageSize, onMailRouteNavigate]
|
||||
)
|
||||
|
||||
const processEmailsForDisplay = useCallback(
|
||||
(emails: Email[]) => {
|
||||
let rows =
|
||||
selectedFolder !== "inbox"
|
||||
? emails
|
||||
: emails.filter((e) =>
|
||||
emailMatchesInboxTab(e, inboxTab, folderFilterCtx, navMaps)
|
||||
)
|
||||
if (conversationMode) {
|
||||
rows = rows.filter(isThreadHeadMessage)
|
||||
}
|
||||
const byId = new Map(emails.map((e) => [e.id, e]))
|
||||
return sortEmailsForInbox(
|
||||
rows,
|
||||
inboxSort,
|
||||
{
|
||||
readOverrides: {},
|
||||
starredIds: [],
|
||||
importantIds: [],
|
||||
},
|
||||
{ conversationMode, byId }
|
||||
)
|
||||
},
|
||||
[
|
||||
selectedFolder,
|
||||
inboxTab,
|
||||
folderFilterCtx,
|
||||
navMaps,
|
||||
conversationMode,
|
||||
inboxSort,
|
||||
]
|
||||
)
|
||||
|
||||
const infiniteListContextKey = `${selectedFolder}:${inboxTab}:${accountId ?? ""}:${messagesApiFolder}`
|
||||
|
||||
useEffect(() => {
|
||||
if (!usesApiInfiniteScroll) {
|
||||
setAccumulatedApiEmails([])
|
||||
setLoadedApiPage(1)
|
||||
infiniteListContextRef.current = ""
|
||||
return
|
||||
}
|
||||
|
||||
if (infiniteListContextRef.current !== infiniteListContextKey) {
|
||||
infiniteListContextRef.current = infiniteListContextKey
|
||||
setAccumulatedApiEmails(displayListEmails)
|
||||
setLoadedApiPage(1)
|
||||
setMobileVisibleCount(LIST_PAGE_SIZE)
|
||||
listViewportRef.current?.scrollTo(0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if (loadedApiPage === 1) {
|
||||
setAccumulatedApiEmails(displayListEmails)
|
||||
}
|
||||
}, [
|
||||
usesApiInfiniteScroll,
|
||||
infiniteListContextKey,
|
||||
displayListEmails,
|
||||
loadedApiPage,
|
||||
listViewportRef,
|
||||
])
|
||||
|
||||
const fetchNextApiPage = useCallback(async () => {
|
||||
if (!usesApiInfiniteScroll || isFetchingNextInfinitePage) return
|
||||
if (loadedApiPage >= totalPages) return
|
||||
|
||||
const nextPage = loadedApiPage + 1
|
||||
setIsFetchingNextInfinitePage(true)
|
||||
try {
|
||||
const result = await queryClient.fetchQuery({
|
||||
queryKey: messagesQueryKey(
|
||||
messagesApiFolder,
|
||||
accountId,
|
||||
nextPage,
|
||||
listPageSize
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchMessagesPage(
|
||||
messagesApiFolder,
|
||||
accountId,
|
||||
nextPage,
|
||||
listPageSize
|
||||
),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const processed = processEmailsForDisplay(
|
||||
result.data.map(apiMessageToEmail)
|
||||
)
|
||||
setAccumulatedApiEmails((prev) => {
|
||||
const ids = new Set(prev.map((e) => e.id))
|
||||
const appended = processed.filter((e) => !ids.has(e.id))
|
||||
if (appended.length === 0) return prev
|
||||
return [...prev, ...appended]
|
||||
})
|
||||
setLoadedApiPage(nextPage)
|
||||
|
||||
if (nextPage < totalPages) {
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: messagesQueryKey(
|
||||
messagesApiFolder,
|
||||
accountId,
|
||||
nextPage + 1,
|
||||
listPageSize
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchMessagesPage(
|
||||
messagesApiFolder,
|
||||
accountId,
|
||||
nextPage + 1,
|
||||
listPageSize
|
||||
),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsFetchingNextInfinitePage(false)
|
||||
}
|
||||
}, [
|
||||
usesApiInfiniteScroll,
|
||||
isFetchingNextInfinitePage,
|
||||
loadedApiPage,
|
||||
totalPages,
|
||||
queryClient,
|
||||
messagesApiFolder,
|
||||
accountId,
|
||||
listPageSize,
|
||||
processEmailsForDisplay,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!usesApiInfiniteScroll || loadedApiPage !== 1 || totalPages <= 1) return
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: messagesQueryKey(messagesApiFolder, accountId, 2, listPageSize),
|
||||
queryFn: () =>
|
||||
fetchMessagesPage(messagesApiFolder, accountId, 2, listPageSize),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}, [
|
||||
usesApiInfiniteScroll,
|
||||
loadedApiPage,
|
||||
totalPages,
|
||||
queryClient,
|
||||
messagesApiFolder,
|
||||
accountId,
|
||||
listPageSize,
|
||||
])
|
||||
|
||||
const infiniteScrollSourceEmails = usesApiInfiniteScroll
|
||||
? accumulatedApiEmails
|
||||
: displayListEmails
|
||||
|
||||
const pagedEmails = useMemo(() => {
|
||||
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
|
||||
return displayListEmails
|
||||
@ -607,11 +776,74 @@ export function useEmailListData({
|
||||
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode, listPageSize])
|
||||
|
||||
const listEmails = useMemo(() => {
|
||||
if (isXs && !isViewMode) {
|
||||
return displayListEmails.slice(0, mobileVisibleCount)
|
||||
if (!scrollInfiniteList) return pagedEmails
|
||||
if (usesApiInfiniteScroll) {
|
||||
if (isXs) {
|
||||
return infiniteScrollSourceEmails.slice(0, mobileVisibleCount)
|
||||
}
|
||||
return pagedEmails
|
||||
}, [isXs, isViewMode, displayListEmails, mobileVisibleCount, pagedEmails])
|
||||
return infiniteScrollSourceEmails
|
||||
}
|
||||
return displayListEmails.slice(0, mobileVisibleCount)
|
||||
}, [
|
||||
scrollInfiniteList,
|
||||
usesApiInfiniteScroll,
|
||||
isXs,
|
||||
infiniteScrollSourceEmails,
|
||||
mobileVisibleCount,
|
||||
displayListEmails,
|
||||
pagedEmails,
|
||||
])
|
||||
|
||||
const hasMoreInfinite = scrollInfiniteList
|
||||
? usesApiInfiniteScroll
|
||||
? isXs
|
||||
? mobileVisibleCount < infiniteScrollSourceEmails.length ||
|
||||
loadedApiPage < totalPages
|
||||
: loadedApiPage < totalPages
|
||||
: mobileVisibleCount < displayListEmails.length
|
||||
: false
|
||||
|
||||
const mobileVisibleCountRef = useRef(mobileVisibleCount)
|
||||
mobileVisibleCountRef.current = mobileVisibleCount
|
||||
|
||||
const loadMoreInfinite = useCallback(() => {
|
||||
if (!scrollInfiniteList) return
|
||||
|
||||
if (usesApiInfiniteScroll) {
|
||||
const sourceLength = infiniteScrollSourceEmails.length
|
||||
if (
|
||||
isXs &&
|
||||
mobileVisibleCountRef.current < sourceLength
|
||||
) {
|
||||
setMobileVisibleCount((prev) =>
|
||||
Math.min(prev + LIST_PAGE_SIZE, sourceLength)
|
||||
)
|
||||
return
|
||||
}
|
||||
void fetchNextApiPage()
|
||||
return
|
||||
}
|
||||
|
||||
setMobileVisibleCount((prev) =>
|
||||
Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length)
|
||||
)
|
||||
}, [
|
||||
scrollInfiniteList,
|
||||
usesApiInfiniteScroll,
|
||||
isXs,
|
||||
infiniteScrollSourceEmails.length,
|
||||
fetchNextApiPage,
|
||||
displayListEmails.length,
|
||||
])
|
||||
|
||||
useMailListInfiniteScroll({
|
||||
enabled: scrollInfiniteList,
|
||||
sentinelRef: loadMoreSentinelRef,
|
||||
scrollRootRef: listViewportRef,
|
||||
hasMore: hasMoreInfinite,
|
||||
isLoadingMore: isFetchingNextInfinitePage,
|
||||
onLoadMore: loadMoreInfinite,
|
||||
})
|
||||
|
||||
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
|
||||
|
||||
@ -674,42 +906,34 @@ export function useEmailListData({
|
||||
inboxCategoryTabIconsCatalog,
|
||||
])
|
||||
|
||||
const prevInfiniteScrollRef = useRef(infiniteScroll)
|
||||
useEffect(() => {
|
||||
if (isXs) return
|
||||
const turnedOn = infiniteScroll && !prevInfiniteScrollRef.current
|
||||
prevInfiniteScrollRef.current = infiniteScroll
|
||||
if (!turnedOn || isXs) return
|
||||
setAccumulatedApiEmails([])
|
||||
setLoadedApiPage(1)
|
||||
setMobileVisibleCount(LIST_PAGE_SIZE)
|
||||
if (listPage !== 1) onMailRouteNavigate({ page: 1 })
|
||||
}, [infiniteScroll, isXs, listPage, onMailRouteNavigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (isXs || infiniteScroll) return
|
||||
if (listPage > totalPages) {
|
||||
onMailRouteNavigate({ page: totalPages })
|
||||
}
|
||||
}, [isXs, listPage, totalPages, onMailRouteNavigate])
|
||||
}, [isXs, infiniteScroll, listPage, totalPages, onMailRouteNavigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (isXs && !isViewMode) return
|
||||
if (scrollInfiniteList) return
|
||||
listViewportRef.current?.scrollTo(0, 0)
|
||||
}, [listPage, selectedFolder, inboxTab, isXs, isViewMode, listViewportRef])
|
||||
}, [listPage, selectedFolder, inboxTab, scrollInfiniteList, listViewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isXs) return
|
||||
if (!scrollInfiniteList || usesApiInfiniteScroll) return
|
||||
setMobileVisibleCount(LIST_PAGE_SIZE)
|
||||
listViewportRef.current?.scrollTo(0, 0)
|
||||
}, [selectedFolder, inboxTab, isXs, listViewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
const root = listViewportRef.current
|
||||
if (!root || !isXs || isViewMode) return
|
||||
|
||||
const onScroll = () => {
|
||||
if (mobileVisibleCount >= displayListEmails.length) return
|
||||
const nearBottom =
|
||||
root.scrollTop + root.clientHeight >= root.scrollHeight - 120
|
||||
if (nearBottom) {
|
||||
setMobileVisibleCount((prev) =>
|
||||
Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
root.addEventListener("scroll", onScroll, { passive: true })
|
||||
return () => root.removeEventListener("scroll", onScroll)
|
||||
}, [isXs, isViewMode, mobileVisibleCount, displayListEmails.length, listViewportRef])
|
||||
}, [selectedFolder, inboxTab, scrollInfiniteList, usesApiInfiniteScroll, listViewportRef])
|
||||
|
||||
const moveTargets = useMoveTargets({
|
||||
folderTree: sidebarNav.folderTree,
|
||||
@ -855,6 +1079,11 @@ export function useEmailListData({
|
||||
notSpamEmailIds,
|
||||
recentMoveTargets,
|
||||
mobileVisibleCount,
|
||||
infiniteScroll,
|
||||
scrollInfiniteList,
|
||||
hasMoreInfinite,
|
||||
loadMoreSentinelRef,
|
||||
isFetchingNextInfinitePage,
|
||||
isXs,
|
||||
touchNav,
|
||||
seenEmailIds,
|
||||
@ -880,6 +1109,10 @@ export function useEmailListData({
|
||||
listEmails,
|
||||
listMailIndex,
|
||||
listRowExtras,
|
||||
scrollInfiniteList,
|
||||
hasMoreInfinite,
|
||||
loadMoreSentinelRef,
|
||||
isFetchingNextInfinitePage,
|
||||
moveTargets,
|
||||
folderUnreadCounts,
|
||||
unseenInTabById,
|
||||
|
||||
@ -510,11 +510,24 @@ export function useEmailListReading(
|
||||
},
|
||||
{ root, threshold: 0.12, rootMargin: "0px" }
|
||||
)
|
||||
|
||||
const observeNewRows = () => {
|
||||
root.querySelectorAll<HTMLElement>("[data-email-row-id]").forEach((el) => {
|
||||
if (el.dataset.seenObserved === "1") return
|
||||
el.dataset.seenObserved = "1"
|
||||
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(() => {
|
||||
if (!isViewMode && !showSplitReadingPane) return
|
||||
|
||||
@ -197,6 +197,8 @@ export function MailSettingsFields({
|
||||
const setReadingPane = useMailSettingsStore((s) => s.setReadingPane)
|
||||
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
|
||||
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
|
||||
const infiniteScroll = useMailSettingsStore((s) => s.infiniteScroll)
|
||||
const setInfiniteScroll = useMailSettingsStore((s) => s.setInfiniteScroll)
|
||||
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
|
||||
|
||||
const isPage = variant === "page"
|
||||
@ -269,7 +271,14 @@ export function MailSettingsFields({
|
||||
}
|
||||
}
|
||||
/>
|
||||
<span className="max-w-full truncate text-[10px] text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-full truncate text-[10px]",
|
||||
activeBackgroundId === preset.id
|
||||
? "font-bold text-foreground dark:text-white"
|
||||
: "text-muted-foreground dark:text-mail-text"
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</span>
|
||||
</button>
|
||||
@ -308,10 +317,20 @@ export function MailSettingsFields({
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Affichage" variant={variant}>
|
||||
<QuickSettingsCheckbox
|
||||
label="Scroll infini"
|
||||
checked={infiniteScroll}
|
||||
onChange={setInfiniteScroll}
|
||||
helpLabel="Faire défiler la liste au lieu d'utiliser la pagination par pages (bureau)"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<section
|
||||
className={cn(
|
||||
"border-b border-border px-4 py-4",
|
||||
isPage && cn("border-b border-border", MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS)
|
||||
"px-4 py-4",
|
||||
!isPage && "border-b border-border",
|
||||
isPage && MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS
|
||||
)}
|
||||
>
|
||||
<SectionHeader title="Fils de discussion" />
|
||||
|
||||
@ -39,7 +39,9 @@ export function QuickSettingsOption({
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 text-sm",
|
||||
checked ? "text-[#1a73e8]" : "text-foreground"
|
||||
checked
|
||||
? "font-bold text-[#1a73e8] dark:text-white"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
||||
@ -30,7 +30,7 @@ export function QuickSettingsPanel() {
|
||||
className="fixed right-0 top-0 z-[61] flex h-full w-full max-w-[360px] flex-col border-l border-border bg-mail-surface shadow-lg"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between gap-2 px-4 pt-5 pb-3">
|
||||
<h1 className="text-base font-normal text-foreground">
|
||||
<h1 className="text-base font-normal text-foreground dark:text-white">
|
||||
Configuration rapide
|
||||
</h1>
|
||||
<Button
|
||||
@ -49,7 +49,7 @@ export function QuickSettingsPanel() {
|
||||
<div className="px-4 pb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50"
|
||||
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50 dark:border-[#9aa0a6] dark:text-white dark:hover:bg-[#3c4043]/50"
|
||||
asChild
|
||||
>
|
||||
<Link href="/mail/settings" onClick={() => setOpen(false)}>
|
||||
|
||||
@ -58,7 +58,14 @@ export function ThemeSettingsDialog() {
|
||||
}
|
||||
}
|
||||
/>
|
||||
<span className="max-w-full truncate text-[10px] text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-full truncate text-[10px]",
|
||||
activeBackgroundId === preset.id
|
||||
? "font-bold text-foreground dark:text-white"
|
||||
: "text-muted-foreground dark:text-mail-text"
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -71,16 +71,16 @@ export function AutomationDomainFilterTab({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-colors",
|
||||
"inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-background text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{domain === "all" ? (
|
||||
<LayoutGrid className="size-3.5 shrink-0 opacity-80" aria-hidden />
|
||||
<LayoutGrid className="size-4 shrink-0 opacity-80" aria-hidden />
|
||||
) : (
|
||||
<AutomationDomainMark domain={domain} className="size-3.5" alt="" />
|
||||
<AutomationDomainMark domain={domain} className="size-4" alt="" />
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
|
||||
@ -60,12 +60,13 @@ export function WebhookEventScopeEditor({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Le webhook part uniquement pour les événements cochés, dans le périmètre défini ci-dessous.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
{DOMAINS.map((domain) => {
|
||||
const options = WEBHOOK_EVENT_OPTIONS.filter((o) => o.domain === domain)
|
||||
return (
|
||||
<AutomationBorderedFieldset
|
||||
key={domain}
|
||||
className="shrink-0"
|
||||
legend={
|
||||
<>
|
||||
<AutomationDomainMark domain={domain} className="size-3.5" alt="" />
|
||||
|
||||
@ -208,7 +208,7 @@ export function WebhooksPanel() {
|
||||
{!editingId ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Modèle de départ</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CREATE_DOMAINS.map((domain) => (
|
||||
<AutomationDomainFilterTab
|
||||
key={domain}
|
||||
|
||||
@ -4,6 +4,7 @@ import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
isMailSettingsLeftAlignedPath,
|
||||
isMailSettingsNavActive,
|
||||
isMailSettingsWideLayoutPath,
|
||||
MAIL_SETTINGS_NAV,
|
||||
@ -107,8 +108,9 @@ export function MailSettingsLayout({ children }: { children: React.ReactNode })
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-3xl",
|
||||
isMailSettingsWideLayoutPath(pathname) && "lg:max-w-6xl"
|
||||
"w-full max-w-3xl",
|
||||
isMailSettingsWideLayoutPath(pathname) && "lg:max-w-6xl",
|
||||
isMailSettingsLeftAlignedPath(pathname) ? "mr-auto" : "mx-auto"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
} from "@/lib/mail-settings/settings-nav"
|
||||
import { DisplaySettingsSection } from "@/components/gmail/settings/sections/display-settings-section"
|
||||
import { AccountsSettingsSection } from "@/components/gmail/settings/sections/accounts-settings-section"
|
||||
import { SignaturesSettingsSection } from "@/components/gmail/settings/sections/signatures-settings-section"
|
||||
import { LabelsFoldersSettingsSection } from "@/components/gmail/settings/sections/labels-folders-settings-section"
|
||||
import { NotificationsSettingsSection } from "@/components/gmail/settings/sections/notifications-settings-section"
|
||||
import { AutomationSettingsSection } from "@/components/gmail/settings/sections/automation-settings-section"
|
||||
@ -14,7 +13,6 @@ import { AutomationSettingsSection } from "@/components/gmail/settings/sections/
|
||||
const SECTIONS: Record<MailSettingsSectionId, React.ComponentType> = {
|
||||
display: DisplaySettingsSection,
|
||||
accounts: AccountsSettingsSection,
|
||||
signatures: SignaturesSettingsSection,
|
||||
labels: LabelsFoldersSettingsSection,
|
||||
notifications: NotificationsSettingsSection,
|
||||
automation: AutomationSettingsSection,
|
||||
|
||||
@ -324,7 +324,7 @@ function NavItemSettingsShell({
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="rounded-lg border border-border bg-card"
|
||||
className="mail-settings-card rounded-lg border border-mail-border bg-mail-surface shadow-sm dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 px-3 py-2"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@ -12,6 +12,13 @@ import {
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -21,6 +28,7 @@ import {
|
||||
} from "@/components/ui/card"
|
||||
import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form"
|
||||
import { EditMailAccountForm } from "@/components/gmail/settings/edit-mail-account-form"
|
||||
import { SignatureLibraryCard } from "@/components/gmail/settings/signature-library-card"
|
||||
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||
import {
|
||||
useCreateMailAccount,
|
||||
@ -34,10 +42,13 @@ import {
|
||||
useUpdateIdentity,
|
||||
useDeleteIdentity,
|
||||
} from "@/lib/api/hooks/use-identity-mutations"
|
||||
import { useMailSignatures } from "@/lib/api/hooks/use-mail-signatures"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import type { ApiMailAccount } from "@/lib/api/types"
|
||||
import type { ApiMailAccount, ApiMailSignature } from "@/lib/api/types"
|
||||
|
||||
const NONE_SIGNATURE = "__none__"
|
||||
|
||||
export function AccountsSettingsSection() {
|
||||
const router = useRouter()
|
||||
@ -45,8 +56,17 @@ export function AccountsSettingsSection() {
|
||||
const oauthStatus = searchParams.get("oauth")
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const { data: accounts = [], isFetching, isError, refetch, isPending } = useMailAccounts()
|
||||
const {
|
||||
data: signatures = [],
|
||||
isFetching: signaturesFetching,
|
||||
isError: signaturesError,
|
||||
refetch: refetchSignatures,
|
||||
isPending: signaturesPending,
|
||||
} = useMailSignatures()
|
||||
const createAccount = useCreateMailAccount()
|
||||
const showInitialLoad = ready && authenticated && isPending && accounts.length === 0
|
||||
const showSignaturesInitialLoad =
|
||||
ready && authenticated && signaturesPending && signatures.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthStatus === "success") {
|
||||
@ -55,11 +75,19 @@ export function AccountsSettingsSection() {
|
||||
}
|
||||
}, [oauthStatus, refetch, router])
|
||||
|
||||
const syncFetching = isFetching || signaturesFetching
|
||||
const syncError = isError || signaturesError
|
||||
|
||||
function handleRetry() {
|
||||
void refetch()
|
||||
void refetchSignatures()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Comptes mail"
|
||||
description="Connexions IMAP/SMTP et identités d'envoi par compte."
|
||||
description="Connexions IMAP/SMTP, identités d'envoi et signatures."
|
||||
/>
|
||||
{oauthStatus === "success" ? (
|
||||
<p className="text-sm text-green-600 dark:text-green-500">
|
||||
@ -72,7 +100,11 @@ export function AccountsSettingsSection() {
|
||||
{searchParams.get("code") ? ` (${searchParams.get("code")})` : ""}.
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
<SettingsSyncBanner
|
||||
isFetching={syncFetching}
|
||||
isError={syncError}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<AddMailAccountForm
|
||||
@ -80,19 +112,32 @@ export function AccountsSettingsSection() {
|
||||
onSubmit={(payload) => createAccount.mutate(payload)}
|
||||
/>
|
||||
|
||||
{showInitialLoad ? null : accounts.length === 0 ? (
|
||||
{!ready || showInitialLoad ? null : accounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucun compte mail configuré. Ajoutez votre adresse e-mail ci-dessus pour commencer.
|
||||
</p>
|
||||
) : (
|
||||
accounts.map((account) => <AccountCard key={account.id} account={account} />)
|
||||
accounts.map((account) => (
|
||||
<AccountCard key={account.id} account={account} signatures={signatures} />
|
||||
))
|
||||
)}
|
||||
|
||||
<SignatureLibraryCard
|
||||
signatures={signatures}
|
||||
showInitialLoad={showSignaturesInitialLoad}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountCard({ account }: { account: ApiMailAccount }) {
|
||||
function AccountCard({
|
||||
account,
|
||||
signatures,
|
||||
}: {
|
||||
account: ApiMailAccount
|
||||
signatures: ApiMailSignature[]
|
||||
}) {
|
||||
const deleteAccount = useDeleteMailAccount()
|
||||
const resanitizeBodies = useResanitizeBodies(account.id)
|
||||
const syncAccount = useSyncMailAccount(account.id)
|
||||
@ -209,6 +254,7 @@ function AccountCard({ account }: { account: ApiMailAccount }) {
|
||||
accountId={account.id}
|
||||
accountEmail={account.email}
|
||||
identities={identities}
|
||||
signatures={signatures}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -219,6 +265,7 @@ function IdentitiesBlock({
|
||||
accountId,
|
||||
accountEmail,
|
||||
identities,
|
||||
signatures,
|
||||
}: {
|
||||
accountId: string
|
||||
accountEmail: string
|
||||
@ -231,6 +278,7 @@ function IdentitiesBlock({
|
||||
default_signature_id?: string
|
||||
reply_to_addrs?: string[]
|
||||
}>
|
||||
signatures: ApiMailSignature[]
|
||||
}) {
|
||||
const createIdentity = useCreateIdentity(accountId)
|
||||
const updateIdentity = useUpdateIdentity(accountId)
|
||||
@ -238,6 +286,14 @@ function IdentitiesBlock({
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newIdentity, setNewIdentity] = useState({ email: accountEmail, name: "" })
|
||||
|
||||
const signatureOptions = useMemo(
|
||||
() => [
|
||||
{ value: NONE_SIGNATURE, label: "Aucune" },
|
||||
...signatures.map((s) => ({ value: s.id, label: s.name })),
|
||||
],
|
||||
[signatures]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAddForm) {
|
||||
setNewIdentity({ email: accountEmail, name: "" })
|
||||
@ -250,6 +306,7 @@ function IdentitiesBlock({
|
||||
email: string
|
||||
name: string
|
||||
is_default: boolean
|
||||
default_signature_id: string
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
@ -258,7 +315,7 @@ function IdentitiesBlock({
|
||||
name: patch.name ?? identity.name,
|
||||
is_default: patch.is_default ?? identity.is_default,
|
||||
signature_html: identity.signature_html ?? "",
|
||||
default_signature_id: identity.default_signature_id ?? "",
|
||||
default_signature_id: patch.default_signature_id ?? identity.default_signature_id ?? "",
|
||||
reply_to_addrs: identity.reply_to_addrs,
|
||||
}
|
||||
}
|
||||
@ -289,10 +346,16 @@ function IdentitiesBlock({
|
||||
<p className="text-xs text-muted-foreground">Aucune identité configurée.</p>
|
||||
) : (
|
||||
<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">
|
||||
<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">
|
||||
<Label className="text-xs">Nom affiché</Label>
|
||||
<Input
|
||||
@ -316,8 +379,33 @@ function IdentitiesBlock({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Signature</Label>
|
||||
<Select
|
||||
value={currentSignature}
|
||||
disabled={updateIdentity.isPending}
|
||||
onValueChange={(value) =>
|
||||
updateIdentity.mutate(
|
||||
identityPayload(identity, {
|
||||
default_signature_id: value === NONE_SIGNATURE ? "" : value,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Signature" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{signatureOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{identity.is_default ? (
|
||||
<p className="text-xs text-muted-foreground sm:col-span-2">
|
||||
<p className="text-xs text-muted-foreground sm:col-span-3">
|
||||
Identité par défaut
|
||||
</p>
|
||||
) : null}
|
||||
@ -333,7 +421,8 @@ function IdentitiesBlock({
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-p
|
||||
import { LLMProvidersPanel } from "@/components/gmail/settings/automation/llm-providers-panel"
|
||||
import { SearchProvidersPanel } from "@/components/gmail/settings/automation/search-providers-panel"
|
||||
import { ApiTokensPanel } from "@/components/gmail/settings/automation/api-tokens-panel"
|
||||
import { MAIL_SETTINGS_TABS_LIST_CLASS } from "@/lib/mail-chrome-classes"
|
||||
|
||||
export function AutomationSettingsSection() {
|
||||
return (
|
||||
@ -16,11 +17,11 @@ export function AutomationSettingsSection() {
|
||||
description="Règles et webhooks pour les événements mail, Drive et contacts — conditions et actions adaptées au déclencheur."
|
||||
/>
|
||||
<Tabs defaultValue="rules">
|
||||
<TabsList className="flex h-auto flex-wrap">
|
||||
<TabsList className={MAIL_SETTINGS_TABS_LIST_CLASS}>
|
||||
<TabsTrigger value="rules">Règles</TabsTrigger>
|
||||
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
|
||||
<TabsTrigger value="llm">Fournisseurs LLM</TabsTrigger>
|
||||
<TabsTrigger value="search">Fournisseurs de recherche</TabsTrigger>
|
||||
<TabsTrigger value="search">Recherche</TabsTrigger>
|
||||
<TabsTrigger value="tokens">Tokens API</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ export function DisplaySettingsSection() {
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Réglages d'affichage"
|
||||
description="Densité, thème, type de boîte de réception et volet de lecture."
|
||||
description="Densité, thème, type de boîte de réception, volet de lecture et défilement de la liste."
|
||||
/>
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
<MailSettingsFields variant="page" />
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
ImapFolderSettingsTree,
|
||||
NavLabelSettingsCard,
|
||||
} from "@/components/gmail/settings/nav-item-settings-card"
|
||||
import { MAIL_SETTINGS_TABS_LIST_CLASS } from "@/lib/mail-chrome-classes"
|
||||
|
||||
function SettingsFormHeading({
|
||||
icon: Icon,
|
||||
@ -96,7 +97,7 @@ export function LabelsFoldersSettingsSection() {
|
||||
description="Mêmes réglages que dans la barre latérale : couleur, affichage dans les listes, arborescence, renommage."
|
||||
/>
|
||||
<Tabs defaultValue="labels">
|
||||
<TabsList>
|
||||
<TabsList className={MAIL_SETTINGS_TABS_LIST_CLASS}>
|
||||
<TabsTrigger value="labels">Libellés</TabsTrigger>
|
||||
<TabsTrigger value="folders-global">Dossiers globaux</TabsTrigger>
|
||||
<TabsTrigger value="folders-account">Dossiers par compte</TabsTrigger>
|
||||
|
||||
@ -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="color:#5f6368">…</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'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'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>
|
||||
)
|
||||
}
|
||||
@ -8,7 +8,7 @@ export function SettingsComingSoon({
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed bg-muted/20 py-4 shadow-none">
|
||||
<Card className="mail-settings-card border-dashed border-mail-border bg-mail-surface py-4 shadow-sm dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]">
|
||||
<CardHeader className="px-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
185
components/gmail/settings/signature-library-card.tsx
Normal file
185
components/gmail/settings/signature-library-card.tsx
Normal 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'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="color:#5f6368">…</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>
|
||||
)
|
||||
}
|
||||
@ -15,7 +15,7 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 cursor-pointer rounded-[4px] border shadow-xs transition-all outline-none hover:border-foreground/60 hover:shadow-sm focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'peer border-[1.5px] border-mail-row-checkbox-border bg-background dark:bg-mail-surface-muted data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 cursor-pointer rounded-[4px] shadow-xs transition-all outline-none hover:border-foreground/60 hover:shadow-sm focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -14,8 +14,13 @@ type UltiMailLogoProps = {
|
||||
/** Icône extraite du master PNG (pas le SVG VTracer, trop « M Gmail » à petite taille). */
|
||||
const HEADER_ICON = "/brand/ultimail-header-icon.png"
|
||||
const STACKED_WORDMARK = "/brand/ultimail-wordmark-stacked.png"
|
||||
/** Fond transparent, picto couleurs d'origine, texte éclairci (pas de filter CSS invert/hue). */
|
||||
const STACKED_WORDMARK_DARK = "/brand/ultimail-wordmark-stacked-dark.png"
|
||||
const DEFAULT_INBOX_HREF = "/mail/inbox"
|
||||
|
||||
const STACKED_IMG_CLASS =
|
||||
"h-[11.25rem] w-auto max-w-none shrink-0 object-contain select-none sm:h-[12rem]"
|
||||
|
||||
export function UltiMailLogo({
|
||||
className,
|
||||
variant = "horizontal",
|
||||
@ -63,7 +68,16 @@ export function UltiMailLogo({
|
||||
width={320}
|
||||
height={320}
|
||||
draggable={false}
|
||||
className="h-[11.25rem] w-auto max-w-none shrink-0 object-contain select-none sm:h-[12rem]"
|
||||
className={cn(STACKED_IMG_CLASS, "dark:hidden")}
|
||||
/>
|
||||
<img
|
||||
src={STACKED_WORDMARK_DARK}
|
||||
alt="Ultimail"
|
||||
width={320}
|
||||
height={320}
|
||||
draggable={false}
|
||||
aria-hidden
|
||||
className={cn(STACKED_IMG_CLASS, "hidden dark:block")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
51
hooks/use-mail-list-infinite-scroll.ts
Normal file
51
hooks/use-mail-list-infinite-scroll.ts
Normal 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 l’attente 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,
|
||||
])
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ApiOrgPolicy, ApiOrgSettingsResponse } from "@/lib/api/admin-org-types"
|
||||
import type { OrgPolicySectionKey } from "@/lib/api/admin-org-types"
|
||||
import type { IntegrationEntry, OrgSettingsState, FilePolicySettings } from "@/lib/admin-settings/org-settings-types"
|
||||
import type { IntegrationEntry, OrgSettingsState, FilePolicySettings, IdentityProvidersPolicy, IdentityProvider } from "@/lib/admin-settings/org-settings-types"
|
||||
|
||||
const INTEGRATION_HREFS: Record<string, string> = {
|
||||
authentik: "/admin/settings/authentication",
|
||||
@ -19,6 +19,59 @@ function mergeIntegrations(
|
||||
}))
|
||||
}
|
||||
|
||||
const DEFAULT_IDENTITY_PROVIDERS: IdentityProvidersPolicy = {
|
||||
allow_self_enrollment: true,
|
||||
default_login_source: "",
|
||||
providers: [],
|
||||
}
|
||||
|
||||
function mergeIdentityProviders(
|
||||
fromApi: Partial<IdentityProvidersPolicy> | undefined
|
||||
): IdentityProvidersPolicy {
|
||||
return {
|
||||
...DEFAULT_IDENTITY_PROVIDERS,
|
||||
...fromApi,
|
||||
providers: (fromApi?.providers ?? []).map((provider) => ({
|
||||
...provider,
|
||||
allowed_email_domains: provider.allowed_email_domains ?? [],
|
||||
allowed_identities: provider.allowed_identities ?? [],
|
||||
allowed_organizations: provider.allowed_organizations ?? [],
|
||||
default_groups: provider.default_groups ?? [],
|
||||
sync_status: provider.sync_status ?? "pending",
|
||||
oauth: provider.oauth
|
||||
? { ...provider.oauth, client_secret: provider.oauth.client_secret ?? "" }
|
||||
: undefined,
|
||||
ldap: provider.ldap
|
||||
? { ...provider.ldap, bind_password: provider.ldap.bind_password ?? "" }
|
||||
: undefined,
|
||||
saml: provider.saml
|
||||
? { ...provider.saml, signing_cert: provider.saml.signing_cert ?? "" }
|
||||
: undefined,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function mapProviderToApi(provider: IdentityProvider) {
|
||||
return {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
slug: provider.slug,
|
||||
type: provider.type,
|
||||
enabled: provider.enabled,
|
||||
authentik_pk: provider.authentik_pk,
|
||||
sync_status: provider.sync_status,
|
||||
sync_error: provider.sync_error,
|
||||
last_synced_at: provider.last_synced_at,
|
||||
allowed_email_domains: provider.allowed_email_domains,
|
||||
allowed_identities: provider.allowed_identities,
|
||||
allowed_organizations: provider.allowed_organizations,
|
||||
default_groups: provider.default_groups,
|
||||
oauth: provider.oauth,
|
||||
saml: provider.saml,
|
||||
ldap: provider.ldap,
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_POLICIES: FilePolicySettings = {
|
||||
max_upload_mib: 512,
|
||||
allowed_extensions: "",
|
||||
@ -49,6 +102,7 @@ export function apiOrgPolicyToStore(policy: ApiOrgPolicy): Partial<OrgSettingsSt
|
||||
allow_password_fallback: policy.authentik.allow_password_fallback,
|
||||
default_groups: policy.authentik.default_groups,
|
||||
},
|
||||
identityProviders: mergeIdentityProviders(policy.identity_providers),
|
||||
twoFactor: {
|
||||
required_for_all: policy.two_factor.required_for_all,
|
||||
required_for_admins: policy.two_factor.required_for_admins,
|
||||
@ -90,6 +144,11 @@ export function storeToApiOrgPolicy(state: OrgSettingsState): ApiOrgPolicy {
|
||||
allow_password_fallback: state.authentik.allow_password_fallback,
|
||||
default_groups: state.authentik.default_groups,
|
||||
},
|
||||
identity_providers: {
|
||||
allow_self_enrollment: state.identityProviders.allow_self_enrollment,
|
||||
default_login_source: state.identityProviders.default_login_source,
|
||||
providers: state.identityProviders.providers.map(mapProviderToApi),
|
||||
},
|
||||
two_factor: {
|
||||
required_for_all: state.twoFactor.required_for_all,
|
||||
required_for_admins: state.twoFactor.required_for_admins,
|
||||
|
||||
@ -16,6 +16,7 @@ import type {
|
||||
PluginEntry,
|
||||
TwoFactorPolicy,
|
||||
UsageQuotaDefaults,
|
||||
IdentityProvidersPolicy,
|
||||
} from "@/lib/admin-settings/org-settings-types"
|
||||
|
||||
const DEFAULT_AUTHENTIK: AuthentikSettings = {
|
||||
@ -28,6 +29,12 @@ const DEFAULT_AUTHENTIK: AuthentikSettings = {
|
||||
default_groups: "ulti-users",
|
||||
}
|
||||
|
||||
const DEFAULT_IDENTITY_PROVIDERS: IdentityProvidersPolicy = {
|
||||
allow_self_enrollment: true,
|
||||
default_login_source: "",
|
||||
providers: [],
|
||||
}
|
||||
|
||||
const DEFAULT_TWO_FACTOR: TwoFactorPolicy = {
|
||||
required_for_all: false,
|
||||
required_for_admins: true,
|
||||
@ -176,6 +183,7 @@ const DEFAULT_INTEGRATIONS: IntegrationEntry[] = [
|
||||
|
||||
type OrgSettingsActions = {
|
||||
setAuthentik: (patch: Partial<AuthentikSettings>) => void
|
||||
setIdentityProviders: (patch: Partial<IdentityProvidersPolicy>) => void
|
||||
setTwoFactor: (patch: Partial<TwoFactorPolicy>) => void
|
||||
setStorageQuotas: (patch: Partial<OrgStorageQuotas>) => void
|
||||
setUsageQuotas: (patch: Partial<UsageQuotaDefaults>) => void
|
||||
@ -195,6 +203,7 @@ type OrgSettingsActions = {
|
||||
toggleIntegration: (id: string, enabled: boolean) => void
|
||||
hydrateFromApi: (patch: Partial<{
|
||||
authentik: AuthentikSettings
|
||||
identityProviders: IdentityProvidersPolicy
|
||||
twoFactor: TwoFactorPolicy
|
||||
storageQuotas: OrgStorageQuotas
|
||||
usageQuotas: UsageQuotaDefaults
|
||||
@ -213,6 +222,7 @@ type OrgSettingsActions = {
|
||||
export const useOrgSettingsStore = create<
|
||||
{
|
||||
authentik: AuthentikSettings
|
||||
identityProviders: IdentityProvidersPolicy
|
||||
twoFactor: TwoFactorPolicy
|
||||
storageQuotas: OrgStorageQuotas
|
||||
usageQuotas: UsageQuotaDefaults
|
||||
@ -230,6 +240,7 @@ export const useOrgSettingsStore = create<
|
||||
} & OrgSettingsActions
|
||||
>()((set) => ({
|
||||
authentik: DEFAULT_AUTHENTIK,
|
||||
identityProviders: DEFAULT_IDENTITY_PROVIDERS,
|
||||
twoFactor: DEFAULT_TWO_FACTOR,
|
||||
storageQuotas: DEFAULT_STORAGE_QUOTAS,
|
||||
usageQuotas: DEFAULT_USAGE_QUOTAS,
|
||||
@ -247,6 +258,10 @@ export const useOrgSettingsStore = create<
|
||||
|
||||
setAuthentik: (patch) =>
|
||||
set((s) => ({ authentik: { ...s.authentik, ...patch } })),
|
||||
setIdentityProviders: (patch) =>
|
||||
set((s) => ({
|
||||
identityProviders: { ...s.identityProviders, ...patch },
|
||||
})),
|
||||
setTwoFactor: (patch) =>
|
||||
set((s) => ({ twoFactor: { ...s.twoFactor, ...patch } })),
|
||||
setStorageQuotas: (patch) =>
|
||||
|
||||
@ -10,6 +10,71 @@ export type AuthentikSettings = {
|
||||
default_groups: string
|
||||
}
|
||||
|
||||
export type IdentityProviderType = "oauth" | "saml" | "ldap"
|
||||
|
||||
export type OAuthProviderPreset =
|
||||
| "google"
|
||||
| "github"
|
||||
| "linkedin"
|
||||
| "microsoft"
|
||||
| "custom"
|
||||
|
||||
export type IdentityProviderOAuth = {
|
||||
provider: OAuthProviderPreset
|
||||
client_id: string
|
||||
client_secret: string
|
||||
scopes: string
|
||||
authorization_url?: string
|
||||
token_url?: string
|
||||
profile_url?: string
|
||||
}
|
||||
|
||||
export type IdentityProviderSAML = {
|
||||
metadata_url?: string
|
||||
metadata_xml?: string
|
||||
entity_id?: string
|
||||
sso_url?: string
|
||||
slo_url?: string
|
||||
signing_cert?: string
|
||||
}
|
||||
|
||||
export type IdentityProviderLDAP = {
|
||||
server_uri: string
|
||||
bind_dn: string
|
||||
bind_password: string
|
||||
base_dn: string
|
||||
user_filter?: string
|
||||
start_tls: boolean
|
||||
sync_users?: boolean
|
||||
}
|
||||
|
||||
export type IdentityProviderSyncStatus = "pending" | "synced" | "error"
|
||||
|
||||
export type IdentityProvider = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
type: IdentityProviderType
|
||||
enabled: boolean
|
||||
authentik_pk?: number
|
||||
sync_status: IdentityProviderSyncStatus
|
||||
sync_error?: string
|
||||
last_synced_at?: string
|
||||
allowed_email_domains: string[]
|
||||
allowed_identities: string[]
|
||||
allowed_organizations: string[]
|
||||
default_groups: string[]
|
||||
oauth?: IdentityProviderOAuth
|
||||
saml?: IdentityProviderSAML
|
||||
ldap?: IdentityProviderLDAP
|
||||
}
|
||||
|
||||
export type IdentityProvidersPolicy = {
|
||||
allow_self_enrollment: boolean
|
||||
default_login_source?: string
|
||||
providers: IdentityProvider[]
|
||||
}
|
||||
|
||||
export type TwoFactorPolicy = {
|
||||
required_for_all: boolean
|
||||
required_for_admins: boolean
|
||||
@ -119,6 +184,7 @@ export type IntegrationEntry = {
|
||||
|
||||
export type OrgSettingsState = {
|
||||
authentik: AuthentikSettings
|
||||
identityProviders: IdentityProvidersPolicy
|
||||
twoFactor: TwoFactorPolicy
|
||||
storageQuotas: OrgStorageQuotas
|
||||
usageQuotas: UsageQuotaDefaults
|
||||
|
||||
@ -10,6 +10,60 @@ export type ApiOrgAuthentik = {
|
||||
default_groups: string
|
||||
}
|
||||
|
||||
export type ApiIdentityProviderOAuth = {
|
||||
provider: "google" | "github" | "linkedin" | "microsoft" | "custom"
|
||||
client_id: string
|
||||
client_secret: string
|
||||
scopes: string
|
||||
authorization_url?: string
|
||||
token_url?: string
|
||||
profile_url?: string
|
||||
}
|
||||
|
||||
export type ApiIdentityProviderSAML = {
|
||||
metadata_url?: string
|
||||
metadata_xml?: string
|
||||
entity_id?: string
|
||||
sso_url?: string
|
||||
slo_url?: string
|
||||
signing_cert?: string
|
||||
}
|
||||
|
||||
export type ApiIdentityProviderLDAP = {
|
||||
server_uri: string
|
||||
bind_dn: string
|
||||
bind_password: string
|
||||
base_dn: string
|
||||
user_filter?: string
|
||||
start_tls: boolean
|
||||
sync_users?: boolean
|
||||
}
|
||||
|
||||
export type ApiIdentityProvider = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
type: "oauth" | "saml" | "ldap"
|
||||
enabled: boolean
|
||||
authentik_pk?: number
|
||||
sync_status: "pending" | "synced" | "error"
|
||||
sync_error?: string
|
||||
last_synced_at?: string
|
||||
allowed_email_domains: string[]
|
||||
allowed_identities: string[]
|
||||
allowed_organizations: string[]
|
||||
default_groups: string[]
|
||||
oauth?: ApiIdentityProviderOAuth
|
||||
saml?: ApiIdentityProviderSAML
|
||||
ldap?: ApiIdentityProviderLDAP
|
||||
}
|
||||
|
||||
export type ApiIdentityProvidersPolicy = {
|
||||
allow_self_enrollment: boolean
|
||||
default_login_source?: string
|
||||
providers: ApiIdentityProvider[]
|
||||
}
|
||||
|
||||
export type ApiOrgTwoFactor = {
|
||||
required_for_all: boolean
|
||||
required_for_admins: boolean
|
||||
@ -116,6 +170,7 @@ export type ApiOrgIntegration = {
|
||||
|
||||
export type ApiOrgPolicy = {
|
||||
authentik: ApiOrgAuthentik
|
||||
identity_providers: ApiIdentityProvidersPolicy
|
||||
two_factor: ApiOrgTwoFactor
|
||||
storage_quotas: ApiOrgStorageQuotas
|
||||
usage_quotas: ApiOrgUsageQuotas
|
||||
@ -159,6 +214,10 @@ export type ApiOrgEffective = {
|
||||
enabled: boolean
|
||||
public_url: string
|
||||
}
|
||||
identity_providers?: {
|
||||
authentik_public_url: string
|
||||
oauth_redirect_template: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiOrgEnvVar = {
|
||||
@ -180,7 +239,7 @@ export type ApiOrgDeployLocked = Record<string, ApiOrgDeployLock>
|
||||
export type ApiOrgSettingsResponse = {
|
||||
policy: ApiOrgPolicy
|
||||
effective: ApiOrgEffective
|
||||
secrets: Record<string, { configured: boolean }>
|
||||
secrets: Record<string, { configured: boolean } | Record<string, Record<string, { configured?: boolean }>>>
|
||||
env_vars: ApiOrgEnvVar[]
|
||||
deploy_locked: ApiOrgDeployLocked
|
||||
updated_at: string
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
|
||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||
|
||||
const AUTH_STORAGE_KEY = "ulti-auth"
|
||||
@ -50,15 +51,28 @@ export const useAuthStore = create<AuthState>()(
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
login: (accessToken, refreshToken, expiresAt, user = null) =>
|
||||
set({ accessToken, refreshToken, expiresAt, user }),
|
||||
logout: () =>
|
||||
login: (accessToken, refreshToken, expiresAt, user = null) => {
|
||||
set({ accessToken, refreshToken, expiresAt, user })
|
||||
useSessionGuardStore.getState().clear()
|
||||
},
|
||||
logout: () => {
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
}),
|
||||
})
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
for (const legacy of LEGACY_AUTH_KEYS) {
|
||||
localStorage.removeItem(legacy)
|
||||
}
|
||||
} catch {
|
||||
/* private mode / quota */
|
||||
}
|
||||
}
|
||||
},
|
||||
isAuthenticated: () => {
|
||||
const { accessToken, expiresAt, refreshToken } = get()
|
||||
if (!accessToken) return false
|
||||
@ -70,10 +84,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
{
|
||||
name: AUTH_STORAGE_KEY,
|
||||
storage: debouncedPersistJSONStorage,
|
||||
version: 1,
|
||||
migrate: (persisted) => {
|
||||
const state = (persisted as { state?: AuthState }).state
|
||||
if (state) {
|
||||
state.accessToken = null
|
||||
state.refreshToken = null
|
||||
state.expiresAt = null
|
||||
}
|
||||
return persisted as { state: AuthState; version: number }
|
||||
},
|
||||
// Tokens stay in httpOnly cookies + in-memory store (via /api/auth/session).
|
||||
partialize: (state) => ({
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
expiresAt: state.expiresAt,
|
||||
user: state.user,
|
||||
}),
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useAuthStore } from "./auth-store"
|
||||
import type { ApiError } from "./types"
|
||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
|
||||
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized"
|
||||
import { isSessionExpired } from "@/lib/auth/session-guard-store"
|
||||
|
||||
export class OfflineError extends Error {
|
||||
constructor() {
|
||||
@ -27,32 +28,6 @@ const DEFAULT_TIMEOUT = 10_000
|
||||
const DEFAULT_RETRIES = 3
|
||||
const BASE_DELAY = 1000
|
||||
|
||||
async function tryRefreshSession(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", { credentials: "include" })
|
||||
if (!res.ok) return false
|
||||
const data = (await res.json()) as {
|
||||
authenticated?: boolean
|
||||
accessToken?: string
|
||||
refreshToken?: string | null
|
||||
expiresAt?: number
|
||||
user?: unknown
|
||||
}
|
||||
if (data.authenticated && data.accessToken && data.expiresAt) {
|
||||
useAuthStore.getState().login(
|
||||
data.accessToken,
|
||||
data.refreshToken ?? "",
|
||||
data.expiresAt,
|
||||
(data.user as PlatformUser | null | undefined) ?? null
|
||||
)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
|
||||
@ -66,11 +41,11 @@ class ApiClient {
|
||||
return new URL(normalizedPath, normalizedBase)
|
||||
}
|
||||
|
||||
private getHeaders(): HeadersInit {
|
||||
private async getHeaders(): Promise<HeadersInit> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const token = await ensureAccessToken()
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
@ -119,7 +94,7 @@ class ApiClient {
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method,
|
||||
headers: { ...this.getHeaders(), ...opts?.headers },
|
||||
headers: { ...(await this.getHeaders()), ...opts?.headers },
|
||||
body: opts?.body ? JSON.stringify(opts.body) : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
@ -139,11 +114,22 @@ class ApiClient {
|
||||
errorBody?.details
|
||||
)
|
||||
|
||||
if (response.status === 401 && !authRetried) {
|
||||
if (response.status === 401) {
|
||||
if (isSessionExpired()) {
|
||||
throw err
|
||||
}
|
||||
if (!authRetried) {
|
||||
authRetried = true
|
||||
if (await tryRefreshSession()) {
|
||||
const resolution = await handleUnauthorized()
|
||||
if (resolution === "refreshed") {
|
||||
continue
|
||||
}
|
||||
if (resolution === "offline") {
|
||||
throw new OfflineError()
|
||||
}
|
||||
} else {
|
||||
await handleUnauthorized({ forceExpired: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
@ -187,18 +173,34 @@ class ApiClient {
|
||||
}
|
||||
|
||||
/** GET binary body (inline attachments, exports). */
|
||||
async getBlob(path: string): Promise<Blob> {
|
||||
async getBlob(path: string, authRetried = false): Promise<Blob> {
|
||||
if (typeof navigator !== "undefined" && !navigator.onLine) {
|
||||
throw new OfflineError()
|
||||
}
|
||||
const url = this.resolveUrl(path)
|
||||
const headers: Record<string, string> = {}
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const token = await ensureAccessToken()
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
const response = await fetch(url.toString(), { method: "GET", headers })
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
if (isSessionExpired()) {
|
||||
throw new ApiRequestError(response.status, "UNKNOWN", response.statusText)
|
||||
}
|
||||
if (!authRetried) {
|
||||
const resolution = await handleUnauthorized()
|
||||
if (resolution === "refreshed") {
|
||||
return this.getBlob(path, true)
|
||||
}
|
||||
if (resolution === "offline") {
|
||||
throw new OfflineError()
|
||||
}
|
||||
} else {
|
||||
await handleUnauthorized({ forceExpired: true })
|
||||
}
|
||||
}
|
||||
throw new ApiRequestError(
|
||||
response.status,
|
||||
"UNKNOWN",
|
||||
|
||||
@ -6,6 +6,8 @@ import { useAuthReady } from '../use-auth-ready'
|
||||
import type { ApiFolder, ApiLabel, ApiIdentity } from '../types'
|
||||
|
||||
export function useFolders(accountId?: string) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['folders', accountId],
|
||||
queryFn: async () => {
|
||||
@ -15,7 +17,7 @@ export function useFolders(accountId?: string) {
|
||||
)
|
||||
return Array.isArray(res) ? res : (res.folders ?? [])
|
||||
},
|
||||
enabled: !!accountId,
|
||||
enabled: ready && authenticated && !!accountId,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
38
lib/api/hooks/use-identity-provider-actions.ts
Normal file
38
lib/api/hooks/use-identity-provider-actions.ts
Normal 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)}`
|
||||
),
|
||||
})
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'
|
||||
import { apiClient, OfflineError } from '../client'
|
||||
import { useAuthReady } from '../use-auth-ready'
|
||||
import { normalizeListPageSize } from '@/lib/mail-list-page-size'
|
||||
import { normalizeListPageSize, LIST_PAGE_SIZE } from '@/lib/mail-list-page-size'
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
ApiMessageSummary,
|
||||
@ -35,34 +35,56 @@ export function unwrapMessages(
|
||||
}
|
||||
}
|
||||
|
||||
export function messagesQueryKey(
|
||||
folder: string,
|
||||
accountId?: string,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
) {
|
||||
return ['messages', folder, accountId, page, pageSize] as const
|
||||
}
|
||||
|
||||
export async function fetchMessagesPage(
|
||||
folder: string,
|
||||
accountId: string | undefined,
|
||||
page: number,
|
||||
pageSize: number
|
||||
): Promise<PaginatedResponse<ApiMessageSummary>> {
|
||||
const safePageSize = normalizeListPageSize(pageSize)
|
||||
const res = await apiClient.get<ApiMessagesPayload>('/mail/messages', {
|
||||
folder,
|
||||
account_id: accountId,
|
||||
page: String(page),
|
||||
page_size: String(safePageSize),
|
||||
})
|
||||
return unwrapMessages(res)
|
||||
}
|
||||
|
||||
export function useMessages(
|
||||
folder: string,
|
||||
accountId?: string,
|
||||
page?: number,
|
||||
pageSize?: number
|
||||
) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['messages', folder, accountId, page, pageSize],
|
||||
queryFn: async () => {
|
||||
const safePageSize = normalizeListPageSize(pageSize ?? 50)
|
||||
const res = await apiClient.get<ApiMessagesPayload>('/mail/messages', {
|
||||
folder,
|
||||
account_id: accountId,
|
||||
page: String(page ?? 1),
|
||||
page_size: String(safePageSize),
|
||||
})
|
||||
return unwrapMessages(res)
|
||||
},
|
||||
queryKey: messagesQueryKey(folder, accountId, page, pageSize),
|
||||
queryFn: () =>
|
||||
fetchMessagesPage(folder, accountId, page ?? 1, pageSize ?? LIST_PAGE_SIZE),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 60_000,
|
||||
enabled: ready && authenticated,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMessage(messageId: string | null) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['message', messageId],
|
||||
queryFn: () => apiClient.get<ApiMessageFull>(`/mail/messages/${messageId}`),
|
||||
enabled: !!messageId,
|
||||
enabled: ready && authenticated && !!messageId,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
@ -82,6 +104,8 @@ export function unwrapThreadMessages(
|
||||
}
|
||||
|
||||
export function useThread(threadId: string | null) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['thread', 'v2', threadId],
|
||||
queryFn: () =>
|
||||
@ -89,7 +113,7 @@ export function useThread(threadId: string | null) {
|
||||
ApiMessageFull[] | { messages?: ApiMessageFull[]; thread_id?: string }
|
||||
>(`/mail/threads/${threadId}`),
|
||||
select: unwrapThreadMessages,
|
||||
enabled: !!threadId,
|
||||
enabled: ready && authenticated && !!threadId,
|
||||
})
|
||||
}
|
||||
|
||||
@ -112,6 +136,7 @@ export function useMailAccounts() {
|
||||
|
||||
export function useMailSearch(filter: MessageSearchFilter | null) {
|
||||
const queryClient = useQueryClient()
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['mail-search', filter],
|
||||
@ -172,6 +197,6 @@ export function useMailSearch(filter: MessageSearchFilter | null) {
|
||||
throw err
|
||||
}
|
||||
},
|
||||
enabled: isMessageSearchFilterActive(filter),
|
||||
enabled: ready && authenticated && isMessageSearchFilterActive(filter),
|
||||
})
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiClient } from '../client'
|
||||
import { useAuthReady } from '../use-auth-ready'
|
||||
import type { ApiMailSignature, CreateMailSignaturePayload } from '../types'
|
||||
|
||||
const SIGNATURES_KEY = ['mail-signatures'] as const
|
||||
@ -14,10 +15,13 @@ async function fetchSignatures(): Promise<ApiMailSignature[]> {
|
||||
}
|
||||
|
||||
export function useMailSignatures() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
return useQuery({
|
||||
queryKey: SIGNATURES_KEY,
|
||||
queryFn: fetchSignatures,
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: ready && authenticated,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
isPreviewThumbQueryKey,
|
||||
revokePreviewBlobData,
|
||||
} from "@/lib/api/preview-blob-url"
|
||||
import { ApiRequestError } from "@/lib/api/client"
|
||||
|
||||
const DB_NAME = "ultimail-query-cache"
|
||||
const STORE_NAME = "query-cache"
|
||||
@ -64,7 +65,12 @@ function makeQueryClient() {
|
||||
gcTime: 1000 * 60 * 60 * 24,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
networkMode: "offlineFirst",
|
||||
retry: 3,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiRequestError && error.status === 401) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 3
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
networkMode: "offlineFirst",
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect } from "react"
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
|
||||
import type { WsEvent, WsEventType, WsMailPayload } from "./types"
|
||||
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
|
||||
import { useAuthStore } from "./auth-store"
|
||||
|
||||
export type WsEventListener = (evt: WsEvent) => void
|
||||
@ -150,12 +151,22 @@ export function useWebSocket() {
|
||||
}, [queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
wsManager.connect(accessToken)
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
const token = accessToken ? await ensureAccessToken() : null
|
||||
if (cancelled) return
|
||||
if (token) {
|
||||
wsManager.connect(token)
|
||||
} else {
|
||||
wsManager.disconnect()
|
||||
}
|
||||
return () => wsManager.disconnect()
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
wsManager.disconnect()
|
||||
}
|
||||
}, [accessToken])
|
||||
}
|
||||
|
||||
|
||||
21
lib/auth/ensure-access-token.ts
Normal file
21
lib/auth/ensure-access-token.ts
Normal 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
|
||||
}
|
||||
79
lib/auth/handle-unauthorized.ts
Normal file
79
lib/auth/handle-unauthorized.ts
Normal 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
|
||||
}
|
||||
@ -50,10 +50,97 @@ type OidcConfig = {
|
||||
endSessionEndpoint: string
|
||||
}
|
||||
|
||||
let discoveryCache: { issuer: string; doc: OidcDiscovery; at: number } | null =
|
||||
null
|
||||
let discoveryCache: {
|
||||
discoveryIssuer: string
|
||||
doc: OidcDiscovery
|
||||
at: number
|
||||
} | null = null
|
||||
const DISCOVERY_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
/** Internal origin for server-side OIDC calls (Docker: http://nginx, dev host: http://127.0.0.1). */
|
||||
function getOidcInternalOrigin(): string | null {
|
||||
const raw =
|
||||
process.env.OIDC_DISCOVERY_ORIGIN?.trim() ||
|
||||
process.env.ULTI_PROXY_ORIGIN?.trim()
|
||||
if (!raw) return null
|
||||
try {
|
||||
return new URL(raw.endsWith("/") ? raw : `${raw}/`).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getOidcPublicOrigin(): string {
|
||||
try {
|
||||
return new URL(getPublicOidcConfig().issuer).origin
|
||||
} catch {
|
||||
return "http://localhost"
|
||||
}
|
||||
}
|
||||
|
||||
function issuerWithOrigin(issuer: string, origin: string): string {
|
||||
try {
|
||||
const parsed = new URL(issuer)
|
||||
return `${origin}${parsed.pathname}`
|
||||
} catch {
|
||||
return issuer
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteOrigin(url: string, origin: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const target = new URL(origin.endsWith("/") ? origin : `${origin}/`)
|
||||
parsed.protocol = target.protocol
|
||||
parsed.hostname = target.hostname
|
||||
parsed.port = target.port
|
||||
return parsed.toString()
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/** Browser redirects must use the public Authentik URL, not Docker-internal hostnames. */
|
||||
function toPublicEndpoint(
|
||||
endpoint: string,
|
||||
internalOrigin: string | null,
|
||||
publicOrigin: string
|
||||
): string {
|
||||
if (!internalOrigin || internalOrigin === publicOrigin) return endpoint
|
||||
return rewriteOrigin(endpoint, publicOrigin)
|
||||
}
|
||||
|
||||
/**
|
||||
* When token exchange hits Docker-internal nginx, Authentik must still emit the public
|
||||
* issuer (localhost) or ultid's ID token verifier rejects the JWT.
|
||||
*/
|
||||
export function oidcServerFetchHeaders(): Record<string, string> {
|
||||
const internalOrigin = getOidcInternalOrigin()
|
||||
const publicOrigin = getOidcPublicOrigin()
|
||||
if (!internalOrigin || internalOrigin === publicOrigin) return {}
|
||||
try {
|
||||
const publicUrl = new URL(publicOrigin)
|
||||
const host = publicUrl.host
|
||||
return {
|
||||
Host: host,
|
||||
"X-Forwarded-Host": host,
|
||||
"X-Forwarded-Proto": publicUrl.protocol.replace(":", ""),
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Server-side token/logout calls must not use browser-facing localhost from inside Docker. */
|
||||
function toServerEndpoint(
|
||||
endpoint: string,
|
||||
internalOrigin: string | null,
|
||||
publicOrigin: string
|
||||
): string {
|
||||
if (!internalOrigin || internalOrigin === publicOrigin) return endpoint
|
||||
return rewriteOrigin(endpoint, internalOrigin)
|
||||
}
|
||||
|
||||
export function getPublicOidcConfig(): OidcConfig {
|
||||
const issuer = trimSlash(
|
||||
process.env.NEXT_PUBLIC_OIDC_ISSUER ??
|
||||
@ -77,34 +164,56 @@ export function getPublicOidcConfig(): OidcConfig {
|
||||
/** Resolve authorize/token URLs from issuer discovery (Authentik uses shared /o/ endpoints). */
|
||||
export async function resolveOidcConfig(): Promise<OidcConfig> {
|
||||
const base = getPublicOidcConfig()
|
||||
const internalOrigin = getOidcInternalOrigin()
|
||||
const discoveryIssuer = internalOrigin
|
||||
? issuerWithOrigin(base.issuer, internalOrigin)
|
||||
: base.issuer
|
||||
const now = Date.now()
|
||||
if (
|
||||
discoveryCache &&
|
||||
discoveryCache.issuer === base.issuer &&
|
||||
discoveryCache.discoveryIssuer === discoveryIssuer &&
|
||||
now - discoveryCache.at < DISCOVERY_TTL_MS
|
||||
) {
|
||||
return applyDiscovery(base, discoveryCache.doc)
|
||||
return applyDiscovery(base, discoveryCache.doc, internalOrigin)
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${base.issuer}.well-known/openid-configuration`,
|
||||
`${discoveryIssuer}.well-known/openid-configuration`,
|
||||
{ next: { revalidate: 300 } }
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`OIDC discovery failed (${res.status}) for ${base.issuer}`)
|
||||
throw new Error(
|
||||
`OIDC discovery failed (${res.status}) for ${discoveryIssuer}`
|
||||
)
|
||||
}
|
||||
const doc = (await res.json()) as OidcDiscovery
|
||||
discoveryCache = { issuer: base.issuer, doc, at: now }
|
||||
return applyDiscovery(base, doc)
|
||||
discoveryCache = { discoveryIssuer, doc, at: now }
|
||||
return applyDiscovery(base, doc, internalOrigin)
|
||||
}
|
||||
|
||||
function applyDiscovery(base: OidcConfig, doc: OidcDiscovery): OidcConfig {
|
||||
function applyDiscovery(
|
||||
base: OidcConfig,
|
||||
doc: OidcDiscovery,
|
||||
internalOrigin: string | null
|
||||
): OidcConfig {
|
||||
const publicOrigin = getOidcPublicOrigin()
|
||||
return {
|
||||
...base,
|
||||
authorizationEndpoint: doc.authorization_endpoint,
|
||||
tokenEndpoint: doc.token_endpoint,
|
||||
endSessionEndpoint:
|
||||
authorizationEndpoint: toPublicEndpoint(
|
||||
doc.authorization_endpoint,
|
||||
internalOrigin,
|
||||
publicOrigin
|
||||
),
|
||||
tokenEndpoint: toServerEndpoint(
|
||||
doc.token_endpoint,
|
||||
internalOrigin,
|
||||
publicOrigin
|
||||
),
|
||||
endSessionEndpoint: toPublicEndpoint(
|
||||
doc.end_session_endpoint ?? base.endSessionEndpoint,
|
||||
internalOrigin,
|
||||
publicOrigin
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
lib/auth/session-guard-store.ts
Normal file
29
lib/auth/session-guard-store.ts
Normal 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
41
lib/auth/session-sync.ts
Normal 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)
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import type { NextResponse } from "next/server"
|
||||
import { decodeJwtPayload } from "@/lib/auth/jwt-claims"
|
||||
import { oidcServerFetchHeaders } from "@/lib/auth/oidc-config"
|
||||
|
||||
/** Ultimail session lifetime — independent of short-lived OIDC access tokens. */
|
||||
export const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 365
|
||||
@ -18,13 +20,24 @@ export type TokenResponse = {
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
function sessionCookieSecure(): boolean {
|
||||
if (process.env.COOKIE_SECURE === "true") return true
|
||||
if (process.env.COOKIE_SECURE === "false") return false
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ""
|
||||
try {
|
||||
return new URL(appUrl).protocol === "https:"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE_SEC,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: sessionCookieSecure(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,13 +45,22 @@ export function computeExpiresAt(expiresIn: number): number {
|
||||
return Date.now() + expiresIn * 1000
|
||||
}
|
||||
|
||||
export function isIdTokenJwtValid(accessToken: string | undefined): boolean {
|
||||
if (!accessToken) return false
|
||||
const claims = decodeJwtPayload(accessToken)
|
||||
const exp = claims?.exp
|
||||
if (typeof exp !== "number") return false
|
||||
return Date.now() < exp * 1000
|
||||
}
|
||||
|
||||
export function isAccessTokenValid(
|
||||
accessToken: string | undefined,
|
||||
expiresAtRaw: string | undefined
|
||||
): boolean {
|
||||
if (!accessToken || !expiresAtRaw) return false
|
||||
if (!accessToken) return false
|
||||
const expiresAt = Number(expiresAtRaw)
|
||||
return Number.isFinite(expiresAt) && Date.now() < expiresAt
|
||||
if (Number.isFinite(expiresAt) && Date.now() < expiresAt) return true
|
||||
return isIdTokenJwtValid(accessToken)
|
||||
}
|
||||
|
||||
type OidcTokenConfig = {
|
||||
@ -59,7 +81,10 @@ export async function exchangeRefreshToken(
|
||||
})
|
||||
const res = await fetch(cfg.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
...oidcServerFetchHeaders(),
|
||||
},
|
||||
body,
|
||||
})
|
||||
if (!res.ok) {
|
||||
@ -69,11 +94,10 @@ export async function exchangeRefreshToken(
|
||||
}
|
||||
|
||||
export function resolveBearerToken(tokens: TokenResponse): string {
|
||||
const bearer = tokens.id_token ?? tokens.access_token
|
||||
if (!bearer) {
|
||||
throw new Error("no_token_in_response")
|
||||
if (!tokens.id_token) {
|
||||
throw new Error("no_id_token_in_response")
|
||||
}
|
||||
return bearer
|
||||
return tokens.id_token
|
||||
}
|
||||
|
||||
export function applySessionCookies(
|
||||
|
||||
28
lib/drive/drive-share-button-state.ts
Normal file
28
lib/drive/drive-share-button-state.ts
Normal 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"
|
||||
}
|
||||
@ -53,7 +53,7 @@ export function openPublicShareItem(file: DriveFileInfo, options: OpenPublicShar
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined
|
||||
const mode = canEdit ? "edit" : "view"
|
||||
router.push(buildPublicShareEditHref(token, file.path, returnTo, mode))
|
||||
router.push(buildPublicShareEditHref(token, file.path, returnTo, mode, file.name))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,8 @@ export function buildPublicShareEditHref(
|
||||
token: string,
|
||||
filePath: string,
|
||||
returnTo?: string,
|
||||
mode: "edit" | "view" = "edit"
|
||||
mode: "edit" | "view" = "edit",
|
||||
displayName?: string
|
||||
): string {
|
||||
const trimmed = filePath.replace(/^\/+|\/+$/g, "")
|
||||
const base = `/drive/s/${encodeURIComponent(token)}/edit/${trimmed.split("/").map(encodeURIComponent).join("/")}`
|
||||
@ -13,6 +14,9 @@ export function buildPublicShareEditHref(
|
||||
if (mode === "view") {
|
||||
params.set("mode", "view")
|
||||
}
|
||||
if (displayName?.trim()) {
|
||||
params.set("name", displayName.trim())
|
||||
}
|
||||
const qs = params.toString()
|
||||
return qs ? `${base}?${qs}` : base
|
||||
}
|
||||
|
||||
16
lib/drive/use-drive-document-title.ts
Normal file
16
lib/drive/use-drive-document-title.ts
Normal 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])
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
|
||||
import { useAuthStore } from "@/lib/api/auth-store"
|
||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||
import { useAccountStore, useActiveAccount } from "@/lib/stores/account-store"
|
||||
|
||||
/** Identity shown in header avatar / account menu (OIDC user, else active mail account). */
|
||||
export function useChromeIdentity(): {
|
||||
@ -9,9 +10,15 @@ export function useChromeIdentity(): {
|
||||
email: string
|
||||
firstName: string
|
||||
} | null {
|
||||
const authHydrated = usePersistHydrated(useAuthStore)
|
||||
const accountHydrated = usePersistHydrated(useAccountStore)
|
||||
const platformUser = useAuthStore((s) => s.user)
|
||||
const mailAccount = useActiveAccount()
|
||||
|
||||
// Keep SSR and first client render identical until persist stores rehydrate.
|
||||
if (!authHydrated) return null
|
||||
if (!platformUser && !accountHydrated) return null
|
||||
|
||||
if (platformUser) {
|
||||
return {
|
||||
name: platformUser.name,
|
||||
|
||||
@ -308,6 +308,18 @@ export const MAIL_SETTINGS_MAIN_INSET_CLASS =
|
||||
export const MAIL_SETTINGS_MAIN_CARD_CLASS =
|
||||
"flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-mail-surface shadow-sm max-sm:rounded-none max-sm:shadow-none"
|
||||
|
||||
/** Liste d'onglets des pages réglages (libellés, automatisations…). */
|
||||
export const MAIL_SETTINGS_TABS_LIST_CLASS = cn(
|
||||
"flex h-auto w-fit max-w-full flex-wrap gap-1.5",
|
||||
"[&_[data-slot=tabs-trigger]]:flex-none",
|
||||
)
|
||||
|
||||
/** Card interne des pages réglages mail (alignée contacts). */
|
||||
export const MAIL_SETTINGS_CARD_CLASS = cn(
|
||||
"mail-settings-card rounded-lg border border-mail-border bg-mail-surface shadow-sm",
|
||||
"dark:bg-mail-surface-elevated dark:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
|
||||
)
|
||||
|
||||
/** Masonry 2 colonnes pour sections réglages (affichage, signatures…) en lg+. */
|
||||
export const MAIL_SETTINGS_PAGE_MASONRY_CLASS = "lg:columns-2 lg:gap-5"
|
||||
|
||||
@ -316,5 +328,7 @@ export const MAIL_SETTINGS_PAGE_MASONRY_ITEM_CLASS =
|
||||
|
||||
/** Bloc empilé → card en masonry (variant page affichage). */
|
||||
export const MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS = cn(
|
||||
"border-border px-0 py-5 lg:mb-5 lg:break-inside-avoid lg:rounded-xl lg:border lg:px-5 lg:py-5 lg:shadow-sm"
|
||||
"mail-settings-masonry-section border-border px-0 py-5",
|
||||
"lg:mb-5 lg:break-inside-avoid lg:rounded-xl lg:border lg:border-mail-border lg:bg-mail-surface lg:px-5 lg:py-5 lg:shadow-sm",
|
||||
"dark:lg:bg-mail-surface-elevated dark:lg:shadow-[0_1px_4px_rgba(0,0,0,0.35)]",
|
||||
)
|
||||
|
||||
@ -4,14 +4,12 @@ import {
|
||||
Bot,
|
||||
FolderKanban,
|
||||
Monitor,
|
||||
PenLine,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
export type MailSettingsSectionId =
|
||||
| "display"
|
||||
| "accounts"
|
||||
| "signatures"
|
||||
| "labels"
|
||||
| "notifications"
|
||||
| "automation"
|
||||
@ -35,17 +33,10 @@ export const MAIL_SETTINGS_NAV: MailSettingsNavItem[] = [
|
||||
{
|
||||
id: "accounts",
|
||||
label: "Comptes mail",
|
||||
description: "IMAP, SMTP et identités d'envoi",
|
||||
description: "IMAP, SMTP, identités d'envoi et signatures",
|
||||
href: "/mail/settings/accounts",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: "signatures",
|
||||
label: "Signatures",
|
||||
description: "Bibliothèque et attribution par identité",
|
||||
href: "/mail/settings/signatures",
|
||||
icon: PenLine,
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
label: "Libellés et dossiers",
|
||||
@ -96,10 +87,11 @@ export function resolveMailSettingsSection(
|
||||
|
||||
const MAIL_SETTINGS_WIDE_LAYOUT_SECTIONS: MailSettingsSectionId[] = [
|
||||
"display",
|
||||
"signatures",
|
||||
"automation",
|
||||
]
|
||||
|
||||
const MAIL_SETTINGS_LEFT_ALIGNED_SECTIONS: MailSettingsSectionId[] = ["accounts"]
|
||||
|
||||
export function isMailSettingsWideLayoutPath(pathname: string | null): boolean {
|
||||
if (!pathname?.startsWith("/mail/settings")) return false
|
||||
return MAIL_SETTINGS_NAV.some(
|
||||
@ -108,3 +100,12 @@ export function isMailSettingsWideLayoutPath(pathname: string | null): boolean {
|
||||
isMailSettingsNavActive(pathname, item)
|
||||
)
|
||||
}
|
||||
|
||||
export function isMailSettingsLeftAlignedPath(pathname: string | null): boolean {
|
||||
if (!pathname?.startsWith("/mail/settings")) return false
|
||||
return MAIL_SETTINGS_NAV.some(
|
||||
(item) =>
|
||||
MAIL_SETTINGS_LEFT_ALIGNED_SECTIONS.includes(item.id) &&
|
||||
isMailSettingsNavActive(pathname, item)
|
||||
)
|
||||
}
|
||||
|
||||
@ -47,12 +47,13 @@ export const MAIL_SETTINGS_SEARCH_INDEX: MailSettingsSearchEntry[] = [
|
||||
entry("display", "inbox-type", "Type de boîte de réception", "important non lus suivis starred tri inbox"),
|
||||
entry("display", "reading-pane", "Volet de lecture", "split panneau droite aperçu message"),
|
||||
entry("display", "conversation", "Mode Conversation", "fil discussion thread regrouper messages"),
|
||||
entry("display", "infinite-scroll", "Scroll infini", "défilement pagination liste messages bureau desktop"),
|
||||
entry("accounts", "add-account", "Ajouter un compte mail", "imap smtp oauth connecter serveur"),
|
||||
entry("accounts", "identities", "Identités d'envoi", "alias from expéditeur adresse envoi"),
|
||||
entry("accounts", "imap", "IMAP", "réception serveur entrant synchronisation"),
|
||||
entry("accounts", "smtp", "SMTP", "envoi serveur sortant"),
|
||||
entry("signatures", "signature-library", "Bibliothèque de signatures", "créer modifier supprimer signature html"),
|
||||
entry("signatures", "signature-assign", "Attribution des signatures", "identité signature par défaut"),
|
||||
entry("accounts", "signature-library", "Bibliothèque de signatures", "créer modifier supprimer signature html"),
|
||||
entry("accounts", "signature-assign", "Signature par identité", "identité signature par défaut sélecteur compte mail"),
|
||||
entry("labels", "labels", "Libellés", "tags couleur étiquettes organisation"),
|
||||
entry("labels", "folders", "Dossiers", "imap unified unifiés arborescence"),
|
||||
entry("labels", "unified-folders", "Dossiers unifiés", "cross-comptes organisation partagée"),
|
||||
|
||||
@ -28,6 +28,7 @@ type MailSettingsState = {
|
||||
inboxSort: InboxSortMode
|
||||
readingPane: ReadingPaneMode
|
||||
conversationMode: boolean
|
||||
infiniteScroll: boolean
|
||||
listPageSize: ListPageSize
|
||||
desktopNewMail: boolean
|
||||
desktopMentions: boolean
|
||||
@ -44,6 +45,7 @@ type MailSettingsActions = {
|
||||
setInboxSort: (sort: InboxSortMode) => void
|
||||
setReadingPane: (mode: ReadingPaneMode) => void
|
||||
setConversationMode: (enabled: boolean) => void
|
||||
setInfiniteScroll: (enabled: boolean) => void
|
||||
setListPageSize: (size: ListPageSize) => void
|
||||
setDesktopNewMail: (enabled: boolean) => void
|
||||
setDesktopMentions: (enabled: boolean) => void
|
||||
@ -75,6 +77,7 @@ const defaults: MailSettingsState = {
|
||||
inboxSort: "default",
|
||||
readingPane: "none",
|
||||
conversationMode: true,
|
||||
infiniteScroll: false,
|
||||
listPageSize: LIST_PAGE_SIZE,
|
||||
...defaultNotificationPrefs,
|
||||
}
|
||||
@ -94,6 +97,7 @@ export const useMailSettingsStore = create<
|
||||
setInboxSort: (inboxSort) => set({ inboxSort }),
|
||||
setReadingPane: (readingPane) => set({ readingPane }),
|
||||
setConversationMode: (conversationMode) => set({ conversationMode }),
|
||||
setInfiniteScroll: (infiniteScroll) => set({ infiniteScroll }),
|
||||
setListPageSize: (listPageSize) => set({ listPageSize }),
|
||||
setDesktopNewMail: (desktopNewMail) => set({ desktopNewMail }),
|
||||
setDesktopMentions: (desktopMentions) => set({ desktopMentions }),
|
||||
@ -122,6 +126,7 @@ export const useMailSettingsStore = create<
|
||||
inboxSort: s.inboxSort,
|
||||
readingPane: s.readingPane,
|
||||
conversationMode: s.conversationMode,
|
||||
infiniteScroll: s.infiniteScroll,
|
||||
listPageSize: s.listPageSize,
|
||||
desktopNewMail: s.desktopNewMail,
|
||||
desktopMentions: s.desktopMentions,
|
||||
|
||||
BIN
public/brand/ultimail-wordmark-stacked-dark.png
Normal file
BIN
public/brand/ultimail-wordmark-stacked-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
@ -32,6 +32,48 @@ function headerIconPath() {
|
||||
return p
|
||||
}
|
||||
|
||||
/** Wordmark empilé fond transparent : picto couleurs d'origine, texte éclairci (sans invert/hue). */
|
||||
async function buildStackedDark(originalPath) {
|
||||
const { data, info } = await sharp(originalPath)
|
||||
.resize({
|
||||
width: 800,
|
||||
height: 800,
|
||||
fit: "contain",
|
||||
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||
})
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i]
|
||||
const g = data[i + 1]
|
||||
const b = data[i + 2]
|
||||
if (r >= 235 && g >= 235 && b >= 235) {
|
||||
data[i + 3] = 0
|
||||
continue
|
||||
}
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const chroma = max - min
|
||||
// Texte / traits neutres sombres → gris clair lisible sur fond dark.
|
||||
if (chroma < 40 && max < 200) {
|
||||
data[i] = 245
|
||||
data[i + 1] = 245
|
||||
data[i + 2] = 247
|
||||
}
|
||||
data[i + 3] = 255
|
||||
}
|
||||
|
||||
const out = join(brandDir, "ultimail-wordmark-stacked-dark.png")
|
||||
await sharp(Buffer.from(data), {
|
||||
raw: { width: info.width, height: info.height, channels: 4 },
|
||||
})
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(out)
|
||||
console.log("Wrote ultimail-wordmark-stacked-dark.png")
|
||||
}
|
||||
|
||||
async function writePngJpg(input, outBase, w, h, bg = "#ffffff") {
|
||||
const resize = {
|
||||
width: w,
|
||||
@ -109,6 +151,7 @@ async function main() {
|
||||
|
||||
await writePngJpg(headerIcon, "ultimail-mark", 256, 256)
|
||||
await writePngJpg(original, "ultimail-wordmark-stacked", 800, 800)
|
||||
await buildStackedDark(original)
|
||||
await writePngJpg(horizontalBuf, "ultimail-wordmark-horizontal", 1600, 460)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user