feat: move sso and ldap logins into plugins (C2)

This commit is contained in:
LukeGus committed 2026-09-24 18:23:20 -05:00
1 parent 5c18435105
commit 925da4a661
223 files changed
+25778 -6820

No files matched your search

+1 -89
View File
@@ -57,12 +57,6 @@ import {
uploadManualSslCertificate,
type AcmeSettings,
} from "@/api/acme-ssl-api";
import {
getAdminSSOProviders,
updateSSOProvider,
deleteSSOProvider,
} from "@/api/sso-provider-api";
import type { SSOProvider } from "@/types/index";
import {
type ApiKey,
type CreatedApiKey,
@@ -85,10 +79,8 @@ import {
AdminDatabaseSection,
AdminGeneralSettingsSection,
AdminHostDefaultsSection,
AdminSSOSection,
AdminSSLSection,
} from "./AdminSettingsSections";
import { SSOProviderDialog } from "./SSOProviderDialog";
import { AdminApiKeysSection } from "./AdminApiKeysSection";
import { AdminAuditLogSection } from "./AdminAuditLogSection";
import {
@@ -168,15 +160,11 @@ export function AdminSettingsPanel({
const [brandingSaving, setBrandingSaving] = useState(false);
const { applyBranding } = useBranding();
// SSO / auto-provision state
// External login state
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
const [secondFactorAfterExternalLogin, setSecondFactorAfterExternalLogin] =
useState(false);
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
const [ssoDialogOpen, setSsoDialogOpen] = useState(false);
const [ssoDialogProvider, setSsoDialogProvider] =
useState<SSOProvider | null>(null);
// Create user dialog
const [createUserOpen, setCreateUserOpen] = useState(false);
@@ -259,7 +247,6 @@ export function AdminSettingsPanel({
loadRoles();
loadApiKeys();
loadGeneralSettings();
loadSSOProviders();
}, []);
// Debounced so typing in the search box does not fire a request per keystroke.
@@ -414,15 +401,6 @@ export function AdminSettingsPanel({
.catch(() => {});
}
async function loadSSOProviders() {
try {
const providers = await getAdminSSOProviders();
setSsoProviders(providers);
} catch {
// non-fatal
}
}
function toggle(id: AdminSection) {
setOpenSections((prev) => {
const next = new Set(prev);
@@ -627,43 +605,6 @@ export function AdminSettingsPanel({
}
}
function handleAddProvider() {
setSsoDialogProvider(null);
setSsoDialogOpen(true);
}
function handleEditProvider(provider: SSOProvider) {
setSsoDialogProvider(provider);
setSsoDialogOpen(true);
}
async function handleDeleteProvider(id: number) {
if (!window.confirm(t("admin.ssoDeleteConfirm"))) return;
try {
await deleteSSOProvider(id);
setSsoProviders((prev) => prev.filter((p) => p.id !== id));
toast.success(t("common.deleted"));
} catch (e) {
toast.error(apiErrorMessage(e, t("common.deleteFailed")));
}
}
async function handleToggleProviderEnabled(id: number, enabled: boolean) {
const provider = ssoProviders.find((p) => p.id === id);
if (!provider) return;
setSsoProviders((prev) =>
prev.map((p) => (p.id === id ? { ...p, enabled } : p)),
);
try {
await updateSSOProvider(id, { enabled });
} catch (e) {
setSsoProviders((prev) =>
prev.map((p) => (p.id === id ? { ...p, enabled: !enabled } : p)),
);
toast.error(apiErrorMessage(e, t("common.saveFailed")));
}
}
async function handleSaveAcmeSettings() {
try {
const payload: Parameters<typeof updateAcmeSslSettings>[0] = {
@@ -736,18 +677,6 @@ export function AdminSettingsPanel({
}
}
function handleProviderSaved(saved: SSOProvider) {
setSsoProviders((prev) => {
const idx = prev.findIndex((p) => p.id === saved.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = saved;
return next;
}
return [...prev, saved];
});
}
async function handleCreateUser() {
if (!newUsername.trim() || !newPassword.trim()) {
toast.error(t("admin.createUserRequired"));
@@ -1043,23 +972,6 @@ export function AdminSettingsPanel({
handleSaveLogLevel={handleSaveLogLevel}
/>
<AdminSSOSection
open={openSections.has("sso")}
onToggle={() => toggle("sso")}
providers={ssoProviders}
onAddProvider={handleAddProvider}
onEditProvider={handleEditProvider}
onDeleteProvider={handleDeleteProvider}
onToggleEnabled={handleToggleProviderEnabled}
/>
<SSOProviderDialog
open={ssoDialogOpen}
onOpenChange={setSsoDialogOpen}
provider={ssoDialogProvider}
onSaved={handleProviderSaved}
/>
<AdminUsersSection
open={openSections.has("users")}
onToggle={() => toggle("users")}
+1 -383
View File
@@ -5,19 +5,8 @@ import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { SettingRow } from "@/components/section-card";
import { Select2 } from "@/components/select2";
import {
Database,
Lock,
Pencil,
Plus,
RefreshCw,
Server,
Settings,
Shield,
Trash2,
} from "lucide-react";
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import type { SSOProvider, SSOProviderType } from "@/types/index";
import type { HostDefaults } from "@/api/settings-api";
import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
@@ -355,268 +344,6 @@ export function AdminGeneralSettingsSection({
);
}
type OidcSettingsSectionProps = {
open: boolean;
onToggle: () => void;
oidcClientId: string;
setOidcClientId: Dispatch<SetStateAction<string>>;
oidcClientSecret: string;
setOidcClientSecret: Dispatch<SetStateAction<string>>;
oidcAuthUrl: string;
setOidcAuthUrl: Dispatch<SetStateAction<string>>;
oidcIssuerUrl: string;
setOidcIssuerUrl: Dispatch<SetStateAction<string>>;
oidcTokenUrl: string;
setOidcTokenUrl: Dispatch<SetStateAction<string>>;
oidcUserIdentifier: string;
setOidcUserIdentifier: Dispatch<SetStateAction<string>>;
oidcDisplayName: string;
setOidcDisplayName: Dispatch<SetStateAction<string>>;
oidcScopes: string;
setOidcScopes: Dispatch<SetStateAction<string>>;
oidcUserinfoUrl: string;
setOidcUserinfoUrl: Dispatch<SetStateAction<string>>;
oidcAllowedUsers: string;
setOidcAllowedUsers: Dispatch<SetStateAction<string>>;
oidcAdminGroup: string;
setOidcAdminGroup: Dispatch<SetStateAction<string>>;
oidcGroupClaim: string;
setOidcGroupClaim: Dispatch<SetStateAction<string>>;
oidcSaving: boolean;
handleRemoveOidc: () => void;
handleSaveOidc: () => void;
};
export function AdminOidcSettingsSection({
open,
onToggle,
oidcClientId,
setOidcClientId,
oidcClientSecret,
setOidcClientSecret,
oidcAuthUrl,
setOidcAuthUrl,
oidcIssuerUrl,
setOidcIssuerUrl,
oidcTokenUrl,
setOidcTokenUrl,
oidcUserIdentifier,
setOidcUserIdentifier,
oidcDisplayName,
setOidcDisplayName,
oidcScopes,
setOidcScopes,
oidcUserinfoUrl,
setOidcUserinfoUrl,
oidcAllowedUsers,
setOidcAllowedUsers,
oidcAdminGroup,
setOidcAdminGroup,
oidcGroupClaim,
setOidcGroupClaim,
oidcSaving,
handleRemoveOidc,
handleSaveOidc,
}: OidcSettingsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionOidc")}
icon={<Shield className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcDescription").split("*")[0]}
<span className="text-accent-brand">*</span>
{t("admin.oidcDescription").split("*")[1]}{" "}
<a
href="https://docs.termix.site/features/authentication/oidc"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.oidcDocsLink")}
</a>
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcClientId")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcClientId}
onChange={(e) => setOidcClientId(e.target.value)}
placeholder="your-client-id"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcClientSecret")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
type="password"
value={oidcClientSecret}
onChange={(e) => setOidcClientSecret(e.target.value)}
placeholder="your-client-secret"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcAuthUrl")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcAuthUrl}
onChange={(e) => setOidcAuthUrl(e.target.value)}
placeholder="https://provider/oauth2/auth"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcIssuerUrl")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcIssuerUrl}
onChange={(e) => setOidcIssuerUrl(e.target.value)}
placeholder="https://provider"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcTokenUrl")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcTokenUrl}
onChange={(e) => setOidcTokenUrl(e.target.value)}
placeholder="https://provider/oauth2/token"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcUserIdentifier")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcUserIdentifier}
onChange={(e) => setOidcUserIdentifier(e.target.value)}
placeholder="sub"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcDisplayName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={oidcDisplayName}
onChange={(e) => setOidcDisplayName(e.target.value)}
placeholder="name"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcScopes")} <span className="text-accent-brand">*</span>
</label>
<Input
value={oidcScopes}
onChange={(e) => setOidcScopes(e.target.value)}
placeholder="openid email profile"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcUserinfoUrl")}
</label>
<Input
value={oidcUserinfoUrl}
onChange={(e) => setOidcUserinfoUrl(e.target.value)}
placeholder="https://provider/oauth2/userinfo"
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcAllowedUsers")}
</label>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcAllowedUsersDesc")}
</span>
<textarea
value={oidcAllowedUsers}
onChange={(e) => setOidcAllowedUsers(e.target.value)}
placeholder={"user@example.com\nanother@example.com"}
rows={3}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcAdminGroup")}
</label>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcAdminGroupDesc")}
</span>
<input
value={oidcAdminGroup}
onChange={(e) => setOidcAdminGroup(e.target.value)}
placeholder="admin"
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.oidcGroupClaim")}
</label>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcGroupClaimDesc")}
</span>
<input
value={oidcGroupClaim}
onChange={(e) => setOidcGroupClaim(e.target.value)}
placeholder="groups"
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex gap-2 justify-end">
<Button
variant="outline"
size="sm"
className="text-xs border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleRemoveOidc}
>
<Trash2 className="size-3" />
{t("admin.removeOidc")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSaveOidc}
disabled={oidcSaving}
>
<RefreshCw className="size-3" />
{oidcSaving ? t("admin.saving") : t("common.save")}
</Button>
</div>
</div>
</AccordionSection>
);
}
type DatabaseSectionProps = {
open: boolean;
onToggle: () => void;
@@ -708,115 +435,6 @@ export function AdminDatabaseSection({
);
}
const SSO_TYPE_LABELS: Record<SSOProviderType, string> = {
oidc: "OIDC",
ldap: "LDAP",
github: "GitHub",
google: "Google",
};
type AdminSSOSectionProps = {
open: boolean;
onToggle: () => void;
providers: SSOProvider[];
onAddProvider: () => void;
onEditProvider: (provider: SSOProvider) => void;
onDeleteProvider: (id: number) => void;
onToggleEnabled: (id: number, enabled: boolean) => void;
};
export function AdminSSOSection({
open,
onToggle,
providers,
onAddProvider,
onEditProvider,
onDeleteProvider,
onToggleEnabled,
}: AdminSSOSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionSso")}
icon={<Shield className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
<a
href="https://docs.termix.site/features/authentication/sso-providers"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.ssoDocsLink")}
</a>
</span>
{providers.length === 0 ? (
<span className="text-[10px] text-muted-foreground">
{t("admin.ssoNoProviders")}
</span>
) : (
<div className="flex flex-col gap-2">
{providers.map((provider) => (
<div
key={provider.id}
className="flex items-center gap-2 p-2 border border-border bg-background"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium truncate">
{provider.name}
</span>
<span className="text-[9px] px-1 py-0.5 bg-muted text-muted-foreground font-mono uppercase">
{SSO_TYPE_LABELS[provider.type] ?? provider.type}
</span>
</div>
</div>
<AdminToggle
on={provider.enabled}
onToggle={() =>
onToggleEnabled(provider.id, !provider.enabled)
}
/>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={() => onEditProvider(provider)}
title={t("admin.ssoEditProvider")}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onDeleteProvider(provider.id)}
title={t("admin.ssoDeleteProvider")}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={onAddProvider}
>
<Plus className="size-3" />
{t("admin.ssoAddProvider")}
</Button>
</div>
</AccordionSection>
);
}
type AdminHostDefaultsSectionProps = {
open: boolean;
onToggle: () => void;
-726
View File
@@ -1,726 +0,0 @@
import { useState, useEffect, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { toast } from "sonner";
import {
type LDAPProviderConfig,
type OIDCProviderConfig,
type SSOProvider,
type SSOProviderType,
} from "@/types/index";
import { createSSOProvider, updateSSOProvider } from "@/api/sso-provider-api";
import { Select2 } from "@/components/select2";
type ApiErrorLike = {
response?: { data?: { error?: string } };
message?: string;
};
function apiErrorMsg(error: unknown, fallback: string) {
const err = error as ApiErrorLike;
return err.response?.data?.error || err.message || fallback;
}
const PROVIDER_TYPE_OPTIONS: { value: SSOProviderType; label: string }[] = [
{ value: "oidc", label: "OIDC" },
{ value: "github", label: "GitHub" },
{ value: "google", label: "Google" },
{ value: "ldap", label: "LDAP" },
];
const githubDefaults = {
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
issuer_url: "https://token.actions.githubusercontent.com",
userinfo_url: "https://api.github.com/user",
scopes: "read:user user:email",
identifier_path: "id",
name_path: "name",
};
const googleDefaults = {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
issuer_url: "https://accounts.google.com",
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
scopes: "openid email profile",
identifier_path: "sub",
name_path: "name",
};
type SSOProviderDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: SSOProvider | null;
onSaved: (provider: SSOProvider) => void;
};
function emptyOidc(): OIDCProviderConfig {
return {
client_id: "",
client_secret: "",
issuer_url: "",
authorization_url: "",
token_url: "",
userinfo_url: "",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
allowed_users: "",
admin_group: "",
group_claim: "",
ca_cert: "",
};
}
function emptyLdap(): Required<LDAPProviderConfig> {
return {
host: "",
port: 389,
useTLS: false,
bindDN: "",
bindPassword: "",
userSearchBase: "",
userSearchFilter: "(uid={{username}})",
usernameAttribute: "uid",
displayNameAttribute: "cn",
groupSearchBase: "",
adminGroup: "",
allowedUsers: "",
};
}
type OIDCFields = {
client_id: string;
client_secret: string;
issuer_url: string;
authorization_url: string;
token_url: string;
userinfo_url: string;
identifier_path: string;
name_path: string;
scopes: string;
allowed_users: string;
admin_group: string;
group_claim: string;
ca_cert: string;
};
type LDAPFields = {
host: string;
port: string;
useTLS: boolean;
bindDN: string;
bindPassword: string;
userSearchBase: string;
userSearchFilter: string;
usernameAttribute: string;
displayNameAttribute: string;
groupSearchBase: string;
adminGroup: string;
allowedUsers: string;
};
export function SSOProviderDialog({
open,
onOpenChange,
provider,
onSaved,
}: SSOProviderDialogProps) {
const { t } = useTranslation();
const isEdit = provider != null;
const [name, setName] = useState("");
const [type, setType] = useState<SSOProviderType>("oidc");
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
const [oidc, setOidc] = useState<OIDCFields>(
() => ({ ...emptyOidc() }) as OIDCFields,
);
const [ldap, setLdap] = useState<LDAPFields>(() => {
const d = emptyLdap();
return { ...d, port: String(d.port) };
});
useEffect(() => {
if (!open) return;
if (provider) {
setName(provider.name);
setType(provider.type);
setEnabled(provider.enabled);
let config: Record<string, unknown> = {};
if (typeof provider.config === "string") {
try {
config = JSON.parse(provider.config);
} catch {
/* */
}
} else if (provider.config && typeof provider.config === "object") {
config = provider.config as Record<string, unknown>;
}
if (provider.type === "ldap") {
const d = emptyLdap();
setLdap({
host: (config.host as string) ?? d.host,
port: String((config.port as number) ?? d.port),
useTLS: (config.useTLS as boolean) ?? d.useTLS,
bindDN: (config.bindDN as string) ?? d.bindDN,
bindPassword: (config.bindPassword as string) ?? "",
userSearchBase: (config.userSearchBase as string) ?? d.userSearchBase,
userSearchFilter:
(config.userSearchFilter as string) ?? d.userSearchFilter,
usernameAttribute:
(config.usernameAttribute as string) ?? d.usernameAttribute,
displayNameAttribute:
(config.displayNameAttribute as string) ?? d.displayNameAttribute,
groupSearchBase: (config.groupSearchBase as string) ?? "",
adminGroup: (config.adminGroup as string) ?? "",
allowedUsers: (config.allowedUsers as string) ?? "",
});
} else {
const d = emptyOidc();
setOidc({
client_id: (config.client_id as string) ?? d.client_id,
client_secret: (config.client_secret as string) ?? "",
issuer_url: (config.issuer_url as string) ?? d.issuer_url,
authorization_url:
(config.authorization_url as string) ?? d.authorization_url,
token_url: (config.token_url as string) ?? d.token_url,
userinfo_url: (config.userinfo_url as string) ?? d.userinfo_url,
identifier_path:
(config.identifier_path as string) ?? d.identifier_path,
name_path: (config.name_path as string) ?? d.name_path,
scopes: (config.scopes as string) ?? d.scopes,
allowed_users: (config.allowed_users as string) ?? d.allowed_users,
admin_group: (config.admin_group as string) ?? d.admin_group,
group_claim: (config.group_claim as string) ?? "",
ca_cert: (config.ca_cert as string) ?? "",
});
}
} else {
setName("");
setType("oidc");
setEnabled(true);
setOidc({ ...emptyOidc() } as OIDCFields);
const d = emptyLdap();
setLdap({ ...d, port: String(d.port) });
}
}, [open, provider]);
function setOidcField<K extends keyof OIDCFields>(
field: K,
value: OIDCFields[K],
) {
setOidc((prev) => ({ ...prev, [field]: value }));
}
function setLdapField<K extends keyof LDAPFields>(
field: K,
value: LDAPFields[K],
) {
setLdap((prev) => ({ ...prev, [field]: value }));
}
function buildConfig(): Record<string, unknown> {
if (type === "ldap") {
return {
host: ldap.host,
port: parseInt(ldap.port, 10) || 389,
useTLS: ldap.useTLS,
bindDN: ldap.bindDN,
bindPassword: ldap.bindPassword,
userSearchBase: ldap.userSearchBase,
userSearchFilter: ldap.userSearchFilter,
usernameAttribute: ldap.usernameAttribute || "uid",
displayNameAttribute: ldap.displayNameAttribute || "cn",
groupSearchBase: ldap.groupSearchBase || undefined,
adminGroup: ldap.adminGroup || undefined,
allowedUsers: ldap.allowedUsers || undefined,
};
}
const providerDefaults =
type === "google"
? googleDefaults
: type === "github"
? githubDefaults
: null;
return {
client_id: oidc.client_id,
client_secret: oidc.client_secret,
issuer_url: oidc.issuer_url || providerDefaults?.issuer_url || "",
authorization_url:
oidc.authorization_url || providerDefaults?.authorization_url || "",
token_url: oidc.token_url || providerDefaults?.token_url || "",
userinfo_url:
oidc.userinfo_url || providerDefaults?.userinfo_url || undefined,
identifier_path:
oidc.identifier_path || providerDefaults?.identifier_path || "sub",
name_path: oidc.name_path || providerDefaults?.name_path || "name",
scopes: oidc.scopes || providerDefaults?.scopes || "openid email profile",
allowed_users: oidc.allowed_users || undefined,
admin_group: oidc.admin_group || undefined,
group_claim: oidc.group_claim || undefined,
ca_cert: oidc.ca_cert || undefined,
};
}
async function handleSave() {
if (!name.trim()) {
toast.error(
t("admin.ssoProviderName") + " " + t("common.required").toLowerCase(),
);
return;
}
setSaving(true);
try {
const data = {
name: name.trim(),
type,
enabled,
displayOrder: 0,
config: buildConfig(),
};
let saved: SSOProvider;
if (isEdit && provider) {
saved = await updateSSOProvider(provider.id, data);
} else {
saved = await createSSOProvider(data);
}
toast.success(t("common.saved"));
onSaved(saved);
onOpenChange(false);
} catch (err) {
toast.error(apiErrorMsg(err, t("common.saveFailed")));
} finally {
setSaving(false);
}
}
const isGithubOrGoogle = type === "github" || type === "google";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? t("admin.ssoEditProvider") : t("admin.ssoAddProvider")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 pt-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.ssoProviderName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My SSO Provider"
className="text-xs"
/>
</div>
{!isEdit && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.ssoProviderType")}
</label>
<Select2
value={type}
onChange={(e) => setType(e.target.value as SSOProviderType)}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{PROVIDER_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select2>
</div>
)}
<div className="flex items-center justify-between">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.ssoEnabled")}
</label>
<button
type="button"
onClick={() => setEnabled((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${enabled ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${enabled ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
</div>
<div className="border-t border-border pt-3">
{type === "ldap" ? (
<LDAPConfigFields ldap={ldap} setLdapField={setLdapField} t={t} />
) : (
<OIDCConfigFields
oidc={oidc}
setOidcField={setOidcField}
type={type}
simplified={isGithubOrGoogle}
t={t}
/>
)}
</div>
<div className="flex gap-2 justify-end pt-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
variant="outline"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{saving ? t("admin.saving") : t("admin.ssoSaveProvider")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{label}
{required && <span className="text-accent-brand ml-1">*</span>}
</label>
{children}
</div>
);
}
function OIDCConfigFields({
oidc,
setOidcField,
type,
simplified,
t,
}: {
oidc: OIDCFields;
setOidcField: <K extends keyof OIDCFields>(k: K, v: OIDCFields[K]) => void;
type: SSOProviderType;
simplified: boolean;
t: (key: string) => string;
}) {
const docsHref = simplified
? "https://docs.termix.site/features/authentication/github-google"
: "https://docs.termix.site/features/authentication/oidc";
return (
<div className="flex flex-col gap-3">
<span className="text-[10px] text-muted-foreground">
<a
href={docsHref}
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.ssoProviderDocsLink")}
</a>
</span>
<Field label={t("admin.oidcClientId")} required>
<Input
value={oidc.client_id}
onChange={(e) => setOidcField("client_id", e.target.value)}
placeholder="your-client-id"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcClientSecret")} required>
<Input
type="password"
value={oidc.client_secret}
onChange={(e) => setOidcField("client_secret", e.target.value)}
placeholder="your-client-secret"
className="text-xs"
/>
</Field>
{simplified ? (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground">
{type === "github"
? `Authorization URL: ${githubDefaults.authorization_url}`
: `Authorization URL: ${googleDefaults.authorization_url}`}
</span>
</div>
) : (
<>
<Field label={t("admin.oidcIssuerUrl")} required>
<Input
value={oidc.issuer_url}
onChange={(e) => setOidcField("issuer_url", e.target.value)}
placeholder="https://provider"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcAuthUrl")} required>
<Input
value={oidc.authorization_url}
onChange={(e) =>
setOidcField("authorization_url", e.target.value)
}
placeholder="https://provider/oauth2/auth"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcTokenUrl")} required>
<Input
value={oidc.token_url}
onChange={(e) => setOidcField("token_url", e.target.value)}
placeholder="https://provider/oauth2/token"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcUserIdentifier")} required>
<Input
value={oidc.identifier_path}
onChange={(e) => setOidcField("identifier_path", e.target.value)}
placeholder="sub"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcDisplayName")} required>
<Input
value={oidc.name_path}
onChange={(e) => setOidcField("name_path", e.target.value)}
placeholder="name"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcScopes")} required>
<Input
value={oidc.scopes}
onChange={(e) => setOidcField("scopes", e.target.value)}
placeholder="openid email profile"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcUserinfoUrl")}>
<Input
value={oidc.userinfo_url}
onChange={(e) => setOidcField("userinfo_url", e.target.value)}
placeholder="https://provider/oauth2/userinfo"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcGroupClaim")}>
<Input
value={oidc.group_claim}
onChange={(e) => setOidcField("group_claim", e.target.value)}
placeholder="groups"
className="text-xs"
/>
</Field>
</>
)}
<Field label={t("admin.oidcAllowedUsers")}>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcAllowedUsersDesc")}
</span>
<textarea
value={oidc.allowed_users}
onChange={(e) => setOidcField("allowed_users", e.target.value)}
placeholder={"user@example.com\nanother@example.com"}
rows={3}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</Field>
<Field label={t("admin.oidcAdminGroup")}>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcAdminGroupDesc")}
</span>
<Input
value={oidc.admin_group}
onChange={(e) => setOidcField("admin_group", e.target.value)}
placeholder="admin"
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcCaCert")}>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcCaCertDesc")}
</span>
<textarea
value={oidc.ca_cert}
onChange={(e) => setOidcField("ca_cert", e.target.value)}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
rows={4}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</Field>
</div>
);
}
function LDAPConfigFields({
ldap,
setLdapField,
t,
}: {
ldap: LDAPFields;
setLdapField: <K extends keyof LDAPFields>(k: K, v: LDAPFields[K]) => void;
t: (key: string) => string;
}) {
return (
<div className="flex flex-col gap-3">
<span className="text-[10px] text-muted-foreground">
<a
href="https://docs.termix.site/features/authentication/ldap"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.ssoProviderDocsLink")}
</a>
</span>
<Field label={t("admin.ldapHost")} required>
<Input
value={ldap.host}
onChange={(e) => setLdapField("host", e.target.value)}
placeholder="ldap.example.com"
className="text-xs"
/>
</Field>
<div className="flex gap-2">
<div className="flex-1">
<Field label={t("admin.ldapPort")} required>
<Input
value={ldap.port}
onChange={(e) => setLdapField("port", e.target.value)}
placeholder="389"
className="text-xs"
/>
</Field>
</div>
<div className="flex items-end pb-0.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setLdapField("useTLS", !ldap.useTLS)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${ldap.useTLS ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${ldap.useTLS ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
<span className="text-[10px] text-muted-foreground">
{t("admin.ldapUseTls")}
</span>
</div>
</div>
</div>
<Field label={t("admin.ldapBindDn")} required>
<Input
value={ldap.bindDN}
onChange={(e) => setLdapField("bindDN", e.target.value)}
placeholder="cn=admin,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapBindPassword")} required>
<Input
type="password"
value={ldap.bindPassword}
onChange={(e) => setLdapField("bindPassword", e.target.value)}
placeholder="bind password"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapUserSearchBase")} required>
<Input
value={ldap.userSearchBase}
onChange={(e) => setLdapField("userSearchBase", e.target.value)}
placeholder="ou=users,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapUserSearchFilter")} required>
<Input
value={ldap.userSearchFilter}
onChange={(e) => setLdapField("userSearchFilter", e.target.value)}
placeholder="(uid={{username}})"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapUsernameAttr")} required>
<Input
value={ldap.usernameAttribute}
onChange={(e) => setLdapField("usernameAttribute", e.target.value)}
placeholder="uid"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapDisplayNameAttr")} required>
<Input
value={ldap.displayNameAttribute}
onChange={(e) => setLdapField("displayNameAttribute", e.target.value)}
placeholder="cn"
className="text-xs"
/>
</Field>
<div className="border-t border-border pt-3 flex flex-col gap-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.ldapGroupSearchBase")}
</span>
<Field label={t("admin.ldapGroupSearchBase")}>
<Input
value={ldap.groupSearchBase}
onChange={(e) => setLdapField("groupSearchBase", e.target.value)}
placeholder="ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapAdminGroup")}>
<Input
value={ldap.adminGroup}
onChange={(e) => setLdapField("adminGroup", e.target.value)}
placeholder="cn=admins,ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("admin.ldapAllowedUsers")}>
<Input
value={ldap.allowedUsers}
onChange={(e) => setLdapField("allowedUsers", e.target.value)}
placeholder="user1,user2,@domain.com"
className="text-xs"
/>
</Field>
</div>
</div>
);
}