Compare commits

..

2 Commits

11 changed files with 563 additions and 335 deletions
+1
View File
@@ -27,6 +27,7 @@ require (
github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/expr-lang/expr v1.17.8
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+2
View File
@@ -28,6 +28,8 @@ github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcej
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
+8 -1
View File
@@ -38,6 +38,8 @@ func scanAuthProvider(row interface {
&item.OIDCRedirectURL, &item.OIDCRedirectURL,
&item.OIDCScopes, &item.OIDCScopes,
&item.OIDCTLSInsecureSkipVerify, &item.OIDCTLSInsecureSkipVerify,
&item.OIDCGroupsClaim,
&item.OIDCAdmissionExpr,
&defaultUserGroupID, &defaultUserGroupID,
&item.UserCount, &item.UserCount,
&item.CreatedAt, &item.CreatedAt,
@@ -56,6 +58,7 @@ const authProviderSelectCols = `
p.ldap_tls_insecure_skip_verify, p.ldap_tls_insecure_skip_verify,
p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url, p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url,
p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify, p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify,
p.oidc_groups_claim, p.oidc_admission_expr,
COALESCE(ug.public_id, ''), COALESCE(ug.public_id, ''),
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id), (SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
p.created_at, p.updated_at` p.created_at, p.updated_at`
@@ -153,14 +156,16 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
ldap_tls_insecure_skip_verify, ldap_tls_insecure_skip_verify,
oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url, oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url,
oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify, oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify,
oidc_groups_claim, oidc_admission_expr,
default_user_group_id, default_user_group_id,
created_at, updated_at created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
item.ID, item.Name, item.Type, item.Enabled, item.ID, item.Name, item.Type, item.Enabled,
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter, item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
item.LDAPTLSInsecureSkipVerify, item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL, item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify, item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr,
item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID,
item.CreatedAt, item.UpdatedAt, item.CreatedAt, item.UpdatedAt,
) )
@@ -190,6 +195,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
ldap_tls_insecure_skip_verify = ?, ldap_tls_insecure_skip_verify = ?,
oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?, oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?,
oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?, oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?,
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
updated_at = ? updated_at = ?
WHERE public_id = ?`, WHERE public_id = ?`,
@@ -198,6 +204,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
item.LDAPTLSInsecureSkipVerify, item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL, item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify, item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr,
item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID,
item.UpdatedAt, item.UpdatedAt,
strings.TrimSpace(item.ID), strings.TrimSpace(item.ID),
@@ -56,6 +56,13 @@ func (api *API) CreateAuthProvider(w http.ResponseWriter, r *http.Request, _ map
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "type must be ldap or oidc") WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "type must be ldap or oidc")
return return
} }
if req.Type == db.AuthProviderTypeOIDC && strings.TrimSpace(req.OIDCAdmissionExpr) != "" {
err = oidcCompileAdmissionExpr(strings.TrimSpace(req.OIDCAdmissionExpr))
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid admission expression: "+err.Error())
return
}
}
created, err = api.store(r).CreateAuthProvider(req) created, err = api.store(r).CreateAuthProvider(req)
if err != nil { if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error()) WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
@@ -84,6 +91,13 @@ func (api *API) UpdateAuthProvider(w http.ResponseWriter, r *http.Request, param
} }
req.ID = existing.ID req.ID = existing.ID
req.Type = existing.Type req.Type = existing.Type
if req.Type == db.AuthProviderTypeOIDC && strings.TrimSpace(req.OIDCAdmissionExpr) != "" {
err = oidcCompileAdmissionExpr(strings.TrimSpace(req.OIDCAdmissionExpr))
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid admission expression: "+err.Error())
return
}
}
if existing.ID == db.BuiltinDBProviderPublicID { if existing.ID == db.BuiltinDBProviderPublicID {
existing.Enabled = req.Enabled existing.Enabled = req.Enabled
if strings.TrimSpace(req.Name) != "" { if strings.TrimSpace(req.Name) != "" {
+119
View File
@@ -17,6 +17,8 @@ import "net/url"
import "strings" import "strings"
import "time" import "time"
import "github.com/expr-lang/expr"
import "codit/internal/db" import "codit/internal/db"
import "codit/internal/models" import "codit/internal/models"
@@ -30,6 +32,7 @@ type oidcUserClaims struct {
PreferredUsername string `json:"preferred_username"` PreferredUsername string `json:"preferred_username"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
Raw map[string]any `json:"-"`
} }
type oidcErrorResponse struct { type oidcErrorResponse struct {
@@ -359,6 +362,10 @@ func (api *API) oidcUserInfoFromProvider(ctx context.Context, p models.AuthProvi
if strings.TrimSpace(claims.Sub) == "" { if strings.TrimSpace(claims.Sub) == "" {
return claims, errors.New("userinfo missing sub") return claims, errors.New("userinfo missing sub")
} }
var raw map[string]any
if json.Unmarshal(body, &raw) == nil {
claims.Raw = raw
}
return claims, nil return claims, nil
} }
@@ -370,6 +377,11 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
var err error var err error
var created models.User var created models.User
err = oidcCheckAdmission(provider, claims)
if err != nil {
return user, err
}
user, err = api.storeContext(ctx).GetUserByProviderAndSub(provider.ID, claims.Sub) user, err = api.storeContext(ctx).GetUserByProviderAndSub(provider.ID, claims.Sub)
if err == nil { if err == nil {
if user.Disabled { if user.Disabled {
@@ -402,6 +414,109 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
return created, nil return created, nil
} }
// oidcAdmissionEnvType defines the expression environment for type-checking at save time.
// Variables exposed: email, email_domain, sub, username, groups ([]string), claims (raw map).
var oidcAdmissionEnvType = map[string]any{
"email": "",
"email_domain": "",
"sub": "",
"username": "",
"groups": []string{},
"claims": map[string]any{},
}
func oidcCompileAdmissionExpr(exprStr string) error {
var err error
_, err = expr.Compile(exprStr, expr.Env(oidcAdmissionEnvType), expr.AsBool())
return err
}
func oidcExtractGroups(raw map[string]any, claimName string) []string {
var val any
var ok bool
var arr []any
var s string
var result []string
var i int
if raw == nil {
return []string{}
}
val, ok = raw[claimName]
if !ok {
return []string{}
}
arr, ok = val.([]any)
if ok {
for i = 0; i < len(arr); i++ {
s, ok = arr[i].(string)
if ok && s != "" {
result = append(result, s)
}
}
if result == nil {
return []string{}
}
return result
}
s, ok = val.(string)
if ok && s != "" {
return []string{s}
}
return []string{}
}
func oidcBuildAdmissionEnv(provider models.AuthProvider, claims oidcUserClaims) map[string]any {
var emailDomain string
var atIdx int
var groupsClaim string
var raw map[string]any
atIdx = strings.LastIndex(claims.Email, "@")
if atIdx >= 0 {
emailDomain = strings.ToLower(claims.Email[atIdx+1:])
}
groupsClaim = strings.TrimSpace(provider.OIDCGroupsClaim)
if groupsClaim == "" {
groupsClaim = "groups"
}
raw = claims.Raw
if raw == nil {
raw = map[string]any{}
}
return map[string]any{
"email": strings.TrimSpace(claims.Email),
"email_domain": emailDomain,
"sub": strings.TrimSpace(claims.Sub),
"username": strings.TrimSpace(claims.PreferredUsername),
"groups": oidcExtractGroups(raw, groupsClaim),
"claims": raw,
}
}
func oidcCheckAdmission(provider models.AuthProvider, claims oidcUserClaims) error {
var exprStr string
var env map[string]any
var result any
var ok bool
var err error
exprStr = strings.TrimSpace(provider.OIDCAdmissionExpr)
if exprStr == "" {
return nil
}
env = oidcBuildAdmissionEnv(provider, claims)
result, err = expr.Eval(exprStr, env)
if err != nil {
return fmt.Errorf("admission expression error: %v", err)
}
ok, _ = result.(bool)
if !ok {
return errors.New("oidc admission denied")
}
return nil
}
func oidcIDTokenAudienceContains(aud json.RawMessage, clientID string) bool { func oidcIDTokenAudienceContains(aud json.RawMessage, clientID string) bool {
var single string var single string
var multiple []string var multiple []string
@@ -458,6 +573,10 @@ func oidcClaimsFromIDToken(idToken string, clientID string) (oidcUserClaims, err
if strings.TrimSpace(claims.Sub) == "" { if strings.TrimSpace(claims.Sub) == "" {
return claims, errors.New("id token missing sub") return claims, errors.New("id token missing sub")
} }
var raw map[string]any
if json.Unmarshal(payload, &raw) == nil {
claims.Raw = raw
}
return claims, nil return claims, nil
} }
+2
View File
@@ -36,6 +36,8 @@ type AuthProvider struct {
OIDCRedirectURL string `json:"oidc_redirect_url"` OIDCRedirectURL string `json:"oidc_redirect_url"`
OIDCScopes string `json:"oidc_scopes"` OIDCScopes string `json:"oidc_scopes"`
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"` OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
OIDCGroupsClaim string `json:"oidc_groups_claim"`
OIDCAdmissionExpr string `json:"oidc_admission_expr"`
DefaultUserGroupID string `json:"default_user_group_id"` DefaultUserGroupID string `json:"default_user_group_id"`
UserCount int `json:"user_count,omitempty"` UserCount int `json:"user_count,omitempty"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
@@ -0,0 +1,2 @@
ALTER TABLE auth_providers ADD COLUMN oidc_groups_claim TEXT NOT NULL DEFAULT 'groups';
ALTER TABLE auth_providers ADD COLUMN oidc_admission_expr TEXT NOT NULL DEFAULT '';
+2
View File
@@ -453,6 +453,8 @@ export interface AuthProvider {
oidc_redirect_url: string oidc_redirect_url: string
oidc_scopes: string oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean oidc_tls_insecure_skip_verify: boolean
oidc_groups_claim: string
oidc_admission_expr: string
default_user_group_id: string default_user_group_id: string
user_count?: number user_count?: number
created_at: number created_at: number
@@ -0,0 +1,86 @@
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent'
import { AuthProvider, UserGroup } from '../api'
type AuthProviderDetailsDialogProps = {
item: AuthProvider | null
userGroups: UserGroup[]
onClose: () => void
}
function fmt(value: number | undefined): string {
if (!value || value <= 0) {
return '-'
}
return new Date(value * 1000).toLocaleString()
}
function yesNo(value: boolean): string {
return value ? 'yes' : 'no'
}
function secretState(value: string): string {
return value ? 'Configured' : 'Not configured'
}
export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDialogProps) {
const item: AuthProvider | null = props.item
const defaultGroup: UserGroup | undefined = props.userGroups.find((group: UserGroup) => group.id === item?.default_user_group_id)
return (
<Dialog open={Boolean(item)} onClose={props.onClose} maxWidth="md" fullWidth>
<DialogTitle>Auth Provider Details</DialogTitle>
<FormDialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1">{item?.name || '-'}</Typography>
{item ? <Chip label={item.type.toUpperCase()} size="small" variant="outlined" /> : null}
{item ? <Chip label={item.enabled ? 'Enabled' : 'Disabled'} size="small" color={item.enabled ? 'success' : 'error'} /> : null}
</Box>
<TextField label="Provider ID" value={item?.id || ''} InputProps={{ readOnly: true }} />
<TextField label="Name" value={item?.name || ''} InputProps={{ readOnly: true }} />
<TextField label="Type" value={item?.type || ''} InputProps={{ readOnly: true }} />
<TextField label="Enabled" value={yesNo(Boolean(item?.enabled))} InputProps={{ readOnly: true }} />
<TextField label="Users" value={String(item?.user_count ?? 0)} InputProps={{ readOnly: true }} />
<TextField label="Default User Group" value={defaultGroup ? `${defaultGroup.name} (${defaultGroup.id})` : item?.default_user_group_id || '-'} InputProps={{ readOnly: true }} />
{item?.type === 'ldap' ? (
<>
<TextField label="LDAP URL" value={item.ldap_url || ''} InputProps={{ readOnly: true }} />
<TextField label="Bind DN" value={item.ldap_bind_dn || '-'} InputProps={{ readOnly: true }} />
<TextField label="Bind Password" value={secretState(item.ldap_bind_password)} InputProps={{ readOnly: true }} />
<TextField label="User Base DN" value={item.ldap_user_base_dn || ''} InputProps={{ readOnly: true }} />
<TextField label="User Filter" value={item.ldap_user_filter || ''} InputProps={{ readOnly: true }} />
<TextField label="TLS Insecure Skip Verify" value={yesNo(item.ldap_tls_insecure_skip_verify)} InputProps={{ readOnly: true }} />
</>
) : null}
{item?.type === 'oidc' ? (
<>
<TextField label="Client ID" value={item.oidc_client_id || ''} InputProps={{ readOnly: true }} />
<TextField label="Client Secret" value={secretState(item.oidc_client_secret)} InputProps={{ readOnly: true }} />
<TextField label="Authorize URL" value={item.oidc_authorize_url || ''} InputProps={{ readOnly: true }} />
<TextField label="Token URL" value={item.oidc_token_url || ''} InputProps={{ readOnly: true }} />
<TextField label="UserInfo URL" value={item.oidc_userinfo_url || '-'} InputProps={{ readOnly: true }} />
<TextField label="Redirect URL" value={item.oidc_redirect_url || ''} InputProps={{ readOnly: true }} />
<TextField label="Scopes" value={item.oidc_scopes || ''} InputProps={{ readOnly: true }} />
<TextField label="TLS Insecure Skip Verify" value={yesNo(item.oidc_tls_insecure_skip_verify)} InputProps={{ readOnly: true }} />
<TextField label="Groups Claim" value={item.oidc_groups_claim || 'groups'} InputProps={{ readOnly: true }} />
<TextField label="Admission Expression" value={item.oidc_admission_expr || '-'} InputProps={{ readOnly: true }} multiline minRows={2} />
</>
) : null}
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} />
<TextField label="Updated At" value={fmt(item?.updated_at)} InputProps={{ readOnly: true }} />
</Box>
</FormDialogContent>
<DialogActions>
<Button onClick={props.onClose}>Close</Button>
</DialogActions>
</Dialog>
)
}
@@ -0,0 +1,295 @@
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import { useEffect, useRef, useState } from 'react'
import Autocomplete from './Autocomplete'
import FormDialogContent from './FormDialogContent'
import PageAlert from './PageAlert'
import SelectField from './SelectField'
import { api, AuthProvider, UserGroup } from '../api'
export type AuthProviderFormState = {
name: string
type: 'ldap' | 'oidc' | 'db'
enabled: boolean
ldap_url: string
ldap_bind_dn: string
ldap_bind_password: string
ldap_user_base_dn: string
ldap_user_filter: string
ldap_tls_insecure_skip_verify: boolean
oidc_client_id: string
oidc_client_secret: string
oidc_authorize_url: string
oidc_token_url: string
oidc_userinfo_url: string
oidc_redirect_url: string
oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean
oidc_groups_claim: string
oidc_admission_expr: string
default_user_group_id: string
}
type AuthProviderFormDialogProps = {
open: boolean
busy: boolean
dialogError: string | null
editingProvider: AuthProvider | null
userGroups: UserGroup[]
form: AuthProviderFormState
setForm: React.Dispatch<React.SetStateAction<AuthProviderFormState>>
onClose: () => void
onSave: () => void
onDialogErrorClose: () => void
}
export function emptyAuthProviderForm(): AuthProviderFormState {
return {
name: '',
type: 'ldap',
enabled: true,
ldap_url: '',
ldap_bind_dn: '',
ldap_bind_password: '',
ldap_user_base_dn: '',
ldap_user_filter: '(uid={username})',
ldap_tls_insecure_skip_verify: false,
oidc_client_id: '',
oidc_client_secret: '',
oidc_authorize_url: '',
oidc_token_url: '',
oidc_userinfo_url: '',
oidc_redirect_url: '',
oidc_scopes: 'openid profile email',
oidc_tls_insecure_skip_verify: false,
oidc_groups_claim: 'groups',
oidc_admission_expr: '',
default_user_group_id: ''
}
}
export function authProviderToForm(item: AuthProvider): AuthProviderFormState {
return {
name: item.name,
type: item.type,
enabled: item.enabled,
ldap_url: item.ldap_url,
ldap_bind_dn: item.ldap_bind_dn,
ldap_bind_password: item.ldap_bind_password,
ldap_user_base_dn: item.ldap_user_base_dn,
ldap_user_filter: item.ldap_user_filter || '(uid={username})',
ldap_tls_insecure_skip_verify: item.ldap_tls_insecure_skip_verify,
oidc_client_id: item.oidc_client_id,
oidc_client_secret: item.oidc_client_secret,
oidc_authorize_url: item.oidc_authorize_url,
oidc_token_url: item.oidc_token_url,
oidc_userinfo_url: item.oidc_userinfo_url,
oidc_redirect_url: item.oidc_redirect_url,
oidc_scopes: item.oidc_scopes || 'openid profile email',
oidc_tls_insecure_skip_verify: item.oidc_tls_insecure_skip_verify,
oidc_groups_claim: item.oidc_groups_claim || 'groups',
oidc_admission_expr: item.oidc_admission_expr || '',
default_user_group_id: item.default_user_group_id
}
}
export default function AuthProviderFormDialog(props: AuthProviderFormDialogProps) {
const isLDAP: boolean = props.form.type === 'ldap'
const isOIDC: boolean = props.form.type === 'oidc'
const [testing, setTesting] = useState<boolean>(false)
const [testError, setTestError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<string | null>(null)
const [testUsername, setTestUsername] = useState<string>('')
const [testPassword, setTestPassword] = useState<string>('')
const testControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
if (!props.open) {
setTesting(false)
setTestError(null)
setTestResult(null)
setTestUsername('')
setTestPassword('')
}
}, [props.open])
useEffect(() => {
return () => {
if (testControllerRef.current) {
testControllerRef.current.abort()
}
}
}, [])
const handleTest = async () => {
const provider: AuthProvider | null = props.editingProvider
const controller: AbortController = new AbortController()
if (!provider) {
return
}
if (testing) {
if (testControllerRef.current) {
testControllerRef.current.abort()
}
return
}
testControllerRef.current = controller
setTesting(true)
setTestError(null)
setTestResult(null)
try {
const result: { status: string; user?: string } = await api.testAuthProvider(
provider.id,
{ username: testUsername.trim() || undefined, password: testPassword || undefined },
controller.signal
)
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
setTestResult('LDAP test canceled.')
return
}
setTestError(err instanceof Error ? err.message : 'LDAP test failed')
} finally {
setTesting(false)
testControllerRef.current = null
}
}
return (
<Dialog open={props.open} onClose={() => { if (!props.busy) props.onClose() }} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">
{props.editingProvider ? `Edit Provider: ${props.editingProvider.name}` : 'New Provider'}
</Typography>
<PageAlert message={props.dialogError} onClose={props.onDialogErrorClose} />
</Box>
</DialogTitle>
<FormDialogContent>
<TextField label="Name" value={props.form.name} onChange={(event) => props.setForm((prev) => ({ ...prev, name: event.target.value }))} />
{!props.editingProvider ? (
<SelectField select label="Type" value={props.form.type} onChange={(event) => props.setForm((prev) => ({ ...prev, type: event.target.value as 'ldap' | 'oidc' }))}>
<MenuItem value="ldap">LDAP</MenuItem>
<MenuItem value="oidc">OIDC</MenuItem>
</SelectField>
) : null}
<FormControlLabel
control={<Checkbox checked={props.form.enabled} onChange={(event) => props.setForm((prev) => ({ ...prev, enabled: event.target.checked }))} />}
label="Enabled"
/>
{props.editingProvider?.type !== 'db' ? (
<>
<Autocomplete<UserGroup, false, false, false>
options={props.userGroups}
value={props.userGroups.find((group: UserGroup) => group.id === props.form.default_user_group_id) ?? null}
onChange={(_event, value) => props.setForm((prev) => ({ ...prev, default_user_group_id: value?.id ?? '' }))}
getOptionLabel={(group) => group.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No user groups"
renderInput={(params) => (
<TextField
{...params}
label="Default User Group"
helperText="New users from this provider are added to this group automatically."
/>
)}
/>
{isLDAP ? (
<>
<TextField label="LDAP URL" value={props.form.ldap_url} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_url: event.target.value }))} helperText="Example: ldap://ldap.example.com:389" />
<TextField label="Bind DN (optional)" value={props.form.ldap_bind_dn} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))} />
<TextField label="Bind Password" type="password" value={props.form.ldap_bind_password} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_bind_password: event.target.value }))} />
<TextField label="User Base DN" value={props.form.ldap_user_base_dn} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_user_base_dn: event.target.value }))} />
<TextField label="User Filter" value={props.form.ldap_user_filter} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_user_filter: event.target.value }))} helperText="Use {username} as placeholder. Example: (uid={username})" />
<FormControlLabel
control={<Checkbox checked={props.form.ldap_tls_insecure_skip_verify} onChange={(event) => props.setForm((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))} />}
label="TLS insecure skip verify (testing/self-signed only)"
/>
</>
) : null}
{isOIDC ? (
<>
<TextField label="Client ID" value={props.form.oidc_client_id} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_client_id: event.target.value }))} />
<TextField label="Client Secret" type="password" value={props.form.oidc_client_secret} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_client_secret: event.target.value }))} />
<TextField label="Authorize URL" value={props.form.oidc_authorize_url} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_authorize_url: event.target.value }))} />
<TextField label="Token URL" value={props.form.oidc_token_url} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_token_url: event.target.value }))} />
<TextField label="UserInfo URL (optional)" value={props.form.oidc_userinfo_url} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_userinfo_url: event.target.value }))} />
<TextField
label="Redirect URL"
value={props.form.oidc_redirect_url}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_redirect_url: event.target.value }))}
helperText={props.editingProvider ? `Callback URL: /api/auth/oidc/${props.editingProvider.id}/callback` : 'You can set this after creating the provider once you have its ID.'}
/>
<TextField label="Scopes" value={props.form.oidc_scopes} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_scopes: event.target.value }))} helperText="Space-separated. Example: openid profile email" />
<FormControlLabel
control={<Checkbox checked={props.form.oidc_tls_insecure_skip_verify} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))} />}
label="OIDC TLS insecure skip verify (testing/self-signed only)"
/>
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Admission</Typography>
<TextField label="Groups Claim" value={props.form.oidc_groups_claim} onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_groups_claim: event.target.value }))} helperText="Claim name containing group membership. Default: groups" />
<TextField
label="Admission Expression (optional)"
value={props.form.oidc_admission_expr}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_admission_expr: event.target.value }))}
multiline
minRows={2}
helperText={
<span>
Boolean expression. Empty = allow all.
Variables: <code>email</code>, <code>email_domain</code>, <code>groups</code> (array), <code>sub</code>, <code>username</code>, <code>claims</code> (map).
Examples: <code>{'email_domain == "company.com"'}</code>, <code>{"\"admin\" in groups"}</code>,
<code>{'email_domain == "corp.com" && "dev" in groups'}</code>
</span>
}
/>
</Box>
</>
) : null}
{props.editingProvider && props.editingProvider.type === 'ldap' ? (
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Test LDAP Connection</Typography>
{testError ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'error.main' }}>
<ErrorOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testError}</Typography>
</Box>
) : testResult ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'success.main' }}>
<CheckCircleOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testResult}</Typography>
</Box>
) : null}
<TextField label="Test Username (optional)" size="small" value={testUsername} onChange={(event) => setTestUsername(event.target.value)} />
<TextField label="Test Password (optional)" type="password" size="small" value={testPassword} onChange={(event) => setTestPassword(event.target.value)} />
<Box>
<Button variant="outlined" size="small" onClick={handleTest} color={testing ? 'warning' : 'primary'}>
{testing ? 'Cancel Test' : 'Test Connection'}
</Button>
</Box>
</Box>
) : null}
</>
) : null}
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onSave} disabled={props.busy}>
{props.busy ? 'Saving...' : props.editingProvider ? 'Save' : 'Create'}
</Button>
<Button onClick={props.onClose} disabled={props.busy}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
+26 -328
View File
@@ -3,72 +3,21 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox' import Checkbox from '@mui/material/Checkbox'
import Chip from '@mui/material/Chip' import Chip from '@mui/material/Chip'
import Dialog from '../components/ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
import List from '@mui/material/List' import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem' import ListItem from '@mui/material/ListItem'
import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' import { useEffect, useMemo, useState } from 'react'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import { useEffect, useMemo, useRef, useState } from 'react'
import CompactListItemText from '../components/CompactListItemText' import CompactListItemText from '../components/CompactListItemText'
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog' import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
import FormDialogContent from '../components/FormDialogContent'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SelectField from '../components/SelectField'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import Autocomplete from '../components/Autocomplete' import AuthProviderDetailsDialog from '../components/AuthProviderDetailsDialog'
import AuthProviderFormDialog, { AuthProviderFormState, authProviderToForm, emptyAuthProviderForm } from '../components/AuthProviderFormDialog'
import { api, AuthProvider, UserGroup } from '../api' import { api, AuthProvider, UserGroup } from '../api'
type ProviderFormState = {
name: string
type: 'ldap' | 'oidc' | 'db'
enabled: boolean
ldap_url: string
ldap_bind_dn: string
ldap_bind_password: string
ldap_user_base_dn: string
ldap_user_filter: string
ldap_tls_insecure_skip_verify: boolean
oidc_client_id: string
oidc_client_secret: string
oidc_authorize_url: string
oidc_token_url: string
oidc_userinfo_url: string
oidc_redirect_url: string
oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean
default_user_group_id: string
}
function emptyProviderForm(): ProviderFormState {
return {
name: '',
type: 'ldap',
enabled: true,
ldap_url: '',
ldap_bind_dn: '',
ldap_bind_password: '',
ldap_user_base_dn: '',
ldap_user_filter: '(uid={username})',
ldap_tls_insecure_skip_verify: false,
oidc_client_id: '',
oidc_client_secret: '',
oidc_authorize_url: '',
oidc_token_url: '',
oidc_userinfo_url: '',
oidc_redirect_url: '',
oidc_scopes: 'openid profile email',
oidc_tls_insecure_skip_verify: false,
default_user_group_id: ''
}
}
export default function AdminSiteAuthPage() { export default function AdminSiteAuthPage() {
// Providers list // Providers list
const [providers, setProviders] = useState<AuthProvider[]>([]) const [providers, setProviders] = useState<AuthProvider[]>([])
@@ -80,9 +29,10 @@ export default function AdminSiteAuthPage() {
// Provider dialog // Provider dialog
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null) const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null)
const [form, setForm] = useState<ProviderFormState>(emptyProviderForm()) const [form, setForm] = useState<AuthProviderFormState>(emptyAuthProviderForm())
const [dialogError, setDialogError] = useState<string | null>(null) const [dialogError, setDialogError] = useState<string | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [detailsItem, setDetailsItem] = useState<AuthProvider | null>(null)
// Delete dialog // Delete dialog
const [deleteItem, setDeleteItem] = useState<AuthProvider | null>(null) const [deleteItem, setDeleteItem] = useState<AuthProvider | null>(null)
@@ -91,14 +41,6 @@ export default function AdminSiteAuthPage() {
const [deleteError, setDeleteError] = useState<string | null>(null) const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
// LDAP test
const [testing, setTesting] = useState(false)
const [testError, setTestError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<string | null>(null)
const [testUsername, setTestUsername] = useState('')
const [testPassword, setTestPassword] = useState('')
const testControllerRef = useRef<AbortController | null>(null)
const filteredProviders = useMemo(() => { const filteredProviders = useMemo(() => {
const needle = providerSearch.trim().toLowerCase() const needle = providerSearch.trim().toLowerCase()
if (!needle) return providers if (!needle) return providers
@@ -124,50 +66,17 @@ export default function AdminSiteAuthPage() {
loadProviders() loadProviders()
}, []) }, [])
useEffect(() => {
return () => {
if (testControllerRef.current) testControllerRef.current.abort()
}
}, [])
const openCreate = () => { const openCreate = () => {
setEditingProvider(null) setEditingProvider(null)
setForm(emptyProviderForm()) setForm(emptyAuthProviderForm())
setDialogError(null) setDialogError(null)
setTestError(null)
setTestResult(null)
setTestUsername('')
setTestPassword('')
setDialogOpen(true) setDialogOpen(true)
} }
const openEdit = (item: AuthProvider) => { const openEdit = (item: AuthProvider) => {
setEditingProvider(item) setEditingProvider(item)
setForm({ setForm(authProviderToForm(item))
name: item.name,
type: item.type,
enabled: item.enabled,
ldap_url: item.ldap_url,
ldap_bind_dn: item.ldap_bind_dn,
ldap_bind_password: item.ldap_bind_password,
ldap_user_base_dn: item.ldap_user_base_dn,
ldap_user_filter: item.ldap_user_filter || '(uid={username})',
ldap_tls_insecure_skip_verify: item.ldap_tls_insecure_skip_verify,
oidc_client_id: item.oidc_client_id,
oidc_client_secret: item.oidc_client_secret,
oidc_authorize_url: item.oidc_authorize_url,
oidc_token_url: item.oidc_token_url,
oidc_userinfo_url: item.oidc_userinfo_url,
oidc_redirect_url: item.oidc_redirect_url,
oidc_scopes: item.oidc_scopes || 'openid profile email',
oidc_tls_insecure_skip_verify: item.oidc_tls_insecure_skip_verify,
default_user_group_id: item.default_user_group_id
})
setDialogError(null) setDialogError(null)
setTestError(null)
setTestResult(null)
setTestUsername('')
setTestPassword('')
setDialogOpen(true) setDialogOpen(true)
} }
@@ -208,39 +117,6 @@ export default function AdminSiteAuthPage() {
} }
} }
const handleTest = async () => {
if (!editingProvider) return
if (testing) {
if (testControllerRef.current) testControllerRef.current.abort()
return
}
const controller = new AbortController()
testControllerRef.current = controller
setTesting(true)
setTestError(null)
setTestResult(null)
try {
const result = await api.testAuthProvider(
editingProvider.id,
{ username: testUsername.trim() || undefined, password: testPassword || undefined },
controller.signal
)
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
setTestResult('LDAP test canceled.')
return
}
setTestError(err instanceof Error ? err.message : 'LDAP test failed')
} finally {
setTesting(false)
testControllerRef.current = null
}
}
const isLDAP = form.type === 'ldap'
const isOIDC = form.type === 'oidc'
return ( return (
<Box sx={{ display: 'grid', gap: 1, minWidth: 0 }}> <Box sx={{ display: 'grid', gap: 1, minWidth: 0 }}>
<Typography variant="h5" sx={{ mb: 1 }}>Admin: Site Authentication</Typography> <Typography variant="h5" sx={{ mb: 1 }}>Admin: Site Authentication</Typography>
@@ -284,9 +160,9 @@ export default function AdminSiteAuthPage() {
<Typography sx={{ wordBreak: 'break-word' }}>{item.name}</Typography> <Typography sx={{ wordBreak: 'break-word' }}>{item.name}</Typography>
<Chip label={item.type.toUpperCase()} size="small" variant="outlined" /> <Chip label={item.type.toUpperCase()} size="small" variant="outlined" />
{item.enabled ? ( {item.enabled ? (
<Chip label="enabled" size="small" color="success" /> <Chip label="Enabled" size="small" color="success" />
) : ( ) : (
<Chip label="disabled" size="small" /> <Chip label="Disabled" size="small" color="error" />
)} )}
</Box> </Box>
} }
@@ -300,6 +176,7 @@ export default function AdminSiteAuthPage() {
disableTypography disableTypography
/> />
<ListRowActions> <ListRowActions>
<ListRowActionButton onClick={() => setDetailsItem(item)}>View</ListRowActionButton>
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton> <ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
<ListRowActionButton color="error" disabled={item.id === 'db'} onClick={() => { <ListRowActionButton color="error" disabled={item.id === 'db'} onClick={() => {
setDeleteError(null) setDeleteError(null)
@@ -315,203 +192,24 @@ export default function AdminSiteAuthPage() {
</List> </List>
</SectionCard> </SectionCard>
{/* Create / Edit dialog */} <AuthProviderFormDialog
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth> open={dialogOpen}
<DialogTitle> busy={saving}
<Box sx={{ display: 'grid', gap: 1 }}> dialogError={dialogError}
<Typography variant="h6"> editingProvider={editingProvider}
{editingProvider ? `Edit Provider: ${editingProvider.name}` : 'New Provider'} userGroups={userGroups}
</Typography> form={form}
<PageAlert message={dialogError} onClose={() => setDialogError(null)} /> setForm={setForm}
</Box> onClose={() => setDialogOpen(false)}
</DialogTitle> onSave={handleSaveProvider}
<FormDialogContent> onDialogErrorClose={() => setDialogError(null)}
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/> />
{!editingProvider ? (
<SelectField <AuthProviderDetailsDialog
select item={detailsItem}
label="Type" userGroups={userGroups}
value={form.type} onClose={() => setDetailsItem(null)}
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value as 'ldap' | 'oidc' }))}
>
<MenuItem value="ldap">LDAP</MenuItem>
<MenuItem value="oidc">OIDC</MenuItem>
</SelectField>
) : null}
<FormControlLabel
control={
<Checkbox
checked={form.enabled}
onChange={(e) => setForm((prev) => ({ ...prev, enabled: e.target.checked }))}
/> />
}
label="Enabled"
/>
{editingProvider?.type !== 'db' ? (
<>
<Autocomplete<UserGroup, false, false, false>
options={userGroups}
value={userGroups.find((g) => g.id === form.default_user_group_id) ?? null}
onChange={(_event, value) => setForm((prev) => ({ ...prev, default_user_group_id: value?.id ?? '' }))}
getOptionLabel={(g) => g.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
noOptionsText="No user groups"
renderInput={(params) => (
<TextField
{...params}
label="Default User Group"
helperText="New users from this provider are added to this group automatically."
/>
)}
/>
{isLDAP ? (
<>
<TextField
label="LDAP URL"
value={form.ldap_url}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_url: e.target.value }))}
helperText="Example: ldap://ldap.example.com:389"
/>
<TextField
label="Bind DN (optional)"
value={form.ldap_bind_dn}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_bind_dn: e.target.value }))}
/>
<TextField
label="Bind Password"
type="password"
value={form.ldap_bind_password}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_bind_password: e.target.value }))}
/>
<TextField
label="User Base DN"
value={form.ldap_user_base_dn}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_user_base_dn: e.target.value }))}
/>
<TextField
label="User Filter"
value={form.ldap_user_filter}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_user_filter: e.target.value }))}
helperText="Use {username} as placeholder. Example: (uid={username})"
/>
<FormControlLabel
control={
<Checkbox
checked={form.ldap_tls_insecure_skip_verify}
onChange={(e) => setForm((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: e.target.checked }))}
/>
}
label="TLS insecure skip verify (testing/self-signed only)"
/>
</>
) : null}
{isOIDC ? (
<>
<TextField
label="Client ID"
value={form.oidc_client_id}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_client_id: e.target.value }))}
/>
<TextField
label="Client Secret"
type="password"
value={form.oidc_client_secret}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_client_secret: e.target.value }))}
/>
<TextField
label="Authorize URL"
value={form.oidc_authorize_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_authorize_url: e.target.value }))}
/>
<TextField
label="Token URL"
value={form.oidc_token_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_token_url: e.target.value }))}
/>
<TextField
label="UserInfo URL (optional)"
value={form.oidc_userinfo_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_userinfo_url: e.target.value }))}
/>
<TextField
label="Redirect URL"
value={form.oidc_redirect_url}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_redirect_url: e.target.value }))}
helperText={
editingProvider
? `Callback URL: /api/auth/oidc/${editingProvider.id}/callback`
: 'You can set this after creating the provider once you have its ID.'
}
/>
<TextField
label="Scopes"
value={form.oidc_scopes}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_scopes: e.target.value }))}
helperText="Space-separated. Example: openid profile email"
/>
<FormControlLabel
control={
<Checkbox
checked={form.oidc_tls_insecure_skip_verify}
onChange={(e) => setForm((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: e.target.checked }))}
/>
}
label="OIDC TLS insecure skip verify (testing/self-signed only)"
/>
</>
) : null}
{editingProvider && editingProvider.type === 'ldap' ? (
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
<Typography variant="subtitle2">Test LDAP Connection</Typography>
{testError ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'error.main' }}>
<ErrorOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testError}</Typography>
</Box>
) : testResult ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, color: 'success.main' }}>
<CheckCircleOutlineIcon fontSize="small" />
<Typography variant="body2" color="inherit">{testResult}</Typography>
</Box>
) : null}
<TextField
label="Test Username (optional)"
size="small"
value={testUsername}
onChange={(e) => setTestUsername(e.target.value)}
/>
<TextField
label="Test Password (optional)"
type="password"
size="small"
value={testPassword}
onChange={(e) => setTestPassword(e.target.value)}
/>
<Box>
<Button
variant="outlined"
size="small"
onClick={handleTest}
color={testing ? 'warning' : 'primary'}
>
{testing ? 'Cancel Test' : 'Test Connection'}
</Button>
</Box>
</Box>
) : null}
</>) : null}
</FormDialogContent>
<DialogActions>
<Button variant="contained" onClick={handleSaveProvider} disabled={saving}>
{saving ? 'Saving...' : editingProvider ? 'Save' : 'Create'}
</Button>
<Button onClick={() => setDialogOpen(false)} disabled={saving}>Cancel</Button>
</DialogActions>
</Dialog>
{/* Delete dialog */} {/* Delete dialog */}
<ConfirmDeleteDialog <ConfirmDeleteDialog