diff --git a/.env.example b/.env.example index de02158..b5123da 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ NEXT_PUBLIC_API_URL=/api/v1 NEXT_PUBLIC_WS_URL=ws://localhost/ws # Cible du proxy Next (optionnel, défaut 127.0.0.1:80) -# ULTI_PROXY_ORIGIN=http://127.0.0.1 +# Sert aussi aux appels OIDC serveur (discovery/token) — Docker: http://nginx +# ULTI_PROXY_ORIGIN=http://127.0.0.1:80 # OIDC Authentik (blueprints deploy/authentik dans ulti-backend) NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/ @@ -10,6 +11,8 @@ NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend # URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0 # URL publique navigateur (suite nginx) — pas :3000 si tu passes par http://localhost/mail NEXT_PUBLIC_APP_URL=http://localhost +# Cookies session Secure (auto: true seulement si NEXT_PUBLIC_APP_URL est https://) +# COOKIE_SECURE=false # Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint OIDC_CLIENT_SECRET=changeme diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts index 4c6b9e9..b3b9016 100644 --- a/app/api/auth/callback/route.ts +++ b/app/api/auth/callback/route.ts @@ -1,6 +1,10 @@ import { cookies } from "next/headers" import { NextResponse } from "next/server" -import { resolveServerOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config" +import { + resolveServerOidcConfig, + getAppOrigin, + oidcServerFetchHeaders, +} from "@/lib/auth/oidc-config" import { platformUserFromToken } from "@/lib/auth/jwt-claims" import { applySessionCookies, @@ -75,7 +79,10 @@ export async function GET(request: Request) { try { const res = await fetch(cfg.tokenEndpoint, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...oidcServerFetchHeaders(), + }, body, }) if (!res.ok) { diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index b81293f..6f49ee3 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server" import { createPkcePair, randomString } from "@/lib/auth/pkce" import { platformUserFromToken } from "@/lib/auth/jwt-claims" import { resolveOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config" +import { sessionCookieOptions } from "@/lib/auth/session" const PKCE_COOKIE = "ulti_pkce_verifier" const STATE_COOKIE = "ulti_oauth_state" @@ -12,11 +13,8 @@ const COOKIE_MAX_AGE = 600 function oauthCookieOptions() { return { - httpOnly: true, - sameSite: "lax" as const, - path: "/", + ...sessionCookieOptions(), maxAge: COOKIE_MAX_AGE, - secure: process.env.NODE_ENV === "production", } } diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 309fd1f..9b9275f 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -8,6 +8,7 @@ import { computeExpiresAt, exchangeRefreshToken, isAccessTokenValid, + isIdTokenJwtValid, resolveBearerToken, } from "@/lib/auth/session" @@ -40,7 +41,25 @@ export async function GET() { try { const cfg = await resolveServerOidcConfig() const tokens = await exchangeRefreshToken(refreshToken, cfg) - const bearer = resolveBearerToken(tokens) + + let bearer: string + try { + bearer = resolveBearerToken(tokens) + } catch { + if (accessToken && isIdTokenJwtValid(accessToken)) { + const expiresAt = Number(expiresAtRaw) || computeExpiresAt(3600) + const user = platformUserFromToken(accessToken) + return NextResponse.json({ + authenticated: true, + accessToken, + refreshToken: refreshToken ?? null, + expiresAt, + user, + }) + } + return NextResponse.json({ authenticated: false, expired: true }) + } + const expiresAt = computeExpiresAt(tokens.expires_in ?? 3600) const user = platformUserFromToken(bearer) diff --git a/app/auth/complete/page.tsx b/app/auth/complete/page.tsx index 5a15fae..d4f5938 100644 --- a/app/auth/complete/page.tsx +++ b/app/auth/complete/page.tsx @@ -2,13 +2,11 @@ import { useEffect, Suspense } from "react" import { useRouter, useSearchParams } from "next/navigation" -import { useAuthStore } from "@/lib/api/auth-store" -import type { PlatformUser } from "@/lib/auth/jwt-claims" +import { applySessionToStore } from "@/lib/auth/session-sync" function AuthCompleteInner() { const router = useRouter() const searchParams = useSearchParams() - const login = useAuthStore((s) => s.login) const returnTo = searchParams.get("returnTo") ?? "/mail/inbox" const accountNotice = searchParams.get("accountNotice") @@ -23,20 +21,9 @@ function AuthCompleteInner() { accessToken?: string refreshToken?: string | null expiresAt?: number - user?: PlatformUser | null + user?: { sub: string; email: string; name: string } | null } - if ( - data.authenticated && - data.accessToken && - data.expiresAt && - !cancelled - ) { - login( - data.accessToken, - data.refreshToken ?? "", - data.expiresAt, - data.user ?? null - ) + if (applySessionToStore(data) && !cancelled) { if (accountNotice === "same") { sessionStorage.setItem("ulti_account_notice", "same") } @@ -55,7 +42,7 @@ function AuthCompleteInner() { return () => { cancelled = true } - }, [accountNotice, login, returnTo, router]) + }, [accountNotice, returnTo, router]) return null } diff --git a/app/drive/edit/[fileId]/layout.tsx b/app/drive/edit/[fileId]/layout.tsx index 27b9fa0..2c095c5 100644 --- a/app/drive/edit/[fileId]/layout.tsx +++ b/app/drive/edit/[fileId]/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next" -import { displayFileName } from "@/lib/drive/display-file-name" +import { displayFileBaseName } from "@/lib/drive/display-file-name" import { suitePageMetadata } from "@/lib/suite/page-metadata" type LayoutProps = { @@ -9,7 +9,9 @@ type LayoutProps = { export async function generateMetadata({ params }: LayoutProps): Promise { const { fileId } = await params - const name = displayFileName(decodeURIComponent(fileId)) + const raw = decodeURIComponent(fileId) + const baseName = raw.split("/").filter(Boolean).pop() ?? raw + const name = displayFileBaseName(baseName) return suitePageMetadata({ app: "drive", titleSegment: name, diff --git a/app/drive/s/[token]/edit/[[...path]]/page.tsx b/app/drive/s/[token]/edit/[[...path]]/page.tsx index be918c9..fb16674 100644 --- a/app/drive/s/[token]/edit/[[...path]]/page.tsx +++ b/app/drive/s/[token]/edit/[[...path]]/page.tsx @@ -13,6 +13,7 @@ export default function PublicShareEditPage() { const filePath = filePathFromPublicEditSegments(token, pathSegments) const returnTo = searchParams.get("returnTo") const mode = searchParams.get("mode") === "view" ? "view" : "edit" + const fileDisplayName = searchParams.get("name") ?? undefined const [password] = useState(() => { if (typeof window === "undefined") return undefined return sessionStorage.getItem(`public-share-pw:${token}`) ?? undefined @@ -25,6 +26,7 @@ export default function PublicShareEditPage() { password={password} returnTo={returnTo} mode={mode} + fileDisplayName={fileDisplayName} /> ) } diff --git a/app/globals.css b/app/globals.css index fa6b096..4883b7a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -719,6 +719,8 @@ html[data-mail-background]:not([data-mail-background='none']) .ultimail-app { position: relative; isolation: isolate; + --border: var(--mail-border); + --input: var(--mail-border); } /* Lignes de liste */ @@ -840,8 +842,8 @@ html.dark .ultimail-app { --muted-foreground: var(--mail-text-muted); --accent: var(--mail-nav-hover); --accent-foreground: var(--mail-text); - --border: var(--mail-border-subtle); - --input: var(--mail-border-subtle); + --border: var(--mail-border); + --input: var(--mail-border); --ring: var(--mail-border); } @@ -917,17 +919,73 @@ html.dark .ultimail-app :where(.tiptap code, .tiptap pre) { background-color: var(--mail-surface-muted); } -/* ── Dark : portails Radix & toasts (rendus hors .ultimail-app) ── */ -html.dark [data-slot='dropdown-menu-content'], -html.dark [data-slot='dropdown-menu-sub-content'], -html.dark [data-slot='context-menu-content'], -html.dark [data-slot='context-menu-sub-content'], -html.dark [data-slot='popover-content'], -html.dark [data-slot='select-content'], -html.dark [data-slot='menubar-content'] { - background-color: var(--popover) !important; - color: var(--popover-foreground) !important; - border-color: var(--border) !important; +/* Portails Radix (rendus hors .ultimail-app) — bordures/champs alignés sur le gris mail */ +:where( + [data-slot='dialog-content'], + [data-slot='alert-dialog-content'], + [data-slot='popover-content'], + [data-slot='hover-card-content'], + [data-slot='dropdown-menu-content'], + [data-slot='dropdown-menu-sub-content'], + [data-slot='context-menu-content'], + [data-slot='context-menu-sub-content'], + [data-slot='select-content'], + [data-slot='menubar-content'], + [data-slot='menubar-sub-content'], + [data-slot='tooltip-content'], + [data-slot='sheet-content'], + [data-slot='drawer-content'] +) { + --border: var(--mail-border); + --input: var(--mail-border); +} + +/* ── Dark : portails Radix (rendus hors .ultimail-app) ── */ +html.dark :where( + [data-slot='dialog-content'], + [data-slot='alert-dialog-content'], + [data-slot='popover-content'], + [data-slot='hover-card-content'], + [data-slot='dropdown-menu-content'], + [data-slot='dropdown-menu-sub-content'], + [data-slot='context-menu-content'], + [data-slot='context-menu-sub-content'], + [data-slot='select-content'], + [data-slot='menubar-content'], + [data-slot='menubar-sub-content'], + [data-slot='tooltip-content'], + [data-slot='sheet-content'], + [data-slot='drawer-content'] +) { + --background: var(--mail-surface-elevated); + --foreground: var(--mail-text); + --popover: var(--mail-surface-elevated); + --popover-foreground: var(--mail-text); + --card: var(--mail-surface-elevated); + --card-foreground: var(--mail-text); + --secondary: var(--mail-surface-muted); + --secondary-foreground: var(--mail-text); + --muted: var(--mail-surface-muted); + --muted-foreground: var(--mail-text-muted); + --accent: var(--mail-nav-hover); + --accent-foreground: var(--mail-text); + --border: var(--mail-border); + --input: var(--mail-border); + --ring: var(--mail-border); + background-color: var(--mail-surface-elevated) !important; + color: var(--mail-text) !important; + border-color: var(--mail-border) !important; +} + +html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) + :where([data-slot='input'], textarea, [data-slot='select-trigger']) { + background-color: var(--mail-surface-muted) !important; + border-color: var(--mail-border) !important; + color: var(--mail-text) !important; +} + +html.dark :where([data-slot='dialog-content'], [data-slot='sheet-content']) fieldset.border { + border-color: var(--mail-border) !important; } /* Drive / Contacts : menus portés — gris mail, pas le noir `popover`. */ @@ -960,13 +1018,13 @@ html.dark [data-slot='context-menu-item']:focus, html.dark [data-slot='context-menu-item'][data-highlighted], html.dark [data-slot='context-menu-sub-trigger']:focus, html.dark [data-slot='context-menu-sub-trigger'][data-state='open'] { - background-color: var(--accent) !important; - color: var(--accent-foreground) !important; + background-color: var(--mail-nav-hover) !important; + color: var(--mail-text) !important; } html.dark [data-slot='dropdown-menu-separator'], html.dark [data-slot='context-menu-separator'] { - background-color: var(--border) !important; + background-color: var(--mail-border-subtle) !important; } /* Recherche avancée — champs (sheet xs + panneau dropdown desktop) */ @@ -1012,12 +1070,14 @@ html.dark .ultimail-app :where(.text-\[#0f172a\], .text-\[#0b57d0\]) { color: var(--foreground) !important; } -html.dark .ultimail-app :where([data-slot='checkbox']) { - background-color: transparent; - border-color: #9aa0a6; -} - -html.dark .ultimail-app :where([data-slot='checkbox'][data-state='checked']) { +html.dark + :where( + .ultimail-app, + [data-slot='dialog-content'], + [data-slot='popover-content'], + [data-slot='sheet-content'] + ) + :where([data-slot='checkbox'][data-state='checked'], [data-slot='checkbox'][data-state='indeterminate']) { background-color: #1a73e8; border-color: #1a73e8; } @@ -1121,6 +1181,37 @@ html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] . border-color: var(--border) !important; } +/* Réglages mail : cartes cohérentes en dark mode (fond + bordure plus visible) */ +html.dark [data-mail-settings-main] { + --border: var(--mail-border); +} + +html.dark [data-mail-settings-main] :where( + .mail-settings-card, + [data-slot='card'], + [class*='rounded-lg'].border, + [class*='rounded-xl'].border, + [class*='rounded-md'].border +):not(.mail-settings-masonry-section) { + background-color: var(--mail-surface-elevated) !important; + border-color: var(--mail-border) !important; +} + +@media (min-width: 1024px) { + html.dark [data-mail-settings-main] .mail-settings-masonry-section { + background-color: var(--mail-surface-elevated) !important; + border-color: var(--mail-border) !important; + } +} + +html.dark [data-mail-settings-main] :where( + [class*='rounded-lg'][class*='border-dashed'], + [class*='rounded-xl'][class*='border-dashed'], + [class*='rounded-md'][class*='border-dashed'] +) { + border-color: color-mix(in srgb, var(--mail-border) 72%, transparent) !important; +} + /* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */ html.dark .ultimail-app :where(.bg-background) { background-color: var(--mail-surface-muted) !important; @@ -1132,6 +1223,6 @@ html.dark .ultimail-app :where(.bg-muted\/10, .bg-muted\/20, .bg-muted\/30, .bg- html.dark .ultimail-app :where([data-slot='input'], [data-slot='select-trigger'], [data-slot='textarea']) { background-color: var(--mail-surface-muted) !important; - border-color: var(--mail-border-subtle) !important; + border-color: var(--mail-border) !important; color: var(--mail-text) !important; } diff --git a/app/layout.tsx b/app/layout.tsx index 90baeb7..25181a2 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { ThemeInitScript } from '@/components/theme-init-script' import { FirstLaunchSplash } from '@/components/first-launch-splash' import { QueryProvider } from '@/lib/api/query-provider' import { AuthProvider } from '@/components/auth/auth-provider' +import { SessionGuard } from '@/components/auth/session-guard' import { MailToaster } from '@/components/gmail/mail-toaster' import { suiteRootMetadata } from '@/lib/suite/page-metadata' @@ -34,6 +35,7 @@ export default function RootLayout({ + {children} diff --git a/app/login/page.tsx b/app/login/page.tsx index 98a3ca8..f294f0a 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -16,7 +16,8 @@ import { cn } from "@/lib/utils" const LOGIN_CARD_CLASS = cn( "w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none", - "sm:gap-5 sm:bg-card sm:px-8 sm:py-8 sm:text-card-foreground sm:shadow-none" + "sm:gap-5 sm:bg-card sm:dark:bg-mail-surface-elevated sm:px-8 sm:py-8", + "sm:text-card-foreground sm:dark:text-mail-text sm:shadow-none" ) function LoginContent() { diff --git a/app/mail/settings/[[...section]]/page.tsx b/app/mail/settings/[[...section]]/page.tsx index dfaa91b..28ecd35 100644 --- a/app/mail/settings/[[...section]]/page.tsx +++ b/app/mail/settings/[[...section]]/page.tsx @@ -1,3 +1,4 @@ +import { redirect } from "next/navigation" import { MailSettingsSectionFromSegments } from "@/components/gmail/settings/mail-settings-section-view" export default async function MailSettingsSectionPage({ @@ -6,5 +7,8 @@ export default async function MailSettingsSectionPage({ params: Promise<{ section?: string[] }> }) { const { section } = await params + if (section?.[0] === "signatures") { + redirect("/mail/settings/accounts") + } return } diff --git a/components/admin/settings/guides/identity-provider-guides.ts b/components/admin/settings/guides/identity-provider-guides.ts new file mode 100644 index 0000000..fe8cc13 --- /dev/null +++ b/components/admin/settings/guides/identity-provider-guides.ts @@ -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 = { + 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 +} diff --git a/components/admin/settings/sections/authentication-section.tsx b/components/admin/settings/sections/authentication-section.tsx index 6da7301..751cebe 100644 --- a/components/admin/settings/sections/authentication-section.tsx +++ b/components/admin/settings/sections/authentication-section.tsx @@ -1,6 +1,7 @@ "use client" import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form" +import { IdentityProvidersSection } from "@/components/admin/settings/sections/identity-providers-section" import { DeployLockedHint, useDeployFieldLocked } from "@/components/admin/settings/deploy-locked-hint" import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store" import { Input } from "@/components/ui/input" @@ -22,12 +23,13 @@ export function AuthenticationSection() { const clientID = clientLocked ? (effective?.client_id ?? authentik.client_id) : authentik.client_id return ( - - +
+ +
@@ -106,6 +108,9 @@ export function AuthenticationSection() { - + + + +
) } diff --git a/components/admin/settings/sections/identity-providers-section.tsx b/components/admin/settings/sections/identity-providers-section.tsx new file mode 100644 index 0000000..53b333c --- /dev/null +++ b/components/admin/settings/sections/identity-providers-section.tsx @@ -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 Synchronisé + case "error": + return Erreur sync + default: + return En attente + } +} + +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(null) + const [newType, setNewType] = useState("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) { + 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 { + const idpSecrets = meta?.secrets?.identity_providers + if (!idpSecrets || typeof idpSecrets !== "object") return {} + const entry = (idpSecrets as Record>)[ + 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 ( + setIdentityProviders(draft)} + > + + +
+ + +
+ + {draft.providers.length === 0 ? ( +

+ Aucun fournisseur. Ajoutez Google Workspace, Azure AD SAML, LDAP AD ou un OAuth custom. +

+ ) : ( +
+ {draft.providers.map((provider, index) => ( +
+
+
+ + {provider.name || provider.slug || "Nouveau fournisseur"} + + {provider.type.toUpperCase()} + {syncBadge(provider.sync_status)} +
+

+ Slug : {provider.slug || "—"} + {provider.sync_error ? ` · ${provider.sync_error}` : ""} +

+
+
+ updateProvider(index, { enabled })} + /> + + + + +
+
+ ))} +
+ )} + + + + + Ajouter un fournisseur + +
+
+ + +
+ +
+
+
+ + !open && setEditIndex(null)}> + + {editingProvider && editIndex != null ? ( + <> + + Configurer le fournisseur + +
+
+
+
+ + { + const name = e.target.value + updateProvider(editIndex, { + name, + slug: editingProvider.slug || slugify(name), + }) + }} + /> +
+
+ + + updateProvider(editIndex, { slug: slugify(e.target.value) }) + } + /> +
+
+ + {editingProvider.type === "oauth" ? ( +
+
+ + +
+
+ + + updateProvider(editIndex, { + oauth: { ...editingProvider.oauth!, client_id: e.target.value }, + }) + } + /> +
+
+ + + updateProvider(editIndex, { + oauth: { + ...editingProvider.oauth!, + client_secret: e.target.value, + }, + }) + } + /> +
+
+ + + updateProvider(editIndex, { + oauth: { ...editingProvider.oauth!, scopes: e.target.value }, + }) + } + /> +
+ {editingProvider.oauth?.provider === "custom" ? ( + <> +
+ + + updateProvider(editIndex, { + oauth: { + ...editingProvider.oauth!, + authorization_url: e.target.value, + }, + }) + } + /> +
+
+ + + updateProvider(editIndex, { + oauth: { + ...editingProvider.oauth!, + token_url: e.target.value, + }, + }) + } + /> +
+
+ + + updateProvider(editIndex, { + oauth: { + ...editingProvider.oauth!, + profile_url: e.target.value, + }, + }) + } + /> +
+ + ) : null} +
+ +
+ + +
+
+
+ ) : null} + + {editingProvider.type === "saml" ? ( +
+
+ + + updateProvider(editIndex, { + saml: { ...editingProvider.saml!, metadata_url: e.target.value }, + }) + } + /> +
+
+ + + updateProvider(editIndex, { + saml: { ...editingProvider.saml!, entity_id: e.target.value }, + }) + } + /> +
+
+ + + updateProvider(editIndex, { + saml: { ...editingProvider.saml!, sso_url: e.target.value }, + }) + } + /> +
+
+ +