fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

This commit is contained in:
LukeGus committed 2026-10-03 16:48:03 -05:00
1 parent 0f4cdcbdfa
commit 58577eafe9
4 files changed
+368 -374

No files matched your search

+7
View File
@@ -23,6 +23,12 @@
"namePlaceholder": "Company directory", "namePlaceholder": "Company directory",
"nameRequired": "A display name is required", "nameRequired": "A display name is required",
"enabled": "Enabled", "enabled": "Enabled",
"enabledDesc": "Let users sign in with this directory.",
"dialogDesc": "Connect an LDAP or Active Directory server so users can sign in with it.",
"sectionGeneral": "General",
"sectionConnection": "Connection",
"sectionUsers": "User Lookup",
"sectionAccess": "Access",
"save": "Save Directory", "save": "Save Directory",
"saving": "Saving...", "saving": "Saving...",
"saved": "Saved", "saved": "Saved",
@@ -36,6 +42,7 @@
"host": "LDAP Host", "host": "LDAP Host",
"port": "Port", "port": "Port",
"useTls": "Use TLS (LDAPS)", "useTls": "Use TLS (LDAPS)",
"useTlsDesc": "Connect over an encrypted LDAPS connection.",
"bindDn": "Bind DN", "bindDn": "Bind DN",
"bindPassword": "Bind Password", "bindPassword": "Bind Password",
"secretKeep": "A password is saved. Leave this empty to keep it.", "secretKeep": "A password is saved. Leave this empty to keep it.",
+157 -164
View File
@@ -5,10 +5,13 @@ import {
Button, Button,
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
FakeSwitch,
Input, Input,
Switch, PasswordInput,
SettingRow,
} from "@termix/plugin-sdk/ui"; } from "@termix/plugin-sdk/ui";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend"; import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import { createLdapApi, type LdapProvider } from "./ldap-api"; import { createLdapApi, type LdapProvider } from "./ldap-api";
@@ -51,9 +54,6 @@ function errorMessage(error: unknown, fallback: string): string {
return err.response?.data?.error || err.message || fallback; return err.response?.data?.error || err.message || fallback;
} }
const labelClass =
"text-[10px] font-semibold text-muted-foreground uppercase tracking-widest";
function Field({ function Field({
label, label,
required, required,
@@ -66,14 +66,25 @@ function Field({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1.5 min-w-0">
<label className={labelClass}> <label className="text-xs font-semibold">
{label} {label}
{required && <span className="text-accent-brand ml-1">*</span>} {required && <span className="text-accent-brand ml-1">*</span>}
</label> </label>
{children}
{hint && ( {hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span> <p className="text-[10px] text-muted-foreground leading-snug">{hint}</p>
)} )}
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{title}
</span>
{children} {children}
</div> </div>
); );
@@ -150,172 +161,155 @@ function ProviderDialog({
} }
} }
const text = (
key: Exclude<keyof Fields, "useTLS" | "bindPassword">,
label: string,
placeholder: string,
required = false,
) => (
<Field label={label} required={required}>
<Input
value={fields[key]}
onChange={(e) => set(key)(e.target.value)}
placeholder={placeholder}
/>
</Field>
);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto"> <DialogContent className="flex flex-col gap-0 p-0 overflow-hidden w-[calc(100vw-2rem)] sm:max-w-xl max-h-[calc(100dvh-2rem)]">
<DialogHeader> <DialogHeader className="px-4 pt-4 pb-3 pr-10 border-b border-border shrink-0">
<DialogTitle> <DialogTitle className="text-lg font-bold">
{provider ? t("providers.edit") : t("providers.add")} {provider ? t("providers.edit") : t("providers.add")}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("providers.dialogDesc")}{" "}
<a
href="https://docs.termix.site/features/authentication/ldap"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("providers.docsLink")}
</a>
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3 pt-2">
<Field label={t("providers.name")} required>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("providers.namePlaceholder")}
className="text-xs"
/>
</Field>
<div className="flex items-center justify-between">
<label className={labelClass}>{t("providers.enabled")}</label>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex flex-col gap-3 border-t border-border pt-3"> <div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 flex flex-col gap-6">
<span className="text-[10px] text-muted-foreground"> <Section title={t("providers.sectionGeneral")}>
<a <Field label={t("providers.name")} required>
href="https://docs.termix.site/features/authentication/ldap"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("providers.docsLink")}
</a>
</span>
<Field label={t("fields.host")} required>
<Input <Input
value={fields.host} value={name}
onChange={(e) => set("host")(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="ldap.example.com" placeholder={t("providers.namePlaceholder")}
className="text-xs"
/> />
</Field> </Field>
<div className="flex gap-2"> <div className="border border-border px-3">
<div className="flex-1"> <SettingRow
<Field label={t("fields.port")} required> label={t("providers.enabled")}
<Input description={t("providers.enabledDesc")}
value={fields.port} >
onChange={(e) => set("port")(e.target.value)} <FakeSwitch checked={enabled} onChange={setEnabled} />
placeholder="389" </SettingRow>
className="text-xs" </div>
/> </Section>
</Field>
</div> <Section title={t("providers.sectionConnection")}>
<div className="flex items-end pb-1.5 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-[1fr_8rem] gap-3">
<Switch {text("host", t("fields.host"), "ldap.example.com", true)}
{text("port", t("fields.port"), "389", true)}
</div>
<div className="border border-border px-3">
<SettingRow
label={t("fields.useTls")}
description={t("fields.useTlsDesc")}
>
<FakeSwitch
checked={fields.useTLS} checked={fields.useTLS}
onCheckedChange={(useTLS) => onChange={(useTLS) =>
setFields((prev) => ({ ...prev, useTLS })) setFields((prev) => ({ ...prev, useTLS }))
} }
aria-label={t("fields.useTls")}
/> />
<span className="text-[10px] text-muted-foreground"> </SettingRow>
{t("fields.useTls")}
</span>
</div>
</div> </div>
<Field label={t("fields.bindDn")} required> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input {text(
value={fields.bindDN} "bindDN",
onChange={(e) => set("bindDN")(e.target.value)} t("fields.bindDn"),
placeholder="cn=admin,dc=example,dc=com" "cn=admin,dc=example,dc=com",
className="text-xs" true,
/> )}
</Field> <Field
<Field label={t("fields.bindPassword")}
label={t("fields.bindPassword")} required={!provider?.hasBindPassword}
required={!provider?.hasBindPassword} hint={
hint={ provider?.hasBindPassword ? t("fields.secretKeep") : undefined
provider?.hasBindPassword ? t("fields.secretKeep") : undefined }
} >
> <PasswordInput
<Input value={fields.bindPassword}
type="password" onChange={(e) => set("bindPassword")(e.target.value)}
value={fields.bindPassword}
onChange={(e) => set("bindPassword")(e.target.value)}
className="text-xs"
/>
</Field>
<Field label={t("fields.userSearchBase")} required>
<Input
value={fields.userSearchBase}
onChange={(e) => set("userSearchBase")(e.target.value)}
placeholder="ou=users,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("fields.userSearchFilter")} required>
<Input
value={fields.userSearchFilter}
onChange={(e) => set("userSearchFilter")(e.target.value)}
placeholder="(uid={{username}})"
className="text-xs"
/>
</Field>
<Field label={t("fields.usernameAttr")} required>
<Input
value={fields.usernameAttribute}
onChange={(e) => set("usernameAttribute")(e.target.value)}
placeholder="uid"
className="text-xs"
/>
</Field>
<Field label={t("fields.displayNameAttr")} required>
<Input
value={fields.displayNameAttribute}
onChange={(e) => set("displayNameAttribute")(e.target.value)}
placeholder="cn"
className="text-xs"
/>
</Field>
<div className="border-t border-border pt-3 flex flex-col gap-3">
<Field label={t("fields.groupSearchBase")}>
<Input
value={fields.groupSearchBase}
onChange={(e) => set("groupSearchBase")(e.target.value)}
placeholder="ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("fields.adminGroup")}>
<Input
value={fields.adminGroup}
onChange={(e) => set("adminGroup")(e.target.value)}
placeholder="cn=admins,ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("fields.allowedUsers")}>
<Input
value={fields.allowedUsers}
onChange={(e) => set("allowedUsers")(e.target.value)}
placeholder="user1,user2,@domain.com"
className="text-xs"
/> />
</Field> </Field>
</div> </div>
</div> </Section>
<div className="flex gap-2 justify-end pt-2"> <Section title={t("providers.sectionUsers")}>
<Button {text(
variant="outline" "userSearchBase",
size="sm" t("fields.userSearchBase"),
className="text-xs" "ou=users,dc=example,dc=com",
onClick={() => onOpenChange(false)} true,
> )}
{t("providers.cancel")} {text(
</Button> "userSearchFilter",
<Button t("fields.userSearchFilter"),
size="sm" "(uid={{username}})",
variant="outline" true,
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand" )}
onClick={save} <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
disabled={saving} {text("usernameAttribute", t("fields.usernameAttr"), "uid", true)}
> {text(
{saving ? t("providers.saving") : t("providers.save")} "displayNameAttribute",
</Button> t("fields.displayNameAttr"),
</div> "cn",
true,
)}
</div>
</Section>
<Section title={t("providers.sectionAccess")}>
{text(
"groupSearchBase",
t("fields.groupSearchBase"),
"ou=groups,dc=example,dc=com",
)}
{text(
"adminGroup",
t("fields.adminGroup"),
"cn=admins,ou=groups,dc=example,dc=com",
)}
{text(
"allowedUsers",
t("fields.allowedUsers"),
"user1,user2,@domain.com",
)}
</Section>
</div>
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border shrink-0">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("providers.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={save}
disabled={saving}
>
{saving ? t("providers.saving") : t("providers.save")}
</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -371,9 +365,9 @@ export function ProvidersSetting() {
if (providers === null) return null; if (providers === null) return null;
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 py-3 border-b border-border last:border-0">
{providers.length === 0 ? ( {providers.length === 0 ? (
<span className="text-[10px] text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t("providers.none")} {t("providers.none")}
</span> </span>
) : ( ) : (
@@ -381,20 +375,19 @@ export function ProvidersSetting() {
{providers.map((provider) => ( {providers.map((provider) => (
<div <div
key={provider.id} key={provider.id}
className="flex items-center gap-2 p-2 border border-border bg-background" className="flex items-center gap-2 p-3 border border-border bg-background"
> >
<span className="flex-1 min-w-0 text-xs font-medium truncate"> <span className="flex-1 min-w-0 text-sm font-medium truncate">
{provider.name} {provider.name}
</span> </span>
<Switch <FakeSwitch
checked={provider.enabled} checked={provider.enabled}
onCheckedChange={() => void toggleEnabled(provider)} onChange={() => void toggleEnabled(provider)}
aria-label={t("providers.enabled")}
/> />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground" className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => { onClick={() => {
setEditing(provider); setEditing(provider);
setDialogOpen(true); setDialogOpen(true);
@@ -406,7 +399,7 @@ export function ProvidersSetting() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive" className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => void remove(provider)} onClick={() => void remove(provider)}
title={t("providers.delete")} title={t("providers.delete")}
> >
+6
View File
@@ -20,6 +20,12 @@
"nameRequired": "A display name is required", "nameRequired": "A display name is required",
"type": "Provider Type", "type": "Provider Type",
"enabled": "Enabled", "enabled": "Enabled",
"enabledDesc": "Show a sign in button for this provider on the login screen.",
"dialogDesc": "Connect an identity provider so users can sign in with it.",
"sectionGeneral": "General",
"sectionCredentials": "Client",
"sectionClaims": "Claims",
"sectionAccess": "Access",
"save": "Save Provider", "save": "Save Provider",
"saving": "Saving...", "saving": "Saving...",
"saved": "Saved", "saved": "Saved",
+198 -210
View File
@@ -5,11 +5,15 @@ import {
Button, Button,
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
FakeSwitch,
Input, Input,
PasswordInput,
Select2, Select2,
Switch, SettingRow,
Textarea,
copyToClipboard, copyToClipboard,
} from "@termix/plugin-sdk/ui"; } from "@termix/plugin-sdk/ui";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend"; import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
@@ -71,11 +75,6 @@ function errorMessage(error: unknown, fallback: string): string {
return err.response?.data?.error || err.message || fallback; return err.response?.data?.error || err.message || fallback;
} }
const labelClass =
"text-[10px] font-semibold text-muted-foreground uppercase tracking-widest";
const areaClass =
"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";
function Field({ function Field({
label, label,
required, required,
@@ -88,14 +87,25 @@ function Field({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1.5 min-w-0">
<label className={labelClass}> <label className="text-xs font-semibold">
{label} {label}
{required && <span className="text-accent-brand ml-1">*</span>} {required && <span className="text-accent-brand ml-1">*</span>}
</label> </label>
{children}
{hint && ( {hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span> <p className="text-[10px] text-muted-foreground leading-snug">{hint}</p>
)} )}
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{title}
</span>
{children} {children}
</div> </div>
); );
@@ -104,14 +114,14 @@ function Field({
function RedirectUri({ uri }: { uri: string }) { function RedirectUri({ uri }: { uri: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 min-w-0 border border-border bg-muted/30">
<code className="flex-1 min-w-0 truncate text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-1"> <code className="flex-1 min-w-0 truncate text-[11px] font-mono text-muted-foreground px-2 py-1.5">
{uri} {uri}
</code> </code>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground" className="h-7 w-7 p-0 shrink-0 text-muted-foreground hover:text-foreground"
title={t("providers.copy")} title={t("providers.copy")}
onClick={async () => { onClick={async () => {
await copyToClipboard(uri); await copyToClipboard(uri);
@@ -204,237 +214,216 @@ function ProviderDialog({
? (provider?.redirectUri ?? newRedirectUri) ? (provider?.redirectUri ?? newRedirectUri)
: newRedirectUri; : newRedirectUri;
const text = (
key: keyof Fields,
label: string,
placeholder: string,
options: { required?: boolean; hint?: string } = {},
) => (
<Field label={label} required={options.required} hint={options.hint}>
<Input
value={fields[key]}
onChange={(e) => set(key)(e.target.value)}
placeholder={placeholder}
/>
</Field>
);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto"> <DialogContent className="flex flex-col gap-0 p-0 overflow-hidden w-[calc(100vw-2rem)] sm:max-w-xl max-h-[calc(100dvh-2rem)]">
<DialogHeader> <DialogHeader className="px-4 pt-4 pb-3 pr-10 border-b border-border shrink-0">
<DialogTitle> <DialogTitle className="text-lg font-bold">
{isEdit ? t("providers.edit") : t("providers.add")} {isEdit ? t("providers.edit") : t("providers.add")}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("providers.dialogDesc")}{" "}
<a
href={
simplified
? "https://docs.termix.site/features/authentication/github-google"
: "https://docs.termix.site/features/authentication/oidc"
}
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("providers.docsLink")}
</a>
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3 pt-2">
<Field label={t("providers.name")} required>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("providers.namePlaceholder")}
className="text-xs"
/>
</Field>
{!isEdit && ( <div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 flex flex-col gap-6">
<Field label={t("providers.type")}> <Section title={t("providers.sectionGeneral")}>
<Select2 <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
value={type} <Field label={t("providers.name")} required>
onChange={(e) => setType(e.target.value as SsoProviderType)} <Input
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" value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("providers.namePlaceholder")}
/>
</Field>
<Field label={t("providers.type")}>
<Select2
value={type}
disabled={isEdit}
onChange={(e) => setType(e.target.value as SsoProviderType)}
className="w-full"
>
{(Object.keys(TYPE_LABELS) as SsoProviderType[]).map(
(value) => (
<option key={value} value={value}>
{TYPE_LABELS[value]}
</option>
),
)}
</Select2>
</Field>
</div>
<div className="border border-border px-3">
<SettingRow
label={t("providers.enabled")}
description={t("providers.enabledDesc")}
> >
{(Object.keys(TYPE_LABELS) as SsoProviderType[]).map( <FakeSwitch checked={enabled} onChange={setEnabled} />
(value) => ( </SettingRow>
<option key={value} value={value}> </div>
{TYPE_LABELS[value]} </Section>
</option>
),
)}
</Select2>
</Field>
)}
<div className="flex items-center justify-between"> <Section title={t("providers.redirectUri")}>
<label className={labelClass}>{t("providers.enabled")}</label> <div className="flex flex-col gap-1.5">
<Switch checked={enabled} onCheckedChange={setEnabled} /> <RedirectUri uri={redirectUri} />
</div> <p className="text-[10px] text-muted-foreground leading-snug">
{t("providers.redirectUriDesc")}
<div className="flex flex-col gap-1 border-t border-border pt-3"> </p>
<label className={labelClass}>{t("providers.redirectUri")}</label> </div>
<span className="text-[10px] text-muted-foreground">
{t("providers.redirectUriDesc")}
</span>
<RedirectUri uri={redirectUri} />
{isEdit && provider.legacyCallback && ( {isEdit && provider.legacyCallback && (
<div className="flex items-start justify-between gap-3 pt-1"> <div className="border border-border px-3">
<span className="text-[10px] text-muted-foreground"> <SettingRow
{t("providers.legacyCallbackDesc", { label={t("providers.legacyCallback")}
description={t("providers.legacyCallbackDesc", {
uri: newRedirectUri, uri: newRedirectUri,
})} })}
</span> >
<Switch <FakeSwitch
checked={legacyCallback} checked={legacyCallback}
onCheckedChange={setLegacyCallback} onChange={setLegacyCallback}
aria-label={t("providers.legacyCallback")} />
/> </SettingRow>
</div> </div>
)} )}
</div> </Section>
<div className="flex flex-col gap-3 border-t border-border pt-3"> <Section title={t("providers.sectionCredentials")}>
<span className="text-[10px] text-muted-foreground"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<a {text("client_id", t("fields.clientId"), "your-client-id", {
href={ required: true,
simplified })}
? "https://docs.termix.site/features/authentication/github-google" <Field
: "https://docs.termix.site/features/authentication/oidc" label={t("fields.clientSecret")}
} required={!provider?.hasClientSecret}
target="_blank" hint={secretHint}
rel="noreferrer"
className="text-accent-brand hover:underline"
> >
{t("providers.docsLink")} <PasswordInput
</a> value={fields.client_secret}
</span> onChange={(e) => set("client_secret")(e.target.value)}
<Field label={t("fields.clientId")} required> placeholder="your-client-secret"
<Input />
value={fields.client_id} </Field>
onChange={(e) => set("client_id")(e.target.value)} </div>
placeholder="your-client-id"
className="text-xs"
/>
</Field>
<Field
label={t("fields.clientSecret")}
required={!provider?.hasClientSecret}
hint={secretHint}
>
<Input
type="password"
value={fields.client_secret}
onChange={(e) => set("client_secret")(e.target.value)}
placeholder="your-client-secret"
className="text-xs"
/>
</Field>
{simplified ? ( {simplified ? (
<span className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground break-all">
{t("providers.authorizationUrl", { {t("providers.authorizationUrl", {
url: AUTHORIZATION_URLS[type as "github" | "google"], url: AUTHORIZATION_URLS[type as "github" | "google"],
})} })}
</span> </p>
) : ( ) : (
<> <>
<Field label={t("fields.issuerUrl")} required> {text("issuer_url", t("fields.issuerUrl"), "https://provider", {
<Input required: true,
value={fields.issuer_url} })}
onChange={(e) => set("issuer_url")(e.target.value)} {text(
placeholder="https://provider" "authorization_url",
className="text-xs" t("fields.authUrl"),
/> "https://provider/oauth2/auth",
</Field> { required: true },
<Field label={t("fields.authUrl")} required> )}
<Input {text(
value={fields.authorization_url} "token_url",
onChange={(e) => set("authorization_url")(e.target.value)} t("fields.tokenUrl"),
placeholder="https://provider/oauth2/auth" "https://provider/oauth2/token",
className="text-xs" { required: true },
/> )}
</Field> {text(
<Field label={t("fields.tokenUrl")} required> "userinfo_url",
<Input t("fields.userinfoUrl"),
value={fields.token_url} "https://provider/oauth2/userinfo",
onChange={(e) => set("token_url")(e.target.value)} )}
placeholder="https://provider/oauth2/token"
className="text-xs"
/>
</Field>
<Field label={t("fields.userIdentifier")} required>
<Input
value={fields.identifier_path}
onChange={(e) => set("identifier_path")(e.target.value)}
placeholder="sub"
className="text-xs"
/>
</Field>
<Field label={t("fields.displayName")} required>
<Input
value={fields.name_path}
onChange={(e) => set("name_path")(e.target.value)}
placeholder="name"
className="text-xs"
/>
</Field>
<Field label={t("fields.scopes")} required>
<Input
value={fields.scopes}
onChange={(e) => set("scopes")(e.target.value)}
placeholder="openid email profile"
className="text-xs"
/>
</Field>
<Field label={t("fields.userinfoUrl")}>
<Input
value={fields.userinfo_url}
onChange={(e) => set("userinfo_url")(e.target.value)}
placeholder="https://provider/oauth2/userinfo"
className="text-xs"
/>
</Field>
<Field
label={t("fields.groupClaim")}
hint={t("fields.groupClaimDesc")}
>
<Input
value={fields.group_claim}
onChange={(e) => set("group_claim")(e.target.value)}
placeholder="groups"
className="text-xs"
/>
</Field>
</> </>
)} )}
</Section>
{!simplified && (
<Section title={t("providers.sectionClaims")}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{text("identifier_path", t("fields.userIdentifier"), "sub", {
required: true,
})}
{text("name_path", t("fields.displayName"), "name", {
required: true,
})}
</div>
{text("scopes", t("fields.scopes"), "openid email profile", {
required: true,
})}
{text("group_claim", t("fields.groupClaim"), "groups", {
hint: t("fields.groupClaimDesc"),
})}
</Section>
)}
<Section title={t("providers.sectionAccess")}>
<Field <Field
label={t("fields.allowedUsers")} label={t("fields.allowedUsers")}
hint={t("fields.allowedUsersDesc")} hint={t("fields.allowedUsersDesc")}
> >
<textarea <Textarea
value={fields.allowed_users} value={fields.allowed_users}
onChange={(e) => set("allowed_users")(e.target.value)} onChange={(e) => set("allowed_users")(e.target.value)}
placeholder={"user@example.com\nanother@example.com"} placeholder={"user@example.com\nanother@example.com"}
rows={3} rows={3}
className={areaClass} className="font-mono resize-y"
/>
</Field>
<Field
label={t("fields.adminGroup")}
hint={t("fields.adminGroupDesc")}
>
<Input
value={fields.admin_group}
onChange={(e) => set("admin_group")(e.target.value)}
placeholder="admin"
className="text-xs"
/> />
</Field> </Field>
{text("admin_group", t("fields.adminGroup"), "admin", {
hint: t("fields.adminGroupDesc"),
})}
<Field label={t("fields.caCert")} hint={t("fields.caCertDesc")}> <Field label={t("fields.caCert")} hint={t("fields.caCertDesc")}>
<textarea <Textarea
value={fields.ca_cert} value={fields.ca_cert}
onChange={(e) => set("ca_cert")(e.target.value)} onChange={(e) => set("ca_cert")(e.target.value)}
placeholder={ placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
} }
rows={4} rows={4}
className={areaClass} className="font-mono resize-y"
/> />
</Field> </Field>
</div> </Section>
</div>
<div className="flex gap-2 justify-end pt-2"> <div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border shrink-0">
<Button <Button variant="ghost" onClick={() => onOpenChange(false)}>
variant="outline" {t("providers.cancel")}
size="sm" </Button>
className="text-xs" <Button
onClick={() => onOpenChange(false)} variant="outline"
> className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
{t("providers.cancel")} onClick={save}
</Button> disabled={saving}
<Button >
size="sm" {saving ? t("providers.saving") : t("providers.save")}
variant="outline" </Button>
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={save}
disabled={saving}
>
{saving ? t("providers.saving") : t("providers.save")}
</Button>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -493,7 +482,7 @@ export function ProvidersSetting() {
if (providers === null) return null; if (providers === null) return null;
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 py-3 border-b border-border last:border-0">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
<a <a
href="https://docs.termix.site/features/authentication/sso-providers" href="https://docs.termix.site/features/authentication/sso-providers"
@@ -505,7 +494,7 @@ export function ProvidersSetting() {
</a> </a>
</span> </span>
{providers.length === 0 ? ( {providers.length === 0 ? (
<span className="text-[10px] text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t("providers.none")} {t("providers.none")}
</span> </span>
) : ( ) : (
@@ -513,26 +502,25 @@ export function ProvidersSetting() {
{providers.map((provider) => ( {providers.map((provider) => (
<div <div
key={provider.id} key={provider.id}
className="flex flex-col gap-1.5 p-2 border border-border bg-background" className="flex flex-col gap-2 p-3 border border-border bg-background"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 min-w-0 flex items-center gap-1.5"> <div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className="text-xs font-medium truncate"> <span className="text-sm font-medium truncate">
{provider.name} {provider.name}
</span> </span>
<span className="text-[9px] px-1 py-0.5 bg-muted text-muted-foreground font-mono uppercase"> <span className="text-[9px] px-1 py-0.5 bg-muted text-muted-foreground font-mono uppercase shrink-0">
{TYPE_LABELS[provider.type] ?? provider.type} {TYPE_LABELS[provider.type] ?? provider.type}
</span> </span>
</div> </div>
<Switch <FakeSwitch
checked={provider.enabled} checked={provider.enabled}
onCheckedChange={() => void toggleEnabled(provider)} onChange={() => void toggleEnabled(provider)}
aria-label={t("providers.enabled")}
/> />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground" className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => { onClick={() => {
setEditing(provider); setEditing(provider);
setDialogOpen(true); setDialogOpen(true);
@@ -544,7 +532,7 @@ export function ProvidersSetting() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive" className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => void remove(provider)} onClick={() => void remove(provider)}
title={t("providers.delete")} title={t("providers.delete")}
> >