Compare commits
4 Commits
fa44e0a762
...
f88195aba9
| Author | SHA1 | Date | |
|---|---|---|---|
| f88195aba9 | |||
| 048454600d | |||
| cd6f2ef5e3 | |||
| 307e856dfe |
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import "database/sql"
|
||||
import "errors"
|
||||
import "fmt"
|
||||
import "strings"
|
||||
import "time"
|
||||
|
||||
@@ -14,6 +15,10 @@ const AuthProviderTypeOIDC = "oidc"
|
||||
|
||||
const BuiltinDBProviderPublicID = "db"
|
||||
|
||||
const GroupSyncModeOff string = "off"
|
||||
const GroupSyncModeFirstLogin string = "first_login"
|
||||
const GroupSyncModeSync string = "sync"
|
||||
|
||||
func scanAuthProvider(row interface {
|
||||
Scan(dest ...any) error
|
||||
}, item *models.AuthProvider) error {
|
||||
@@ -40,6 +45,8 @@ func scanAuthProvider(row interface {
|
||||
&item.OIDCTLSInsecureSkipVerify,
|
||||
&item.OIDCGroupsClaim,
|
||||
&item.OIDCAdmissionExpr,
|
||||
&item.OIDCEndSessionURL,
|
||||
&item.GroupSyncMode,
|
||||
&defaultUserGroupID,
|
||||
&item.UserCount,
|
||||
&item.CreatedAt,
|
||||
@@ -58,7 +65,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,
|
||||
p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_end_session_url, p.group_sync_mode,
|
||||
COALESCE(ug.public_id, ''),
|
||||
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
|
||||
p.created_at, p.updated_at`
|
||||
@@ -67,6 +74,105 @@ const authProviderFromClause = `
|
||||
FROM auth_providers p
|
||||
LEFT JOIN user_groups ug ON ug.id = p.default_user_group_id`
|
||||
|
||||
func normalizeAuthProviderGroupSyncMode(value string) (string, error) {
|
||||
var mode string
|
||||
|
||||
mode = strings.TrimSpace(value)
|
||||
if mode == "" {
|
||||
return GroupSyncModeOff, nil
|
||||
}
|
||||
if mode == GroupSyncModeOff || mode == GroupSyncModeFirstLogin || mode == GroupSyncModeSync {
|
||||
return mode, nil
|
||||
}
|
||||
return "", errors.New("invalid group_sync_mode")
|
||||
}
|
||||
|
||||
func scanAuthProviderGroupMappings(rows *sql.Rows) ([]models.AuthProviderGroupMapping, error) {
|
||||
var items []models.AuthProviderGroupMapping
|
||||
var item models.AuthProviderGroupMapping
|
||||
var err error
|
||||
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&item.ClaimValue, &item.GroupID, &item.GroupName, &item.CreatedAt, &item.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListAuthProviderGroupMappings(providerID string) ([]models.AuthProviderGroupMapping, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
rows, err = s.Query(`
|
||||
SELECT m.claim_value, g.public_id, g.name, m.created_at, m.updated_at
|
||||
FROM auth_provider_group_mappings m
|
||||
JOIN auth_providers p ON p.id = m.auth_provider_id
|
||||
JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE p.public_id = ?
|
||||
ORDER BY m.claim_value, g.name`, strings.TrimSpace(providerID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAuthProviderGroupMappings(rows)
|
||||
}
|
||||
|
||||
func (s *Store) fillAuthProviderGroupMappings(items []models.AuthProvider) ([]models.AuthProvider, error) {
|
||||
var err error
|
||||
var i int
|
||||
|
||||
for i = 0; i < len(items); i++ {
|
||||
items[i].GroupMappings, err = s.ListAuthProviderGroupMappings(items[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func insertAuthProviderGroupMappings(exec sqlExecutor, providerID string, mappings []models.AuthProviderGroupMapping, now int64) error {
|
||||
var result sql.Result
|
||||
var err error
|
||||
var mapping models.AuthProviderGroupMapping
|
||||
var claimValue string
|
||||
var groupID string
|
||||
var affected int64
|
||||
var i int
|
||||
|
||||
for i = 0; i < len(mappings); i++ {
|
||||
mapping = mappings[i]
|
||||
claimValue = strings.TrimSpace(mapping.ClaimValue)
|
||||
groupID = strings.TrimSpace(mapping.GroupID)
|
||||
if claimValue == "" {
|
||||
return errors.New("group mapping claim_value is required")
|
||||
}
|
||||
if groupID == "" {
|
||||
return errors.New("group mapping group_id is required")
|
||||
}
|
||||
result, err = exec.Exec(`
|
||||
INSERT INTO auth_provider_group_mappings (auth_provider_id, group_id, claim_value, created_at, updated_at)
|
||||
SELECT p.id, g.id, ?, ?, ?
|
||||
FROM auth_providers p
|
||||
JOIN user_groups g ON g.public_id = ?
|
||||
WHERE p.public_id = ? AND g.scope = ?`,
|
||||
claimValue, now, now, groupID, strings.TrimSpace(providerID), UserGroupScopeExplicit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err = result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("explicit user group not found for group mapping: %s", groupID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAuthProviders() ([]models.AuthProvider, error) {
|
||||
var rows *sql.Rows
|
||||
var items []models.AuthProvider
|
||||
@@ -85,7 +191,11 @@ func (s *Store) ListAuthProviders() ([]models.AuthProvider, error) {
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.fillAuthProviderGroupMappings(items)
|
||||
}
|
||||
|
||||
func (s *Store) ListEnabledAuthProviders() ([]models.AuthProvider, error) {
|
||||
@@ -106,7 +216,11 @@ func (s *Store) ListEnabledAuthProviders() ([]models.AuthProvider, error) {
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.fillAuthProviderGroupMappings(items)
|
||||
}
|
||||
|
||||
func (s *Store) GetAuthProvider(id string) (models.AuthProvider, error) {
|
||||
@@ -116,6 +230,10 @@ func (s *Store) GetAuthProvider(id string) (models.AuthProvider, error) {
|
||||
|
||||
row = s.QueryRow(`SELECT`+authProviderSelectCols+authProviderFromClause+` WHERE p.public_id = ?`, strings.TrimSpace(id))
|
||||
err = scanAuthProvider(row, &item)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.GroupMappings, err = s.ListAuthProviderGroupMappings(item.ID)
|
||||
return item, err
|
||||
}
|
||||
|
||||
@@ -130,7 +248,9 @@ func (s *Store) CountAuthProviderUsers(id string) (int, error) {
|
||||
}
|
||||
|
||||
func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
|
||||
var tx *sql.Tx
|
||||
var err error
|
||||
var owned bool
|
||||
var now int64
|
||||
|
||||
now = time.Now().Unix()
|
||||
@@ -149,26 +269,45 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
||||
if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
|
||||
item.OIDCScopes = "openid profile email"
|
||||
}
|
||||
item.GroupSyncMode, err = normalizeAuthProviderGroupSyncMode(item.GroupSyncMode)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
_, err = s.Exec(`INSERT INTO auth_providers (
|
||||
tx, owned, err = s.begin()
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`INSERT INTO auth_providers (
|
||||
public_id, name, type, enabled,
|
||||
ldap_url, ldap_bind_dn, ldap_bind_password, ldap_user_base_dn, ldap_user_filter,
|
||||
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,
|
||||
oidc_groups_claim, oidc_admission_expr, oidc_end_session_url, group_sync_mode,
|
||||
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.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode,
|
||||
item.DefaultUserGroupID, item.DefaultUserGroupID,
|
||||
item.CreatedAt, item.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
rollbackIfOwned(tx, owned)
|
||||
return item, err
|
||||
}
|
||||
err = insertAuthProviderGroupMappings(tx, item.ID, item.GroupMappings, now)
|
||||
if err != nil {
|
||||
rollbackIfOwned(tx, owned)
|
||||
return item, err
|
||||
}
|
||||
err = commitIfOwned(tx, owned)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
@@ -176,7 +315,9 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
|
||||
var tx *sql.Tx
|
||||
var err error
|
||||
var owned bool
|
||||
var now int64
|
||||
|
||||
now = time.Now().Unix()
|
||||
@@ -188,14 +329,23 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
||||
if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
|
||||
item.OIDCScopes = "openid profile email"
|
||||
}
|
||||
item.GroupSyncMode, err = normalizeAuthProviderGroupSyncMode(item.GroupSyncMode)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
_, err = s.Exec(`UPDATE auth_providers SET
|
||||
tx, owned, err = s.begin()
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`UPDATE auth_providers SET
|
||||
name = ?, enabled = ?,
|
||||
ldap_url = ?, ldap_bind_dn = ?, ldap_bind_password = ?, ldap_user_base_dn = ?, ldap_user_filter = ?,
|
||||
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 = ?,
|
||||
oidc_groups_claim = ?, oidc_admission_expr = ?, oidc_end_session_url = ?, group_sync_mode = ?,
|
||||
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
|
||||
updated_at = ?
|
||||
WHERE public_id = ?`,
|
||||
@@ -204,11 +354,26 @@ 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.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode,
|
||||
item.DefaultUserGroupID, item.DefaultUserGroupID,
|
||||
item.UpdatedAt,
|
||||
strings.TrimSpace(item.ID),
|
||||
)
|
||||
if err != nil {
|
||||
rollbackIfOwned(tx, owned)
|
||||
return item, err
|
||||
}
|
||||
_, err = tx.Exec(`DELETE FROM auth_provider_group_mappings WHERE auth_provider_id = (SELECT id FROM auth_providers WHERE public_id = ?)`, strings.TrimSpace(item.ID))
|
||||
if err != nil {
|
||||
rollbackIfOwned(tx, owned)
|
||||
return item, err
|
||||
}
|
||||
err = insertAuthProviderGroupMappings(tx, item.ID, item.GroupMappings, now)
|
||||
if err != nil {
|
||||
rollbackIfOwned(tx, owned)
|
||||
return item, err
|
||||
}
|
||||
err = commitIfOwned(tx, owned)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
|
||||
@@ -90,9 +90,22 @@ func (s *Store) CreateUserGroup(item models.UserGroup) (models.UserGroup, error)
|
||||
func (s *Store) UpdateUserGroup(item models.UserGroup) (models.UserGroup, error) {
|
||||
var now int64
|
||||
var err error
|
||||
var mappingCount int
|
||||
now = time.Now().UTC().Unix()
|
||||
item.UpdatedAt = now
|
||||
item.Scope = NormalizeUserGroupScope(item.Scope)
|
||||
if item.Scope != UserGroupScopeExplicit {
|
||||
err = s.QueryRow(`SELECT COUNT(*)
|
||||
FROM auth_provider_group_mappings m
|
||||
JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE g.public_id = ?`, strings.TrimSpace(item.ID)).Scan(&mappingCount)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if mappingCount > 0 {
|
||||
return item, errors.New("group is used by auth provider group mappings")
|
||||
}
|
||||
}
|
||||
_, err = s.Exec(`UPDATE user_groups
|
||||
SET name = ?, description = ?, disabled = ?, totp_required = ?, scope = ?, updated_at = ?
|
||||
WHERE public_id = ?`,
|
||||
|
||||
@@ -421,11 +421,11 @@ type projectGroupRoleRequest struct {
|
||||
}
|
||||
|
||||
type userGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Disabled bool `json:"disabled"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Disabled bool `json:"disabled"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type userGroupMemberRequest struct {
|
||||
@@ -561,9 +561,22 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
|
||||
|
||||
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||
var cookie *http.Cookie
|
||||
var endSessionURL string
|
||||
var err error
|
||||
|
||||
cookie, err = r.Cookie(api.sessionCookieName())
|
||||
if err == nil {
|
||||
var user models.User
|
||||
var userErr error
|
||||
user, _, _, userErr = api.store(r).GetSessionUser(cookie.Value)
|
||||
if userErr == nil && strings.TrimSpace(user.AuthProviderID) != "" {
|
||||
var provider models.AuthProvider
|
||||
var provErr error
|
||||
provider, provErr = api.store(r).GetAuthProvider(user.AuthProviderID)
|
||||
if provErr == nil {
|
||||
endSessionURL = strings.TrimSpace(provider.OIDCEndSessionURL)
|
||||
}
|
||||
}
|
||||
_ = api.store(r).DeleteSession(cookie.Value)
|
||||
}
|
||||
cookie = &http.Cookie{
|
||||
@@ -574,6 +587,10 @@ func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]stri
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
if endSessionURL != "" {
|
||||
WriteJSON(w, http.StatusOK, map[string]string{"oidc_end_session_url": endSessionURL})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -1024,11 +1041,11 @@ func (api *API) CreateUserGroup(w http.ResponseWriter, r *http.Request, _ map[st
|
||||
return
|
||||
}
|
||||
group = models.UserGroup{
|
||||
Name: req.Name,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
Disabled: req.Disabled,
|
||||
TOTPRequired: req.TOTPRequired,
|
||||
Scope: db.NormalizeUserGroupScope(req.Scope),
|
||||
Name: req.Name,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
Disabled: req.Disabled,
|
||||
TOTPRequired: req.TOTPRequired,
|
||||
Scope: db.NormalizeUserGroupScope(req.Scope),
|
||||
}
|
||||
group, err = api.store(r).CreateUserGroup(group)
|
||||
if err != nil {
|
||||
|
||||
@@ -387,6 +387,8 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
|
||||
if user.Disabled {
|
||||
return user, errors.New("user disabled")
|
||||
}
|
||||
err = api.oidcSyncUserGroups(ctx, provider, user, claims, false)
|
||||
if err != nil { return user, err }
|
||||
return user, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) { return user, err }
|
||||
@@ -408,12 +410,88 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
|
||||
created, err = api.storeContext(ctx).CreateUser(user, "")
|
||||
if err != nil { return created, err }
|
||||
|
||||
if provider.DefaultUserGroupID != "" {
|
||||
_ = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, created.ID)
|
||||
}
|
||||
err = api.oidcSyncUserGroups(ctx, provider, created, claims, true)
|
||||
if err != nil { return created, err }
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (api *API) oidcSyncUserGroups(ctx context.Context, provider models.AuthProvider, user models.User, claims oidcUserClaims, isNew bool) error {
|
||||
var mode string
|
||||
var groupsClaim string
|
||||
var claimValues []string
|
||||
var claimSet map[string]bool
|
||||
var mappings []models.AuthProviderGroupMapping
|
||||
var groupMatched map[string]bool
|
||||
var err error
|
||||
var assignedAny bool
|
||||
var i int
|
||||
var groupID string
|
||||
var matched bool
|
||||
|
||||
mode = strings.TrimSpace(provider.GroupSyncMode)
|
||||
if mode == db.GroupSyncModeOff || mode == "" {
|
||||
if isNew && strings.TrimSpace(provider.DefaultUserGroupID) != "" {
|
||||
err = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, user.ID)
|
||||
if err != nil { return fmt.Errorf("oidc default user group assignment failed: %w", err) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if mode == db.GroupSyncModeFirstLogin && !isNew {
|
||||
return nil
|
||||
}
|
||||
if mode != db.GroupSyncModeFirstLogin && mode != db.GroupSyncModeSync {
|
||||
return fmt.Errorf("invalid oidc group sync mode: %s", mode)
|
||||
}
|
||||
|
||||
groupsClaim = strings.TrimSpace(provider.OIDCGroupsClaim)
|
||||
if groupsClaim == "" {
|
||||
groupsClaim = "groups"
|
||||
}
|
||||
claimValues = oidcExtractGroups(claims.Raw, groupsClaim)
|
||||
claimSet = make(map[string]bool, len(claimValues))
|
||||
for i = 0; i < len(claimValues); i++ {
|
||||
claimSet[claimValues[i]] = true
|
||||
}
|
||||
|
||||
mappings, err = api.storeContext(ctx).ListAuthProviderGroupMappings(provider.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("oidc group mapping lookup failed: %w", err)
|
||||
}
|
||||
|
||||
groupMatched = make(map[string]bool)
|
||||
for i = 0; i < len(mappings); i++ {
|
||||
groupID = strings.TrimSpace(mappings[i].GroupID)
|
||||
if groupID == "" {
|
||||
continue
|
||||
}
|
||||
if _, matched = groupMatched[groupID]; !matched {
|
||||
groupMatched[groupID] = false
|
||||
}
|
||||
if claimSet[mappings[i].ClaimValue] {
|
||||
groupMatched[groupID] = true
|
||||
}
|
||||
}
|
||||
|
||||
assignedAny = false
|
||||
for groupID, matched = range groupMatched {
|
||||
if matched {
|
||||
err = api.storeContext(ctx).AddUserGroupMember(groupID, user.ID)
|
||||
if err != nil { return fmt.Errorf("oidc sso group assignment failed: %w", err) }
|
||||
assignedAny = true
|
||||
} else if mode == db.GroupSyncModeSync {
|
||||
err = api.storeContext(ctx).RemoveUserGroupMember(groupID, user.ID)
|
||||
if err != nil { return fmt.Errorf("oidc sso group removal failed: %w", err) }
|
||||
}
|
||||
}
|
||||
|
||||
if isNew && !assignedAny && strings.TrimSpace(provider.DefaultUserGroupID) != "" {
|
||||
err = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, user.ID)
|
||||
if err != nil { return fmt.Errorf("oidc default user group assignment failed: %w", err) }
|
||||
}
|
||||
|
||||
return 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{
|
||||
|
||||
@@ -49,12 +49,23 @@ type AuthProvider struct {
|
||||
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
|
||||
OIDCGroupsClaim string `json:"oidc_groups_claim"`
|
||||
OIDCAdmissionExpr string `json:"oidc_admission_expr"`
|
||||
OIDCEndSessionURL string `json:"oidc_end_session_url"`
|
||||
GroupSyncMode string `json:"group_sync_mode"`
|
||||
GroupMappings []AuthProviderGroupMapping `json:"group_mappings,omitempty"`
|
||||
DefaultUserGroupID string `json:"default_user_group_id"`
|
||||
UserCount int `json:"user_count,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AuthProviderGroupMapping struct {
|
||||
ClaimValue string `json:"claim_value"`
|
||||
GroupID string `json:"group_id"`
|
||||
GroupName string `json:"group_name,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -94,14 +105,14 @@ type SubjectPermission struct {
|
||||
}
|
||||
|
||||
type UserGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Disabled bool `json:"disabled"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Disabled bool `json:"disabled"`
|
||||
TOTPRequired bool `json:"totp_required"`
|
||||
Scope string `json:"scope"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Scope string `json:"scope"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserGroupMember struct {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE auth_providers ADD COLUMN oidc_end_session_url TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,16 @@
|
||||
ALTER TABLE auth_providers ADD COLUMN group_sync_mode TEXT NOT NULL DEFAULT 'off';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_provider_group_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
auth_provider_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
claim_value TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (auth_provider_id) REFERENCES auth_providers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
UNIQUE (auth_provider_id, claim_value, group_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_provider_group_mappings_provider ON auth_provider_group_mappings(auth_provider_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_provider_group_mappings_claim ON auth_provider_group_mappings(auth_provider_id, claim_value);
|
||||
+12
-1
@@ -437,6 +437,14 @@ export interface PrincipalProjectRole {
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface AuthProviderGroupMapping {
|
||||
claim_value: string
|
||||
group_id: string
|
||||
group_name?: string
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
}
|
||||
|
||||
export interface AuthProvider {
|
||||
id: string
|
||||
name: string
|
||||
@@ -458,6 +466,9 @@ export interface AuthProvider {
|
||||
oidc_tls_insecure_skip_verify: boolean
|
||||
oidc_groups_claim: string
|
||||
oidc_admission_expr: string
|
||||
oidc_end_session_url: string
|
||||
group_sync_mode: 'off' | 'first_login' | 'sync'
|
||||
group_mappings?: AuthProviderGroupMapping[]
|
||||
default_user_group_id: string
|
||||
user_count?: number
|
||||
created_at: number
|
||||
@@ -1539,7 +1550,7 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password, otp_code: otpCode || '' })
|
||||
}),
|
||||
logout: () => request<void>('/api/logout', { method: 'POST' }),
|
||||
logout: () => request<{ oidc_end_session_url?: string } | null>('/api/logout', { method: 'POST' }),
|
||||
me: () => request<User>('/api/me'),
|
||||
updateMe: (payload: MeUpdatePayload) =>
|
||||
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -130,9 +130,13 @@ export default function Layout() {
|
||||
|
||||
const handleLogout = async () => {
|
||||
closeAccountMenu()
|
||||
await api.logout()
|
||||
const result = await api.logout()
|
||||
setUser(null)
|
||||
navigate('/login')
|
||||
if (result?.oidc_end_session_url) {
|
||||
window.location.href = result.oidc_end_session_url
|
||||
} else {
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ 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'
|
||||
import { AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
|
||||
|
||||
type AuthProviderDetailsDialogProps = {
|
||||
item: AuthProvider | null
|
||||
@@ -49,7 +49,6 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
|
||||
<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 }} />
|
||||
@@ -58,6 +57,7 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
|
||||
<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 }} />
|
||||
<TextField label="Fallback User Group" value={defaultGroup ? `${defaultGroup.name} (${defaultGroup.id})` : item.default_user_group_id || '-'} InputProps={{ readOnly: true }} />
|
||||
</>
|
||||
) : null}
|
||||
{item?.type === 'oidc' ? (
|
||||
@@ -70,8 +70,25 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
|
||||
<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="Group Sync Mode" value={item.group_sync_mode || 'off'} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Fallback User Group" value={defaultGroup ? `${defaultGroup.name} (${defaultGroup.id})` : item.default_user_group_id || '-'} InputProps={{ readOnly: true }} />
|
||||
<TextField label="Groups Claim" value={item.oidc_groups_claim || 'groups'} InputProps={{ readOnly: true }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Group Mappings</Typography>
|
||||
{item.group_mappings && item.group_mappings.length > 0 ? (
|
||||
<Box sx={{ display: 'grid', gap: 0.5 }}>
|
||||
{item.group_mappings.map((mapping: AuthProviderGroupMapping, index: number) => (
|
||||
<Typography key={`${mapping.claim_value}:${mapping.group_id}:${index}`} variant="body2">
|
||||
{mapping.claim_value} → {mapping.group_name || mapping.group_id}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">No group mappings configured.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<TextField label="Admission Expression" value={item.oidc_admission_expr || '-'} InputProps={{ readOnly: true }} multiline minRows={2} />
|
||||
<TextField label="End Session URL" value={item.oidc_end_session_url || '-'} InputProps={{ readOnly: true }} />
|
||||
</>
|
||||
) : null}
|
||||
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} />
|
||||
|
||||
@@ -5,17 +5,19 @@ import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
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 DeleteIcon from '@mui/icons-material/Delete'
|
||||
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'
|
||||
import { api, AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
|
||||
|
||||
export type AuthProviderFormState = {
|
||||
name: string
|
||||
@@ -37,6 +39,9 @@ export type AuthProviderFormState = {
|
||||
oidc_tls_insecure_skip_verify: boolean
|
||||
oidc_groups_claim: string
|
||||
oidc_admission_expr: string
|
||||
oidc_end_session_url: string
|
||||
group_sync_mode: 'off' | 'first_login' | 'sync'
|
||||
group_mappings: AuthProviderGroupMapping[]
|
||||
default_user_group_id: string
|
||||
}
|
||||
|
||||
@@ -74,6 +79,9 @@ export function emptyAuthProviderForm(): AuthProviderFormState {
|
||||
oidc_tls_insecure_skip_verify: false,
|
||||
oidc_groups_claim: 'groups',
|
||||
oidc_admission_expr: '',
|
||||
oidc_end_session_url: '',
|
||||
group_sync_mode: 'off',
|
||||
group_mappings: [],
|
||||
default_user_group_id: ''
|
||||
}
|
||||
}
|
||||
@@ -99,6 +107,13 @@ export function authProviderToForm(item: AuthProvider): AuthProviderFormState {
|
||||
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 || '',
|
||||
oidc_end_session_url: item.oidc_end_session_url || '',
|
||||
group_sync_mode: (item.group_sync_mode as 'off' | 'first_login' | 'sync') || 'off',
|
||||
group_mappings: Array.isArray(item.group_mappings) ? item.group_mappings.map((mapping: AuthProviderGroupMapping) => ({
|
||||
claim_value: mapping.claim_value || '',
|
||||
group_id: mapping.group_id || '',
|
||||
group_name: mapping.group_name || ''
|
||||
})) : [],
|
||||
default_user_group_id: item.default_user_group_id
|
||||
}
|
||||
}
|
||||
@@ -113,6 +128,49 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
|
||||
const [testPassword, setTestPassword] = useState<string>('')
|
||||
const testControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const updateGroupMapping = (index: number, patch: Partial<AuthProviderGroupMapping>) => {
|
||||
props.setForm((prev: AuthProviderFormState) => {
|
||||
const mappings: AuthProviderGroupMapping[] = prev.group_mappings.map((mapping: AuthProviderGroupMapping, mappingIndex: number) => (
|
||||
mappingIndex === index ? { ...mapping, ...patch } : mapping
|
||||
))
|
||||
return { ...prev, group_mappings: mappings }
|
||||
})
|
||||
}
|
||||
|
||||
const addGroupMapping = () => {
|
||||
props.setForm((prev: AuthProviderFormState) => ({
|
||||
...prev,
|
||||
group_mappings: [...prev.group_mappings, { claim_value: '', group_id: '' }]
|
||||
}))
|
||||
}
|
||||
|
||||
const removeGroupMapping = (index: number) => {
|
||||
props.setForm((prev: AuthProviderFormState) => ({
|
||||
...prev,
|
||||
group_mappings: prev.group_mappings.filter((_mapping: AuthProviderGroupMapping, mappingIndex: number) => mappingIndex !== index)
|
||||
}))
|
||||
}
|
||||
|
||||
function fallbackUserGroupField(helperText: string) {
|
||||
return (
|
||||
<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="Fallback User Group"
|
||||
helperText={helperText}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.open) {
|
||||
setTesting(false)
|
||||
@@ -191,21 +249,6 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
|
||||
/>
|
||||
{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" />
|
||||
@@ -217,6 +260,10 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
|
||||
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)"
|
||||
/>
|
||||
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
|
||||
<Typography variant="subtitle2">Group</Typography>
|
||||
{fallbackUserGroupField('New users from this provider are added to this group automatically.')}
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
{isOIDC ? (
|
||||
@@ -238,8 +285,60 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
|
||||
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>
|
||||
<Typography variant="subtitle2">Group</Typography>
|
||||
{fallbackUserGroupField('New users from this provider are added to this group when no OIDC group mapping matches.')}
|
||||
<SelectField
|
||||
select
|
||||
label="Group Sync Mode"
|
||||
value={props.form.group_sync_mode}
|
||||
onChange={(event) => props.setForm((prev) => ({ ...prev, group_sync_mode: event.target.value as 'off' | 'first_login' | 'sync' }))}
|
||||
helperText="How OIDC group claims map to local user groups (matched by SSO Group Claim on each group)."
|
||||
>
|
||||
<MenuItem value="off">Off — no claim-based assignment</MenuItem>
|
||||
<MenuItem value="first_login">First login — assign matching groups on account creation only</MenuItem>
|
||||
<MenuItem value="sync">Sync — re-sync on every login (adds and removes SSO-mapped groups)</MenuItem>
|
||||
</SelectField>
|
||||
<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" />
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="subtitle2">Group Mappings</Typography>
|
||||
<Button size="small" variant="outlined" onClick={addGroupMapping}>Add Mapping</Button>
|
||||
</Box>
|
||||
{props.form.group_mappings.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
No claim-to-group mappings configured.
|
||||
</Typography>
|
||||
) : null}
|
||||
{props.form.group_mappings.map((mapping: AuthProviderGroupMapping, index: number) => (
|
||||
<Box key={index} sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr auto' }, gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Claim Value"
|
||||
value={mapping.claim_value}
|
||||
onChange={(event) => updateGroupMapping(index, { claim_value: event.target.value })}
|
||||
/>
|
||||
<Autocomplete<UserGroup, false, false, false>
|
||||
options={props.userGroups}
|
||||
value={props.userGroups.find((group: UserGroup) => group.id === mapping.group_id) ?? null}
|
||||
onChange={(_event, value) => updateGroupMapping(index, { group_id: value?.id ?? '', group_name: value?.name ?? '' })}
|
||||
getOptionLabel={(group) => group.name}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
noOptionsText="No explicit user groups"
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="User Group"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<IconButton size="small" onClick={() => removeGroupMapping(index)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
|
||||
<Typography variant="subtitle2">Admission</Typography>
|
||||
<TextField
|
||||
label="Admission Expression (optional)"
|
||||
value={props.form.oidc_admission_expr}
|
||||
@@ -256,6 +355,15 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ borderTop: 1, borderColor: 'divider', pt: 1.5, mt: 0.5, display: 'grid', gap: 1 }}>
|
||||
<Typography variant="subtitle2">Sign-out</Typography>
|
||||
<TextField
|
||||
label="End Session URL (optional)"
|
||||
value={props.form.oidc_end_session_url}
|
||||
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_end_session_url: event.target.value }))}
|
||||
helperText="RP-initiated logout endpoint (end_session_endpoint). When set, users are redirected here on logout to also clear their SSO session."
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
{props.editingProvider && props.editingProvider.type === 'ldap' ? (
|
||||
|
||||
@@ -44,7 +44,7 @@ function defaultUserGroupForm(): UserGroupFormState {
|
||||
description: '',
|
||||
disabled: false,
|
||||
totpRequired: false,
|
||||
scope: 'explicit'
|
||||
scope: 'explicit',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function AdminUserGroupsPage() {
|
||||
description: item.description || '',
|
||||
disabled: item.disabled,
|
||||
totpRequired: Boolean(item.totp_required),
|
||||
scope: item.scope === 'all_users' ? 'all_users' : 'explicit'
|
||||
scope: item.scope === 'all_users' ? 'all_users' : 'explicit',
|
||||
})
|
||||
setEditOpen(true)
|
||||
}
|
||||
@@ -182,7 +182,7 @@ export default function AdminUserGroupsPage() {
|
||||
description: form.description.trim(),
|
||||
disabled: form.disabled,
|
||||
totp_required: form.totpRequired,
|
||||
scope: form.scope
|
||||
scope: form.scope,
|
||||
}
|
||||
if (editID) {
|
||||
await api.updateUserGroup(editID, payload)
|
||||
@@ -321,7 +321,7 @@ export default function AdminUserGroupsPage() {
|
||||
description: selectedGroup.description || '',
|
||||
disabled: selectedGroup.disabled,
|
||||
totp_required: next,
|
||||
scope: selectedGroup.scope === 'all_users' ? 'all_users' : 'explicit'
|
||||
scope: selectedGroup.scope === 'all_users' ? 'all_users' : 'explicit',
|
||||
}
|
||||
await api.updateUserGroup(selectedGroup.id, payload)
|
||||
await loadGroups()
|
||||
|
||||
@@ -14,7 +14,8 @@ import HeaderActionButton from '../components/HeaderActionButton'
|
||||
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import UserFormDialog, { UserFormState } from '../components/UserFormDialog'
|
||||
import { api, SubjectPermission, User, UserCreatePayload, UserUpdatePayload } from '../api'
|
||||
import AuthProviderDetailsDialog from '../components/AuthProviderDetailsDialog'
|
||||
import { api, AuthProvider, SubjectPermission, User, UserCreatePayload, UserUpdatePayload, UserGroup } from '../api'
|
||||
import CompactListItemText from '../components/CompactListItemText'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
|
||||
@@ -53,6 +54,9 @@ export default function AdminUsersPage() {
|
||||
const [resetTOTPConfirm, setResetTOTPConfirm] = useState('')
|
||||
const [resettingTOTP, setResettingTOTP] = useState(false)
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([])
|
||||
const [authProviderDetail, setAuthProviderDetail] = useState<AuthProvider | null>(null)
|
||||
const [authProviderUserGroups, setAuthProviderUserGroups] = useState<UserGroup[]>([])
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const normalized: string = userSearch.trim().toLowerCase()
|
||||
@@ -77,12 +81,14 @@ export default function AdminUsersPage() {
|
||||
}, [])
|
||||
|
||||
const reloadUsers = async () => {
|
||||
const [list, permissions] = await Promise.all([
|
||||
const [list, permissions, providers] = await Promise.all([
|
||||
api.listUsers(),
|
||||
api.listSubjectPermissions(projectCreatePermission, 'user')
|
||||
api.listSubjectPermissions(projectCreatePermission, 'user'),
|
||||
api.listAuthProviders(),
|
||||
])
|
||||
setUsers(Array.isArray(list) ? list : [])
|
||||
setSubjectPermissions(Array.isArray(permissions) ? permissions : [])
|
||||
setAuthProviders(Array.isArray(providers) ? providers : [])
|
||||
}
|
||||
|
||||
const hasDirectProjectCreate = (userID: string) => {
|
||||
@@ -264,6 +270,15 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openAuthProviderDetail = (id: string) => {
|
||||
Promise.all([api.getAuthProvider(id), api.listUserGroups()])
|
||||
.then(([provider, groups]) => {
|
||||
setAuthProviderUserGroups(Array.isArray(groups) ? groups : [])
|
||||
setAuthProviderDetail(provider)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Users</Typography>
|
||||
@@ -304,8 +319,18 @@ export default function AdminUsersPage() {
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{u.is_admin ? 'admin' : 'user'} · source: {u.auth_provider_id || 'db'}{u.totp_required ? ' · TOTP required' : ''}{u.totp_enabled ? ' · TOTP enabled' : ''}{hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}
|
||||
<Typography variant="caption" color="text.secondary" component="span">
|
||||
{u.is_admin ? 'admin' : 'user'} · source:{' '}
|
||||
{u.auth_provider_id ? (
|
||||
<Typography
|
||||
component="span" variant="caption"
|
||||
sx={{ color: 'primary.main', cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||||
onClick={() => openAuthProviderDetail(u.auth_provider_id!)}
|
||||
>
|
||||
{authProviders.find((p) => p.id === u.auth_provider_id)?.name ?? u.auth_provider_id}
|
||||
</Typography>
|
||||
) : 'db'}
|
||||
{u.totp_required ? ' · TOTP required' : ''}{u.totp_enabled ? ' · TOTP enabled' : ''}{hasDirectProjectCreate(u.id) ? ' · direct project.create' : ''}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
@@ -381,6 +406,11 @@ export default function AdminUsersPage() {
|
||||
onConfirm={handleResetTOTP}
|
||||
onClose={() => { setResetTOTPUser(null); setResetTOTPConfirm('') }}
|
||||
/>
|
||||
<AuthProviderDetailsDialog
|
||||
item={authProviderDetail}
|
||||
userGroups={authProviderUserGroups}
|
||||
onClose={() => setAuthProviderDetail(null)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user