ultisuite-client/components/admin/settings/sections/identity-providers-section.tsx
R3D347HR4Y 9e9fd208ad
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(admin-settings): enhance admin settings with new components and layout improvements
- Introduced new components for managing admin settings, including AdminListControls, AdminSettingsCard, and TechBrandSelectLabel.
- Implemented dynamic loading for admin settings sections to optimize performance.
- Enhanced the layout of various admin settings sections for better user experience.
- Updated the AiAssistantSection to include LLM provider management and improved model selection.
- Refactored authentication settings to streamline configuration and improve accessibility.
2026-06-15 00:22:20 +02:00

800 lines
31 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import {
Copy,
Loader2,
Plus,
RefreshCw,
TestTube2,
Trash2,
} from "lucide-react"
import { guideForProvider } from "@/components/admin/settings/guides/identity-provider-guides"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
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"
const OAUTH_PRESET_LABELS: Record<OAuthProviderPreset, string> = {
google: "Google",
github: "GitHub",
linkedin: "LinkedIn",
microsoft: "Microsoft",
custom: "Autre / custom",
}
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 IdentityProvidersPanel({
onRegisterBeforeSave,
}: {
onRegisterBeforeSave?: (fn: (() => void) | null) => void
}) {
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")
}
}
useEffect(() => {
onRegisterBeforeSave?.(() => setIdentityProviders(draft))
return () => onRegisterBeforeSave?.(null)
}, [draft, onRegisterBeforeSave, setIdentityProviders])
return (
<>
<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">
<TechBrandSelectLabel brand="oauth">
OAuth (Google, GitHub, LinkedIn)
</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="saml">
<TechBrandSelectLabel brand="saml">
SAML (Azure AD, Okta)
</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="ldap">
<TechBrandSelectLabel brand="ldap">
LDAP / Active Directory
</TechBrandSelectLabel>
</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>
{editingProvider.oauth?.provider ? (
<TechBrandSelectLabel brand={editingProvider.oauth.provider}>
{OAUTH_PRESET_LABELS[editingProvider.oauth.provider]}
</TechBrandSelectLabel>
) : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="google">
<TechBrandSelectLabel brand="google">Google</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="github">
<TechBrandSelectLabel brand="github">GitHub</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="linkedin">
<TechBrandSelectLabel brand="linkedin">LinkedIn</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="microsoft">
<TechBrandSelectLabel brand="microsoft">Microsoft</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="custom">
<TechBrandSelectLabel brand="custom">Autre / custom</TechBrandSelectLabel>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Client ID</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth?.client_id ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: { ...editingProvider.oauth!, client_id: e.target.value },
})
}
/>
</div>
<div>
<Label>Client Secret</Label>
<Input
className="mt-1 h-9"
type="password"
placeholder={
providerSecrets(editingProvider).oauth_client_secret?.configured
? "Laisser vide pour conserver"
: "Secret OAuth"
}
value={editingProvider.oauth?.client_secret ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: {
...editingProvider.oauth!,
client_secret: e.target.value,
},
})
}
/>
</div>
<div>
<Label>Scopes</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth?.scopes ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: { ...editingProvider.oauth!, scopes: e.target.value },
})
}
/>
</div>
{editingProvider.oauth?.provider === "custom" ? (
<>
<div>
<Label>Authorization URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth.authorization_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: {
...editingProvider.oauth!,
authorization_url: e.target.value,
},
})
}
/>
</div>
<div>
<Label>Token URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth.token_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: {
...editingProvider.oauth!,
token_url: e.target.value,
},
})
}
/>
</div>
<div>
<Label>Profile URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.oauth.profile_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
oauth: {
...editingProvider.oauth!,
profile_url: e.target.value,
},
})
}
/>
</div>
</>
) : null}
<div>
<Label>URI de redirection</Label>
<div className="mt-1 flex gap-2">
<Input
className="h-9"
readOnly
value={redirectTemplate.replace(
"{slug}",
editingProvider.slug || "votre-slug"
)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyRedirect(editingProvider.slug)}
>
<Copy className="size-4" />
</Button>
</div>
</div>
</div>
) : null}
{editingProvider.type === "saml" ? (
<div className="space-y-4 rounded-lg border p-4">
<div>
<Label>Metadata URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.saml?.metadata_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
saml: { ...editingProvider.saml!, metadata_url: e.target.value },
})
}
/>
</div>
<div>
<Label>Entity ID / Issuer</Label>
<Input
className="mt-1 h-9"
value={editingProvider.saml?.entity_id ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
saml: { ...editingProvider.saml!, entity_id: e.target.value },
})
}
/>
</div>
<div>
<Label>SSO URL</Label>
<Input
className="mt-1 h-9"
value={editingProvider.saml?.sso_url ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
saml: { ...editingProvider.saml!, sso_url: e.target.value },
})
}
/>
</div>
<div>
<Label>Certificat signing (PEM)</Label>
<Textarea
className="mt-1 min-h-24 font-mono text-xs"
placeholder={
providerSecrets(editingProvider).saml_signing_cert?.configured
? "Laisser vide pour conserver"
: "-----BEGIN CERTIFICATE-----"
}
value={editingProvider.saml?.signing_cert ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
saml: {
...editingProvider.saml!,
signing_cert: e.target.value,
},
})
}
/>
</div>
</div>
) : null}
{editingProvider.type === "ldap" ? (
<div className="space-y-4 rounded-lg border p-4">
<div>
<Label>Server URI</Label>
<Input
className="mt-1 h-9"
placeholder="ldaps://ad.company.com:636"
value={editingProvider.ldap?.server_uri ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, server_uri: e.target.value },
})
}
/>
</div>
<div>
<Label>Bind DN</Label>
<Input
className="mt-1 h-9"
value={editingProvider.ldap?.bind_dn ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, bind_dn: e.target.value },
})
}
/>
</div>
<div>
<Label>Bind password</Label>
<Input
className="mt-1 h-9"
type="password"
placeholder={
providerSecrets(editingProvider).ldap_bind_password?.configured
? "Laisser vide pour conserver"
: "Mot de passe LDAP"
}
value={editingProvider.ldap?.bind_password ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: {
...editingProvider.ldap!,
bind_password: e.target.value,
},
})
}
/>
</div>
<div>
<Label>Base DN</Label>
<Input
className="mt-1 h-9"
value={editingProvider.ldap?.base_dn ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, base_dn: e.target.value },
})
}
/>
</div>
<div>
<Label>Filtre utilisateur</Label>
<Input
className="mt-1 h-9"
placeholder="(sAMAccountName=%(user)s)"
value={editingProvider.ldap?.user_filter ?? ""}
onChange={(e) =>
updateProvider(editIndex, {
ldap: {
...editingProvider.ldap!,
user_filter: e.target.value,
},
})
}
/>
</div>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<span className="text-sm">StartTLS</span>
<Switch
checked={editingProvider.ldap?.start_tls ?? true}
onCheckedChange={(start_tls) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, start_tls },
})
}
/>
</label>
<label className="flex items-center justify-between gap-4 rounded-lg border p-3">
<span className="text-sm">Synchroniser les utilisateurs LDAP</span>
<Switch
checked={editingProvider.ldap?.sync_users ?? false}
onCheckedChange={(sync_users) =>
updateProvider(editIndex, {
ldap: { ...editingProvider.ldap!, sync_users },
})
}
/>
</label>
</div>
) : null}
<div className="space-y-3 rounded-lg border p-4">
<p className="text-sm font-medium">Restrictions d&apos;accès</p>
<div>
<Label>Domaines email autorisés</Label>
<Textarea
className="mt-1 min-h-20"
placeholder="company.com&#10;subsidiary.fr"
value={joinList(editingProvider.allowed_email_domains)}
onChange={(e) =>
updateProvider(editIndex, {
allowed_email_domains: splitList(e.target.value),
})
}
/>
</div>
<div>
<Label>Identités autorisées (emails)</Label>
<Textarea
className="mt-1 min-h-20"
placeholder="admin@company.com"
value={joinList(editingProvider.allowed_identities)}
onChange={(e) =>
updateProvider(editIndex, {
allowed_identities: splitList(e.target.value),
})
}
/>
</div>
<div>
<Label>Organisations autorisées</Label>
<Textarea
className="mt-1 min-h-20"
placeholder="tenant-id Azure, domaine Google hd, org GitHub…"
value={joinList(editingProvider.allowed_organizations)}
onChange={(e) =>
updateProvider(editIndex, {
allowed_organizations: splitList(e.target.value),
})
}
/>
</div>
<div>
<Label>Groupes Authentik par défaut</Label>
<Input
className="mt-1 h-9"
placeholder="ulti-users, ulti-admins"
value={joinList(editingProvider.default_groups).replace(/\n/g, ", ")}
onChange={(e) =>
updateProvider(editIndex, {
default_groups: splitList(e.target.value.replace(/,/g, "\n")),
})
}
/>
</div>
</div>
</div>
{guide ? (
<aside className="rounded-lg border bg-muted/30 p-4">
<p className="text-sm font-medium">{guide.title}</p>
<ol className="mt-3 list-decimal space-y-2 pl-4 text-xs text-muted-foreground">
{guide.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</aside>
) : null}
</div>
</>
) : null}
</SheetContent>
</Sheet>
</>
)
}