Compare commits
2 Commits
f536496069
...
c6cc79b8e5
| Author | SHA1 | Date | |
|---|---|---|---|
| c6cc79b8e5 | |||
| ccc08631ed |
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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) != "" {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 '';
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user