Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6cc79b8e5 | |||
| ccc08631ed |
@@ -27,6 +27,7 @@ require (
|
||||
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
||||
github.com/dustin/go-humanize v1.0.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/go-asn1-ber/asn1-ber v1.5.5 // 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/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
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/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
|
||||
|
||||
@@ -38,6 +38,8 @@ func scanAuthProvider(row interface {
|
||||
&item.OIDCRedirectURL,
|
||||
&item.OIDCScopes,
|
||||
&item.OIDCTLSInsecureSkipVerify,
|
||||
&item.OIDCGroupsClaim,
|
||||
&item.OIDCAdmissionExpr,
|
||||
&defaultUserGroupID,
|
||||
&item.UserCount,
|
||||
&item.CreatedAt,
|
||||
@@ -56,6 +58,7 @@ const authProviderSelectCols = `
|
||||
p.ldap_tls_insecure_skip_verify,
|
||||
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_groups_claim, p.oidc_admission_expr,
|
||||
COALESCE(ug.public_id, ''),
|
||||
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
|
||||
p.created_at, p.updated_at`
|
||||
@@ -153,14 +156,16 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
||||
ldap_tls_insecure_skip_verify,
|
||||
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_groups_claim, oidc_admission_expr,
|
||||
default_user_group_id,
|
||||
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.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
|
||||
item.LDAPTLSInsecureSkipVerify,
|
||||
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
||||
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
|
||||
item.OIDCGroupsClaim, item.OIDCAdmissionExpr,
|
||||
item.DefaultUserGroupID, item.DefaultUserGroupID,
|
||||
item.CreatedAt, item.UpdatedAt,
|
||||
)
|
||||
@@ -190,6 +195,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
||||
ldap_tls_insecure_skip_verify = ?,
|
||||
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_groups_claim = ?, oidc_admission_expr = ?,
|
||||
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
|
||||
updated_at = ?
|
||||
WHERE public_id = ?`,
|
||||
@@ -198,6 +204,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
||||
item.LDAPTLSInsecureSkipVerify,
|
||||
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
||||
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
|
||||
item.OIDCGroupsClaim, item.OIDCAdmissionExpr,
|
||||
item.DefaultUserGroupID, item.DefaultUserGroupID,
|
||||
item.UpdatedAt,
|
||||
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")
|
||||
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)
|
||||
if err != nil {
|
||||
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.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 {
|
||||
existing.Enabled = req.Enabled
|
||||
if strings.TrimSpace(req.Name) != "" {
|
||||
|
||||
@@ -17,6 +17,8 @@ import "net/url"
|
||||
import "strings"
|
||||
import "time"
|
||||
|
||||
import "github.com/expr-lang/expr"
|
||||
|
||||
import "codit/internal/db"
|
||||
import "codit/internal/models"
|
||||
|
||||
@@ -30,6 +32,7 @@ type oidcUserClaims struct {
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Raw map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
type oidcErrorResponse struct {
|
||||
@@ -359,6 +362,10 @@ func (api *API) oidcUserInfoFromProvider(ctx context.Context, p models.AuthProvi
|
||||
if strings.TrimSpace(claims.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
|
||||
}
|
||||
|
||||
@@ -370,6 +377,11 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
|
||||
var err error
|
||||
var created models.User
|
||||
|
||||
err = oidcCheckAdmission(provider, claims)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
user, err = api.storeContext(ctx).GetUserByProviderAndSub(provider.ID, claims.Sub)
|
||||
if err == nil {
|
||||
if user.Disabled {
|
||||
@@ -402,6 +414,109 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
|
||||
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 {
|
||||
var single string
|
||||
var multiple []string
|
||||
@@ -458,6 +573,10 @@ func oidcClaimsFromIDToken(idToken string, clientID string) (oidcUserClaims, err
|
||||
if strings.TrimSpace(claims.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
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ type AuthProvider struct {
|
||||
OIDCRedirectURL string `json:"oidc_redirect_url"`
|
||||
OIDCScopes string `json:"oidc_scopes"`
|
||||
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"`
|
||||
UserCount int `json:"user_count,omitempty"`
|
||||
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_scopes: string
|
||||
oidc_tls_insecure_skip_verify: boolean
|
||||
oidc_groups_claim: string
|
||||
oidc_admission_expr: string
|
||||
default_user_group_id: string
|
||||
user_count?: 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 Checkbox from '@mui/material/Checkbox'
|
||||
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 List from '@mui/material/List'
|
||||
import ListItem from '@mui/material/ListItem'
|
||||
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, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'
|
||||
import FormDialogContent from '../components/FormDialogContent'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SelectField from '../components/SelectField'
|
||||
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'
|
||||
|
||||
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() {
|
||||
// Providers list
|
||||
const [providers, setProviders] = useState<AuthProvider[]>([])
|
||||
@@ -80,9 +29,10 @@ export default function AdminSiteAuthPage() {
|
||||
// Provider dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
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 [saving, setSaving] = useState(false)
|
||||
const [detailsItem, setDetailsItem] = useState<AuthProvider | null>(null)
|
||||
|
||||
// Delete dialog
|
||||
const [deleteItem, setDeleteItem] = useState<AuthProvider | null>(null)
|
||||
@@ -91,14 +41,6 @@ export default function AdminSiteAuthPage() {
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
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 needle = providerSearch.trim().toLowerCase()
|
||||
if (!needle) return providers
|
||||
@@ -124,50 +66,17 @@ export default function AdminSiteAuthPage() {
|
||||
loadProviders()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (testControllerRef.current) testControllerRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingProvider(null)
|
||||
setForm(emptyProviderForm())
|
||||
setForm(emptyAuthProviderForm())
|
||||
setDialogError(null)
|
||||
setTestError(null)
|
||||
setTestResult(null)
|
||||
setTestUsername('')
|
||||
setTestPassword('')
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (item: AuthProvider) => {
|
||||
setEditingProvider(item)
|
||||
setForm({
|
||||
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
|
||||
})
|
||||
setForm(authProviderToForm(item))
|
||||
setDialogError(null)
|
||||
setTestError(null)
|
||||
setTestResult(null)
|
||||
setTestUsername('')
|
||||
setTestPassword('')
|
||||
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 (
|
||||
<Box sx={{ display: 'grid', gap: 1, minWidth: 0 }}>
|
||||
<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>
|
||||
<Chip label={item.type.toUpperCase()} size="small" variant="outlined" />
|
||||
{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>
|
||||
}
|
||||
@@ -300,6 +176,7 @@ export default function AdminSiteAuthPage() {
|
||||
disableTypography
|
||||
/>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton onClick={() => setDetailsItem(item)}>View</ListRowActionButton>
|
||||
<ListRowActionButton onClick={() => openEdit(item)}>Edit</ListRowActionButton>
|
||||
<ListRowActionButton color="error" disabled={item.id === 'db'} onClick={() => {
|
||||
setDeleteError(null)
|
||||
@@ -315,203 +192,24 @@ export default function AdminSiteAuthPage() {
|
||||
</List>
|
||||
</SectionCard>
|
||||
|
||||
{/* Create / Edit dialog */}
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="h6">
|
||||
{editingProvider ? `Edit Provider: ${editingProvider.name}` : 'New Provider'}
|
||||
</Typography>
|
||||
<PageAlert message={dialogError} onClose={() => setDialogError(null)} />
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<FormDialogContent>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
<AuthProviderFormDialog
|
||||
open={dialogOpen}
|
||||
busy={saving}
|
||||
dialogError={dialogError}
|
||||
editingProvider={editingProvider}
|
||||
userGroups={userGroups}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onSave={handleSaveProvider}
|
||||
onDialogErrorClose={() => setDialogError(null)}
|
||||
/>
|
||||
{!editingProvider ? (
|
||||
<SelectField
|
||||
select
|
||||
label="Type"
|
||||
value={form.type}
|
||||
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 }))}
|
||||
|
||||
<AuthProviderDetailsDialog
|
||||
item={detailsItem}
|
||||
userGroups={userGroups}
|
||||
onClose={() => setDetailsItem(null)}
|
||||
/>
|
||||
}
|
||||
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 */}
|
||||
<ConfirmDeleteDialog
|
||||
|
||||
Reference in New Issue
Block a user