mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles
This commit is contained in:
1 parent
0f4cdcbdfa
commit
58577eafe9
4 files changed
+368
-374
No files matched your search
@@ -23,6 +23,12 @@
|
||||
"namePlaceholder": "Company directory",
|
||||
"nameRequired": "A display name is required",
|
||||
"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",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
@@ -36,6 +42,7 @@
|
||||
"host": "LDAP Host",
|
||||
"port": "Port",
|
||||
"useTls": "Use TLS (LDAPS)",
|
||||
"useTlsDesc": "Connect over an encrypted LDAPS connection.",
|
||||
"bindDn": "Bind DN",
|
||||
"bindPassword": "Bind Password",
|
||||
"secretKeep": "A password is saved. Leave this empty to keep it.",
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
FakeSwitch,
|
||||
Input,
|
||||
Switch,
|
||||
PasswordInput,
|
||||
SettingRow,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
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;
|
||||
}
|
||||
|
||||
const labelClass =
|
||||
"text-[10px] font-semibold text-muted-foreground uppercase tracking-widest";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
@@ -66,14 +66,25 @@ function Field({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className={labelClass}>
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<label className="text-xs font-semibold">
|
||||
{label}
|
||||
{required && <span className="text-accent-brand ml-1">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{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}
|
||||
</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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<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 className="px-4 pt-4 pb-3 pr-10 border-b border-border shrink-0">
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{provider ? t("providers.edit") : t("providers.add")}
|
||||
</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>
|
||||
<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">
|
||||
<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("providers.docsLink")}
|
||||
</a>
|
||||
</span>
|
||||
<Field label={t("fields.host")} required>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 flex flex-col gap-6">
|
||||
<Section title={t("providers.sectionGeneral")}>
|
||||
<Field label={t("providers.name")} required>
|
||||
<Input
|
||||
value={fields.host}
|
||||
onChange={(e) => set("host")(e.target.value)}
|
||||
placeholder="ldap.example.com"
|
||||
className="text-xs"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("providers.namePlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Field label={t("fields.port")} required>
|
||||
<Input
|
||||
value={fields.port}
|
||||
onChange={(e) => set("port")(e.target.value)}
|
||||
placeholder="389"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-end pb-1.5 gap-2">
|
||||
<Switch
|
||||
<div className="border border-border px-3">
|
||||
<SettingRow
|
||||
label={t("providers.enabled")}
|
||||
description={t("providers.enabledDesc")}
|
||||
>
|
||||
<FakeSwitch checked={enabled} onChange={setEnabled} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("providers.sectionConnection")}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_8rem] gap-3">
|
||||
{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}
|
||||
onCheckedChange={(useTLS) =>
|
||||
onChange={(useTLS) =>
|
||||
setFields((prev) => ({ ...prev, useTLS }))
|
||||
}
|
||||
aria-label={t("fields.useTls")}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("fields.useTls")}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
<Field label={t("fields.bindDn")} required>
|
||||
<Input
|
||||
value={fields.bindDN}
|
||||
onChange={(e) => set("bindDN")(e.target.value)}
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("fields.bindPassword")}
|
||||
required={!provider?.hasBindPassword}
|
||||
hint={
|
||||
provider?.hasBindPassword ? t("fields.secretKeep") : undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
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"
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{text(
|
||||
"bindDN",
|
||||
t("fields.bindDn"),
|
||||
"cn=admin,dc=example,dc=com",
|
||||
true,
|
||||
)}
|
||||
<Field
|
||||
label={t("fields.bindPassword")}
|
||||
required={!provider?.hasBindPassword}
|
||||
hint={
|
||||
provider?.hasBindPassword ? t("fields.secretKeep") : undefined
|
||||
}
|
||||
>
|
||||
<PasswordInput
|
||||
value={fields.bindPassword}
|
||||
onChange={(e) => set("bindPassword")(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("providers.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={save}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t("providers.saving") : t("providers.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<Section title={t("providers.sectionUsers")}>
|
||||
{text(
|
||||
"userSearchBase",
|
||||
t("fields.userSearchBase"),
|
||||
"ou=users,dc=example,dc=com",
|
||||
true,
|
||||
)}
|
||||
{text(
|
||||
"userSearchFilter",
|
||||
t("fields.userSearchFilter"),
|
||||
"(uid={{username}})",
|
||||
true,
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{text("usernameAttribute", t("fields.usernameAttr"), "uid", true)}
|
||||
{text(
|
||||
"displayNameAttribute",
|
||||
t("fields.displayNameAttr"),
|
||||
"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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -371,9 +365,9 @@ export function ProvidersSetting() {
|
||||
if (providers === null) return null;
|
||||
|
||||
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 ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("providers.none")}
|
||||
</span>
|
||||
) : (
|
||||
@@ -381,20 +375,19 @@ export function ProvidersSetting() {
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
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}
|
||||
</span>
|
||||
<Switch
|
||||
<FakeSwitch
|
||||
checked={provider.enabled}
|
||||
onCheckedChange={() => void toggleEnabled(provider)}
|
||||
aria-label={t("providers.enabled")}
|
||||
onChange={() => void toggleEnabled(provider)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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={() => {
|
||||
setEditing(provider);
|
||||
setDialogOpen(true);
|
||||
@@ -406,7 +399,7 @@ export function ProvidersSetting() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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)}
|
||||
title={t("providers.delete")}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
"nameRequired": "A display name is required",
|
||||
"type": "Provider Type",
|
||||
"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",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
|
||||
@@ -5,11 +5,15 @@ import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
FakeSwitch,
|
||||
Input,
|
||||
PasswordInput,
|
||||
Select2,
|
||||
Switch,
|
||||
SettingRow,
|
||||
Textarea,
|
||||
copyToClipboard,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
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;
|
||||
}
|
||||
|
||||
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({
|
||||
label,
|
||||
required,
|
||||
@@ -88,14 +87,25 @@ function Field({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className={labelClass}>
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<label className="text-xs font-semibold">
|
||||
{label}
|
||||
{required && <span className="text-accent-brand ml-1">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{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}
|
||||
</div>
|
||||
);
|
||||
@@ -104,14 +114,14 @@ function Field({
|
||||
function RedirectUri({ uri }: { uri: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="flex-1 min-w-0 truncate text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-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-[11px] font-mono text-muted-foreground px-2 py-1.5">
|
||||
{uri}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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")}
|
||||
onClick={async () => {
|
||||
await copyToClipboard(uri);
|
||||
@@ -204,237 +214,216 @@ function ProviderDialog({
|
||||
? (provider?.redirectUri ?? 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<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 className="px-4 pt-4 pb-3 pr-10 border-b border-border shrink-0">
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{isEdit ? t("providers.edit") : t("providers.add")}
|
||||
</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>
|
||||
<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 && (
|
||||
<Field label={t("providers.type")}>
|
||||
<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"
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 flex flex-col gap-6">
|
||||
<Section title={t("providers.sectionGeneral")}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label={t("providers.name")} required>
|
||||
<Input
|
||||
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(
|
||||
(value) => (
|
||||
<option key={value} value={value}>
|
||||
{TYPE_LABELS[value]}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</Select2>
|
||||
</Field>
|
||||
)}
|
||||
<FakeSwitch checked={enabled} onChange={setEnabled} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<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-1 border-t border-border pt-3">
|
||||
<label className={labelClass}>{t("providers.redirectUri")}</label>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("providers.redirectUriDesc")}
|
||||
</span>
|
||||
<RedirectUri uri={redirectUri} />
|
||||
<Section title={t("providers.redirectUri")}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<RedirectUri uri={redirectUri} />
|
||||
<p className="text-[10px] text-muted-foreground leading-snug">
|
||||
{t("providers.redirectUriDesc")}
|
||||
</p>
|
||||
</div>
|
||||
{isEdit && provider.legacyCallback && (
|
||||
<div className="flex items-start justify-between gap-3 pt-1">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("providers.legacyCallbackDesc", {
|
||||
<div className="border border-border px-3">
|
||||
<SettingRow
|
||||
label={t("providers.legacyCallback")}
|
||||
description={t("providers.legacyCallbackDesc", {
|
||||
uri: newRedirectUri,
|
||||
})}
|
||||
</span>
|
||||
<Switch
|
||||
checked={legacyCallback}
|
||||
onCheckedChange={setLegacyCallback}
|
||||
aria-label={t("providers.legacyCallback")}
|
||||
/>
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={legacyCallback}
|
||||
onChange={setLegacyCallback}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<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"
|
||||
<Section title={t("providers.sectionCredentials")}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{text("client_id", t("fields.clientId"), "your-client-id", {
|
||||
required: true,
|
||||
})}
|
||||
<Field
|
||||
label={t("fields.clientSecret")}
|
||||
required={!provider?.hasClientSecret}
|
||||
hint={secretHint}
|
||||
>
|
||||
{t("providers.docsLink")}
|
||||
</a>
|
||||
</span>
|
||||
<Field label={t("fields.clientId")} required>
|
||||
<Input
|
||||
value={fields.client_id}
|
||||
onChange={(e) => set("client_id")(e.target.value)}
|
||||
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>
|
||||
<PasswordInput
|
||||
value={fields.client_secret}
|
||||
onChange={(e) => set("client_secret")(e.target.value)}
|
||||
placeholder="your-client-secret"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{simplified ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<p className="text-[10px] text-muted-foreground break-all">
|
||||
{t("providers.authorizationUrl", {
|
||||
url: AUTHORIZATION_URLS[type as "github" | "google"],
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t("fields.issuerUrl")} required>
|
||||
<Input
|
||||
value={fields.issuer_url}
|
||||
onChange={(e) => set("issuer_url")(e.target.value)}
|
||||
placeholder="https://provider"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.authUrl")} required>
|
||||
<Input
|
||||
value={fields.authorization_url}
|
||||
onChange={(e) => set("authorization_url")(e.target.value)}
|
||||
placeholder="https://provider/oauth2/auth"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("fields.tokenUrl")} required>
|
||||
<Input
|
||||
value={fields.token_url}
|
||||
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>
|
||||
{text("issuer_url", t("fields.issuerUrl"), "https://provider", {
|
||||
required: true,
|
||||
})}
|
||||
{text(
|
||||
"authorization_url",
|
||||
t("fields.authUrl"),
|
||||
"https://provider/oauth2/auth",
|
||||
{ required: true },
|
||||
)}
|
||||
{text(
|
||||
"token_url",
|
||||
t("fields.tokenUrl"),
|
||||
"https://provider/oauth2/token",
|
||||
{ required: true },
|
||||
)}
|
||||
{text(
|
||||
"userinfo_url",
|
||||
t("fields.userinfoUrl"),
|
||||
"https://provider/oauth2/userinfo",
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
label={t("fields.allowedUsers")}
|
||||
hint={t("fields.allowedUsersDesc")}
|
||||
>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={fields.allowed_users}
|
||||
onChange={(e) => set("allowed_users")(e.target.value)}
|
||||
placeholder={"user@example.com\nanother@example.com"}
|
||||
rows={3}
|
||||
className={areaClass}
|
||||
/>
|
||||
</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"
|
||||
className="font-mono resize-y"
|
||||
/>
|
||||
</Field>
|
||||
{text("admin_group", t("fields.adminGroup"), "admin", {
|
||||
hint: t("fields.adminGroupDesc"),
|
||||
})}
|
||||
<Field label={t("fields.caCert")} hint={t("fields.caCertDesc")}>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={fields.ca_cert}
|
||||
onChange={(e) => set("ca_cert")(e.target.value)}
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
rows={4}
|
||||
className={areaClass}
|
||||
className="font-mono resize-y"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("providers.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={save}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t("providers.saving") : t("providers.save")}
|
||||
</Button>
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -493,7 +482,7 @@ export function ProvidersSetting() {
|
||||
if (providers === null) return null;
|
||||
|
||||
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">
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/sso-providers"
|
||||
@@ -505,7 +494,7 @@ export function ProvidersSetting() {
|
||||
</a>
|
||||
</span>
|
||||
{providers.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("providers.none")}
|
||||
</span>
|
||||
) : (
|
||||
@@ -513,26 +502,25 @@ export function ProvidersSetting() {
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
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-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}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
<FakeSwitch
|
||||
checked={provider.enabled}
|
||||
onCheckedChange={() => void toggleEnabled(provider)}
|
||||
aria-label={t("providers.enabled")}
|
||||
onChange={() => void toggleEnabled(provider)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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={() => {
|
||||
setEditing(provider);
|
||||
setDialogOpen(true);
|
||||
@@ -544,7 +532,7 @@ export function ProvidersSetting() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
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)}
|
||||
title={t("providers.delete")}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user