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