Compare commits

..

4 Commits

13 changed files with 532 additions and 61 deletions
+175 -10
View File
@@ -2,6 +2,7 @@ package db
import "database/sql" import "database/sql"
import "errors" import "errors"
import "fmt"
import "strings" import "strings"
import "time" import "time"
@@ -14,6 +15,10 @@ const AuthProviderTypeOIDC = "oidc"
const BuiltinDBProviderPublicID = "db" const BuiltinDBProviderPublicID = "db"
const GroupSyncModeOff string = "off"
const GroupSyncModeFirstLogin string = "first_login"
const GroupSyncModeSync string = "sync"
func scanAuthProvider(row interface { func scanAuthProvider(row interface {
Scan(dest ...any) error Scan(dest ...any) error
}, item *models.AuthProvider) error { }, item *models.AuthProvider) error {
@@ -40,6 +45,8 @@ func scanAuthProvider(row interface {
&item.OIDCTLSInsecureSkipVerify, &item.OIDCTLSInsecureSkipVerify,
&item.OIDCGroupsClaim, &item.OIDCGroupsClaim,
&item.OIDCAdmissionExpr, &item.OIDCAdmissionExpr,
&item.OIDCEndSessionURL,
&item.GroupSyncMode,
&defaultUserGroupID, &defaultUserGroupID,
&item.UserCount, &item.UserCount,
&item.CreatedAt, &item.CreatedAt,
@@ -58,7 +65,7 @@ const authProviderSelectCols = `
p.ldap_tls_insecure_skip_verify, p.ldap_tls_insecure_skip_verify,
p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url, p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url,
p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify, p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify,
p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_end_session_url, p.group_sync_mode,
COALESCE(ug.public_id, ''), COALESCE(ug.public_id, ''),
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id), (SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
p.created_at, p.updated_at` p.created_at, p.updated_at`
@@ -67,6 +74,105 @@ const authProviderFromClause = `
FROM auth_providers p FROM auth_providers p
LEFT JOIN user_groups ug ON ug.id = p.default_user_group_id` 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) { func (s *Store) ListAuthProviders() ([]models.AuthProvider, error) {
var rows *sql.Rows var rows *sql.Rows
var items []models.AuthProvider var items []models.AuthProvider
@@ -85,7 +191,11 @@ func (s *Store) ListAuthProviders() ([]models.AuthProvider, error) {
} }
items = append(items, item) 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) { func (s *Store) ListEnabledAuthProviders() ([]models.AuthProvider, error) {
@@ -106,7 +216,11 @@ func (s *Store) ListEnabledAuthProviders() ([]models.AuthProvider, error) {
} }
items = append(items, item) 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) { 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)) row = s.QueryRow(`SELECT`+authProviderSelectCols+authProviderFromClause+` WHERE p.public_id = ?`, strings.TrimSpace(id))
err = scanAuthProvider(row, &item) err = scanAuthProvider(row, &item)
if err != nil {
return item, err
}
item.GroupMappings, err = s.ListAuthProviderGroupMappings(item.ID)
return item, err 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) { func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
var tx *sql.Tx
var err error var err error
var owned bool
var now int64 var now int64
now = time.Now().Unix() 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) == "" { if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
item.OIDCScopes = "openid profile email" 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, public_id, name, type, enabled,
ldap_url, ldap_bind_dn, ldap_bind_password, ldap_user_base_dn, ldap_user_filter, ldap_url, ldap_bind_dn, ldap_bind_password, ldap_user_base_dn, ldap_user_filter,
ldap_tls_insecure_skip_verify, ldap_tls_insecure_skip_verify,
oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url, oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url,
oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify, oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify,
oidc_groups_claim, oidc_admission_expr, oidc_groups_claim, oidc_admission_expr, oidc_end_session_url, group_sync_mode,
default_user_group_id, default_user_group_id,
created_at, updated_at created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
item.ID, item.Name, item.Type, item.Enabled, item.ID, item.Name, item.Type, item.Enabled,
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter, item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
item.LDAPTLSInsecureSkipVerify, item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL, item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify, item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID,
item.CreatedAt, item.UpdatedAt, 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 { if err != nil {
return item, err 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) { func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvider, error) {
var tx *sql.Tx
var err error var err error
var owned bool
var now int64 var now int64
now = time.Now().Unix() 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) == "" { if item.Type == AuthProviderTypeOIDC && strings.TrimSpace(item.OIDCScopes) == "" {
item.OIDCScopes = "openid profile email" 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 = ?, name = ?, enabled = ?,
ldap_url = ?, ldap_bind_dn = ?, ldap_bind_password = ?, ldap_user_base_dn = ?, ldap_user_filter = ?, ldap_url = ?, ldap_bind_dn = ?, ldap_bind_password = ?, ldap_user_base_dn = ?, ldap_user_filter = ?,
ldap_tls_insecure_skip_verify = ?, ldap_tls_insecure_skip_verify = ?,
oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?, oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?,
oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?, oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?, 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, default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
updated_at = ? updated_at = ?
WHERE public_id = ?`, WHERE public_id = ?`,
@@ -204,11 +354,26 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
item.LDAPTLSInsecureSkipVerify, item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL, item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify, item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID, item.DefaultUserGroupID,
item.UpdatedAt, item.UpdatedAt,
strings.TrimSpace(item.ID), 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 { if err != nil {
return item, err return item, err
} }
+13
View File
@@ -90,9 +90,22 @@ func (s *Store) CreateUserGroup(item models.UserGroup) (models.UserGroup, error)
func (s *Store) UpdateUserGroup(item models.UserGroup) (models.UserGroup, error) { func (s *Store) UpdateUserGroup(item models.UserGroup) (models.UserGroup, error) {
var now int64 var now int64
var err error var err error
var mappingCount int
now = time.Now().UTC().Unix() now = time.Now().UTC().Unix()
item.UpdatedAt = now item.UpdatedAt = now
item.Scope = NormalizeUserGroupScope(item.Scope) 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 _, err = s.Exec(`UPDATE user_groups
SET name = ?, description = ?, disabled = ?, totp_required = ?, scope = ?, updated_at = ? SET name = ?, description = ?, disabled = ?, totp_required = ?, scope = ?, updated_at = ?
WHERE public_id = ?`, WHERE public_id = ?`,
+17
View File
@@ -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) { func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var cookie *http.Cookie var cookie *http.Cookie
var endSessionURL string
var err error var err error
cookie, err = r.Cookie(api.sessionCookieName()) cookie, err = r.Cookie(api.sessionCookieName())
if err == nil { 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) _ = api.store(r).DeleteSession(cookie.Value)
} }
cookie = &http.Cookie{ cookie = &http.Cookie{
@@ -574,6 +587,10 @@ func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]stri
HttpOnly: true, HttpOnly: true,
} }
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
if endSessionURL != "" {
WriteJSON(w, http.StatusOK, map[string]string{"oidc_end_session_url": endSessionURL})
return
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
+81 -3
View File
@@ -387,6 +387,8 @@ func (api *API) oidcGetOrCreateUser(ctx context.Context, claims oidcUserClaims,
if user.Disabled { if user.Disabled {
return user, errors.New("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 return user, nil
} }
if !errors.Is(err, sql.ErrNoRows) { return user, err } 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, "") created, err = api.storeContext(ctx).CreateUser(user, "")
if err != nil { return created, err } if err != nil { return created, err }
if provider.DefaultUserGroupID != "" { err = api.oidcSyncUserGroups(ctx, provider, created, claims, true)
_ = api.storeContext(ctx).AddUserGroupMember(provider.DefaultUserGroupID, created.ID) if err != nil { return created, err }
}
return created, nil 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. // oidcAdmissionEnvType defines the expression environment for type-checking at save time.
// Variables exposed: email, email_domain, sub, username, groups ([]string), claims (raw map). // Variables exposed: email, email_domain, sub, username, groups ([]string), claims (raw map).
var oidcAdmissionEnvType = map[string]any{ var oidcAdmissionEnvType = map[string]any{
+11
View File
@@ -49,12 +49,23 @@ type AuthProvider struct {
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"` OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
OIDCGroupsClaim string `json:"oidc_groups_claim"` OIDCGroupsClaim string `json:"oidc_groups_claim"`
OIDCAdmissionExpr string `json:"oidc_admission_expr"` 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"` DefaultUserGroupID string `json:"default_user_group_id"`
UserCount int `json:"user_count,omitempty"` UserCount int `json:"user_count,omitempty"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_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 { type Project struct {
ID string `json:"id"` ID string `json:"id"`
Slug string `json:"slug"` Slug string `json:"slug"`
@@ -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
View File
@@ -437,6 +437,14 @@ export interface PrincipalProjectRole {
created_at: number created_at: number
} }
export interface AuthProviderGroupMapping {
claim_value: string
group_id: string
group_name?: string
created_at?: number
updated_at?: number
}
export interface AuthProvider { export interface AuthProvider {
id: string id: string
name: string name: string
@@ -458,6 +466,9 @@ export interface AuthProvider {
oidc_tls_insecure_skip_verify: boolean oidc_tls_insecure_skip_verify: boolean
oidc_groups_claim: string oidc_groups_claim: string
oidc_admission_expr: 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 default_user_group_id: string
user_count?: number user_count?: number
created_at: number created_at: number
@@ -1539,7 +1550,7 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password, otp_code: otpCode || '' }) 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'), me: () => request<User>('/api/me'),
updateMe: (payload: MeUpdatePayload) => updateMe: (payload: MeUpdatePayload) =>
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }), request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
+5 -1
View File
@@ -130,10 +130,14 @@ export default function Layout() {
const handleLogout = async () => { const handleLogout = async () => {
closeAccountMenu() closeAccountMenu()
await api.logout() const result = await api.logout()
setUser(null) setUser(null)
if (result?.oidc_end_session_url) {
window.location.href = result.oidc_end_session_url
} else {
navigate('/login') navigate('/login')
} }
}
const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => { const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => {
setAccountMenuAnchor(event.currentTarget) setAccountMenuAnchor(event.currentTarget)
@@ -7,7 +7,7 @@ import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import { AuthProvider, UserGroup } from '../api' import { AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
type AuthProviderDetailsDialogProps = { type AuthProviderDetailsDialogProps = {
item: AuthProvider | null item: AuthProvider | null
@@ -49,7 +49,6 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
<TextField label="Type" value={item?.type || ''} 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="Enabled" value={yesNo(Boolean(item?.enabled))} InputProps={{ readOnly: true }} />
<TextField label="Users" value={String(item?.user_count ?? 0)} 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' ? ( {item?.type === 'ldap' ? (
<> <>
<TextField label="LDAP URL" value={item.ldap_url || ''} InputProps={{ readOnly: true }} /> <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 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="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="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} ) : null}
{item?.type === 'oidc' ? ( {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="Redirect URL" value={item.oidc_redirect_url || ''} InputProps={{ readOnly: true }} />
<TextField label="Scopes" value={item.oidc_scopes || ''} 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="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 }} /> <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="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} ) : null}
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} /> <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 DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
import IconButton from '@mui/material/IconButton'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import DeleteIcon from '@mui/icons-material/Delete'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import Autocomplete from './Autocomplete' import Autocomplete from './Autocomplete'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import PageAlert from './PageAlert' import PageAlert from './PageAlert'
import SelectField from './SelectField' import SelectField from './SelectField'
import { api, AuthProvider, UserGroup } from '../api' import { api, AuthProvider, AuthProviderGroupMapping, UserGroup } from '../api'
export type AuthProviderFormState = { export type AuthProviderFormState = {
name: string name: string
@@ -37,6 +39,9 @@ export type AuthProviderFormState = {
oidc_tls_insecure_skip_verify: boolean oidc_tls_insecure_skip_verify: boolean
oidc_groups_claim: string oidc_groups_claim: string
oidc_admission_expr: 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 default_user_group_id: string
} }
@@ -74,6 +79,9 @@ export function emptyAuthProviderForm(): AuthProviderFormState {
oidc_tls_insecure_skip_verify: false, oidc_tls_insecure_skip_verify: false,
oidc_groups_claim: 'groups', oidc_groups_claim: 'groups',
oidc_admission_expr: '', oidc_admission_expr: '',
oidc_end_session_url: '',
group_sync_mode: 'off',
group_mappings: [],
default_user_group_id: '' 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_tls_insecure_skip_verify: item.oidc_tls_insecure_skip_verify,
oidc_groups_claim: item.oidc_groups_claim || 'groups', oidc_groups_claim: item.oidc_groups_claim || 'groups',
oidc_admission_expr: item.oidc_admission_expr || '', 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 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 [testPassword, setTestPassword] = useState<string>('')
const testControllerRef = useRef<AbortController | null>(null) 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(() => { useEffect(() => {
if (!props.open) { if (!props.open) {
setTesting(false) setTesting(false)
@@ -191,21 +249,6 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
/> />
{props.editingProvider?.type !== 'db' ? ( {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 ? ( {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="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 }))} />} 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)" 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} ) : null}
{isOIDC ? ( {isOIDC ? (
@@ -238,8 +285,60 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
label="OIDC TLS insecure skip verify (testing/self-signed only)" 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 }}> <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" /> <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 <TextField
label="Admission Expression (optional)" label="Admission Expression (optional)"
value={props.form.oidc_admission_expr} value={props.form.oidc_admission_expr}
@@ -256,6 +355,15 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
} }
/> />
</Box> </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} ) : null}
{props.editingProvider && props.editingProvider.type === 'ldap' ? ( {props.editingProvider && props.editingProvider.type === 'ldap' ? (
+4 -4
View File
@@ -44,7 +44,7 @@ function defaultUserGroupForm(): UserGroupFormState {
description: '', description: '',
disabled: false, disabled: false,
totpRequired: false, totpRequired: false,
scope: 'explicit' scope: 'explicit',
} }
} }
@@ -166,7 +166,7 @@ export default function AdminUserGroupsPage() {
description: item.description || '', description: item.description || '',
disabled: item.disabled, disabled: item.disabled,
totpRequired: Boolean(item.totp_required), totpRequired: Boolean(item.totp_required),
scope: item.scope === 'all_users' ? 'all_users' : 'explicit' scope: item.scope === 'all_users' ? 'all_users' : 'explicit',
}) })
setEditOpen(true) setEditOpen(true)
} }
@@ -182,7 +182,7 @@ export default function AdminUserGroupsPage() {
description: form.description.trim(), description: form.description.trim(),
disabled: form.disabled, disabled: form.disabled,
totp_required: form.totpRequired, totp_required: form.totpRequired,
scope: form.scope scope: form.scope,
} }
if (editID) { if (editID) {
await api.updateUserGroup(editID, payload) await api.updateUserGroup(editID, payload)
@@ -321,7 +321,7 @@ export default function AdminUserGroupsPage() {
description: selectedGroup.description || '', description: selectedGroup.description || '',
disabled: selectedGroup.disabled, disabled: selectedGroup.disabled,
totp_required: next, 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 api.updateUserGroup(selectedGroup.id, payload)
await loadGroups() await loadGroups()
+35 -5
View File
@@ -14,7 +14,8 @@ import HeaderActionButton from '../components/HeaderActionButton'
import { ListRowActionButton, ListRowActions } from '../components/ListRowActions' import { ListRowActionButton, ListRowActions } from '../components/ListRowActions'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import UserFormDialog, { UserFormState } from '../components/UserFormDialog' 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 CompactListItemText from '../components/CompactListItemText'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
@@ -53,6 +54,9 @@ export default function AdminUsersPage() {
const [resetTOTPConfirm, setResetTOTPConfirm] = useState('') const [resetTOTPConfirm, setResetTOTPConfirm] = useState('')
const [resettingTOTP, setResettingTOTP] = useState(false) const [resettingTOTP, setResettingTOTP] = useState(false)
const [userSearch, setUserSearch] = useState('') 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 filteredUsers = useMemo(() => {
const normalized: string = userSearch.trim().toLowerCase() const normalized: string = userSearch.trim().toLowerCase()
@@ -77,12 +81,14 @@ export default function AdminUsersPage() {
}, []) }, [])
const reloadUsers = async () => { const reloadUsers = async () => {
const [list, permissions] = await Promise.all([ const [list, permissions, providers] = await Promise.all([
api.listUsers(), api.listUsers(),
api.listSubjectPermissions(projectCreatePermission, 'user') api.listSubjectPermissions(projectCreatePermission, 'user'),
api.listAuthProviders(),
]) ])
setUsers(Array.isArray(list) ? list : []) setUsers(Array.isArray(list) ? list : [])
setSubjectPermissions(Array.isArray(permissions) ? permissions : []) setSubjectPermissions(Array.isArray(permissions) ? permissions : [])
setAuthProviders(Array.isArray(providers) ? providers : [])
} }
const hasDirectProjectCreate = (userID: string) => { 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 ( return (
<Box> <Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Users</Typography> <Typography variant="h5" sx={{ mb: 2 }}>Admin: Users</Typography>
@@ -304,8 +319,18 @@ export default function AdminUsersPage() {
</Typography> </Typography>
} }
secondary={ secondary={
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary" component="span">
{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' : ''} {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> </Typography>
} }
/> />
@@ -381,6 +406,11 @@ export default function AdminUsersPage() {
onConfirm={handleResetTOTP} onConfirm={handleResetTOTP}
onClose={() => { setResetTOTPUser(null); setResetTOTPConfirm('') }} onClose={() => { setResetTOTPUser(null); setResetTOTPConfirm('') }}
/> />
<AuthProviderDetailsDialog
item={authProviderDetail}
userGroups={authProviderUserGroups}
onClose={() => setAuthProviderDetail(null)}
/>
</Box> </Box>
) )
} }