diff --git a/betterdesk-server/api/auth_handlers.go b/betterdesk-server/api/auth_handlers.go index d69a7177..26519d5c 100644 --- a/betterdesk-server/api/auth_handlers.go +++ b/betterdesk-server/api/auth_handlers.go @@ -210,31 +210,66 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } - // Try LDAP authentication if enabled - var ldapResult *auth.LDAPResult - if s.ldapProvider != nil && s.ldapProvider.IsEnabled() { - ldapResult, err = s.ldapProvider.Authenticate(body.Username, body.Password) - if err != nil { - log.Printf("[LDAP] Auth error for %s: %v", body.Username, err) - // LDAP error is non-fatal — fall through to local auth + // Determine the authentication provider bound to this account. An account + // is authoritatively tied to a single provider (Issue #148): accounts + // provisioned via LDAP/OIDC can ONLY authenticate through that provider, + // and local accounts can ONLY authenticate with their local password. + // This eliminates the dual-password hole where a same-name AD account and + // local account could each log in with their own password, and guarantees + // that LDAP group→role mapping is always re-applied on LDAP logins. + provider := db.AuthProviderLocal + if user != nil && user.AuthProvider != "" { + provider = user.AuthProvider + } + + // OIDC-bound accounts must authenticate through single sign-on, never with + // a password on this endpoint. + if user != nil && provider == db.AuthProviderOIDC { + if s.auditLog != nil { + s.auditLog.Log(audit.ActionAuthLoginFailed, s.remoteIP(r), body.Username, map[string]string{"reason": "oidc_account_password_login"}) + } + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "This account uses single sign-on. Please log in with your identity provider."}) + return + } + + ldapEnabled := s.ldapProvider != nil && s.ldapProvider.IsEnabled() + + // Authenticate through LDAP only when the account is LDAP-bound, or when + // it is an unknown user and LDAP is enabled (first-time auto-provisioning). + // A local account is NEVER probed against LDAP, even on a username clash. + if ldapEnabled && (user == nil || provider == db.AuthProviderLDAP) { + ldapResult, ldapErr := s.ldapProvider.Authenticate(body.Username, body.Password) + if ldapErr != nil { + log.Printf("[LDAP] Auth error for %s: %v", body.Username, ldapErr) ldapResult = nil } + if ldapResult != nil && ldapResult.Authenticated { - // LDAP auth succeeded — auto-provision local user if needed + role := ldapResult.Role + if role == "" { + role = auth.RoleViewer + } + if user == nil { - hash, hashErr := auth.HashPassword(body.Password) + // Auto-provision an LDAP-bound account. The local password hash + // is a random, unusable value — the AD password is NEVER stored + // as a local credential, so it cannot be replayed via the local + // password path. + randomSecret, randErr := auth.GenerateRandomString(32) + if randErr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"}) + return + } + unusable, hashErr := auth.HashPassword(randomSecret) if hashErr != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Internal error"}) return } - role := ldapResult.Role - if role == "" { - role = auth.RoleViewer - } newUser := &db.User{ Username: body.Username, - PasswordHash: hash, + PasswordHash: unusable, Role: role, + AuthProvider: db.AuthProviderLDAP, } if createErr := s.db.CreateUser(newUser); createErr != nil { log.Printf("[LDAP] Failed to auto-create user %s: %v", body.Username, createErr) @@ -248,20 +283,28 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } log.Printf("[LDAP] Auto-provisioned user %s with role %s", body.Username, role) } else { - // Update role from LDAP group mapping if changed + // Existing LDAP-bound account — always re-apply the AD group→role + // mapping so permission changes in AD take effect on next login. + changed := false if ldapResult.Role != "" && ldapResult.Role != user.Role { user.Role = ldapResult.Role - _ = s.db.UpdateUser(user) + changed = true log.Printf("[LDAP] Updated role for %s to %s", body.Username, ldapResult.Role) } + if user.AuthProvider != db.AuthProviderLDAP { + user.AuthProvider = db.AuthProviderLDAP + changed = true + } + if changed { + _ = s.db.UpdateUser(user) + } } if s.auditLog != nil { s.auditLog.Log(audit.ActionAuthLogin, s.remoteIP(r), user.Username, map[string]string{"method": "ldap"}) } - // LDAP users skip TOTP (TOTP is managed locally, not via LDAP) - // If user has TOTP enabled locally, still require it + // TOTP is managed locally; if enabled, still require it for LDAP users. if user.TOTPEnabled { partialToken, tokenErr := s.jwtManager.GenerateWithTTL(user.Username, "__2fa_pending__", 5*time.Minute) if tokenErr != nil { @@ -288,10 +331,23 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { }) return } + + // LDAP authentication failed. An LDAP-bound account must NOT fall back + // to local password verification — reject immediately. + if user != nil && provider == db.AuthProviderLDAP { + if s.auditLog != nil { + s.auditLog.Log(audit.ActionAuthLoginFailed, s.remoteIP(r), body.Username, map[string]string{"method": "ldap"}) + } + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"}) + return + } + // Unknown user with failed LDAP — fall through to the local check below, + // which returns 401 because user == nil. } - // Local password verification - if user == nil || !auth.VerifyPassword(user.PasswordHash, body.Password) { + // Local password verification — only for local accounts. LDAP-bound and + // unknown users are rejected here. + if user == nil || provider != db.AuthProviderLocal || !auth.VerifyPassword(user.PasswordHash, body.Password) { if s.auditLog != nil { s.auditLog.Log(audit.ActionAuthLoginFailed, s.remoteIP(r), body.Username, nil) } @@ -492,6 +548,7 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { ID int64 `json:"id"` Username string `json:"username"` Role string `json:"role"` + AuthProvider string `json:"auth_provider"` TOTPEnabled bool `json:"totp_enabled"` IsServerAdmin bool `json:"is_server_admin"` CreatedAt string `json:"created_at"` @@ -500,9 +557,14 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { result := make([]userView, len(users)) for i, u := range users { + provider := u.AuthProvider + if provider == "" { + provider = db.AuthProviderLocal + } result[i] = userView{ ID: u.ID, Username: u.Username, Role: u.Role, - TOTPEnabled: u.TOTPEnabled, IsServerAdmin: u.IsServerAdmin, + AuthProvider: provider, + TOTPEnabled: u.TOTPEnabled, IsServerAdmin: u.IsServerAdmin, CreatedAt: u.CreatedAt, LastLogin: u.LastLogin, } } @@ -662,6 +724,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { Username: body.Username, PasswordHash: hash, Role: body.Role, + AuthProvider: db.AuthProviderLocal, } if err := s.db.CreateUser(user); err != nil { writeJSON(w, http.StatusConflict, map[string]string{"error": "Username already exists or DB error"}) @@ -702,6 +765,16 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { } if body.Password != "" { + // Issue #148: LDAP/OIDC accounts have no usable local password. Setting + // one here would re-open the dual-authentication hole, so reject it. + provider := user.AuthProvider + if provider == "" { + provider = db.AuthProviderLocal + } + if provider != db.AuthProviderLocal { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Cannot set a local password on an LDAP/OIDC account"}) + return + } hash, err := auth.HashPassword(body.Password) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Password hash failed"}) diff --git a/betterdesk-server/api/oidc_handlers.go b/betterdesk-server/api/oidc_handlers.go index 9722ef97..757b2aa4 100644 --- a/betterdesk-server/api/oidc_handlers.go +++ b/betterdesk-server/api/oidc_handlers.go @@ -360,6 +360,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { Username: result.Username, PasswordHash: hash, Role: role, + AuthProvider: db.AuthProviderOIDC, } if createErr := s.db.CreateUser(newUser); createErr != nil { log.Printf("[OIDC] Failed to create user %s: %v", result.Username, createErr) @@ -375,12 +376,21 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { log.Printf("[OIDC] Auto-provisioned user %s with role %s", result.Username, role) } else { - // Update role from OIDC group mapping if changed + // Update role from OIDC group mapping if changed, and ensure the + // account is bound to the OIDC provider (Issue #148). + changed := false if result.Role != "" && result.Role != user.Role { user.Role = result.Role - _ = s.db.UpdateUser(user) + changed = true log.Printf("[OIDC] Updated role for %s to %s", result.Username, result.Role) } + if user.AuthProvider != db.AuthProviderOIDC { + user.AuthProvider = db.AuthProviderOIDC + changed = true + } + if changed { + _ = s.db.UpdateUser(user) + } } // Audit log diff --git a/betterdesk-server/db/database.go b/betterdesk-server/db/database.go index b2b11c2d..ca88c9a6 100644 --- a/betterdesk-server/db/database.go +++ b/betterdesk-server/db/database.go @@ -39,12 +39,23 @@ type ServerConfig struct { } // User represents an API user account. +// Authentication provider values for User.AuthProvider. Determines how an +// account is allowed to authenticate. A user bound to a non-local provider +// can ONLY authenticate via that provider — password login is rejected even +// when a username collides with a local account (Issue #148). +const ( + AuthProviderLocal = "local" // local password (PBKDF2/bcrypt) + AuthProviderLDAP = "ldap" // LDAP / Active Directory + AuthProviderOIDC = "oidc" // OpenID Connect / OAuth2 SSO +) + type User struct { ID int64 `json:"id"` Username string `json:"username"` PasswordHash string `json:"-"` Role string `json:"role"` // admin, operator, viewer IsServerAdmin bool `json:"is_server_admin"` // Phase 3: separate server admin flag + AuthProvider string `json:"auth_provider"` // local, ldap, oidc (Issue #148) TOTPSecret string `json:"-"` TOTPEnabled bool `json:"totp_enabled"` TOTPRecoveryCodes string `json:"-"` // JSON array of bcrypt-hashed recovery codes (H4) diff --git a/betterdesk-server/db/postgres.go b/betterdesk-server/db/postgres.go index a37cbded..cea3685e 100644 --- a/betterdesk-server/db/postgres.go +++ b/betterdesk-server/db/postgres.go @@ -118,6 +118,7 @@ func (pg *PostgresDB) Migrate() error { username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', + auth_provider TEXT NOT NULL DEFAULT 'local', totp_secret TEXT NOT NULL DEFAULT '', totp_enabled BOOLEAN NOT NULL DEFAULT FALSE, totp_recovery_codes TEXT DEFAULT NULL, @@ -406,6 +407,8 @@ func (pg *PostgresDB) Migrate() error { `ALTER TABLE peers ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT ''`, // users: server admin flag (RBAC Phase 52) `ALTER TABLE users ADD COLUMN IF NOT EXISTS is_server_admin BOOLEAN NOT NULL DEFAULT FALSE`, + // users: authentication provider (Issue #148) + `ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'local'`, // org_users: server_user_id for linking existing users (Issue #106) `ALTER TABLE org_users ADD COLUMN IF NOT EXISTS server_user_id BIGINT NOT NULL DEFAULT 0`, } @@ -909,10 +912,13 @@ func (pg *PostgresDB) ListConfigByPrefix(prefix string) ([]ServerConfig, error) // CreateUser inserts a new user and sets u.ID to the generated primary key. func (pg *PostgresDB) CreateUser(u *User) error { + if u.AuthProvider == "" { + u.AuthProvider = AuthProviderLocal + } err := pg.pool.QueryRow(pg.ctx, - `INSERT INTO users (username, password_hash, role, totp_secret, totp_enabled) - VALUES ($1, $2, $3, $4, $5) RETURNING id`, - u.Username, u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled, + `INSERT INTO users (username, password_hash, role, auth_provider, totp_secret, totp_enabled) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + u.Username, u.PasswordHash, u.Role, u.AuthProvider, u.TOTPSecret, u.TOTPEnabled, ).Scan(&u.ID) if err != nil { return fmt.Errorf("db: CreateUser: %w", err) @@ -928,9 +934,10 @@ func scanUser(row pgx.Row) (*User, error) { var createdAt *time.Time var lastLogin *time.Time var recoveryCodes *string + var authProvider *string err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, - &u.TOTPSecret, &u.TOTPEnabled, &createdAt, &lastLogin, &u.IsServerAdmin, &recoveryCodes) + &u.TOTPSecret, &u.TOTPEnabled, &createdAt, &lastLogin, &u.IsServerAdmin, &recoveryCodes, &authProvider) if err != nil { return nil, err } @@ -944,6 +951,11 @@ func scanUser(row pgx.Row) (*User, error) { if recoveryCodes != nil { u.TOTPRecoveryCodes = *recoveryCodes } + if authProvider != nil && *authProvider != "" { + u.AuthProvider = *authProvider + } else { + u.AuthProvider = AuthProviderLocal + } return u, nil } @@ -952,7 +964,7 @@ func scanUser(row pgx.Row) (*User, error) { func (pg *PostgresDB) GetUser(username string) (*User, error) { row := pg.pool.QueryRow(pg.ctx, `SELECT id, username, password_hash, role, totp_secret, totp_enabled, - created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes FROM users WHERE username = $1`, username) + created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes, COALESCE(auth_provider, 'local') FROM users WHERE username = $1`, username) u, err := scanUser(row) if err == pgx.ErrNoRows { return nil, nil @@ -964,7 +976,7 @@ func (pg *PostgresDB) GetUser(username string) (*User, error) { func (pg *PostgresDB) GetUserByID(id int64) (*User, error) { row := pg.pool.QueryRow(pg.ctx, `SELECT id, username, password_hash, role, totp_secret, totp_enabled, - created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes FROM users WHERE id = $1`, id) + created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes, COALESCE(auth_provider, 'local') FROM users WHERE id = $1`, id) u, err := scanUser(row) if err == pgx.ErrNoRows { return nil, nil @@ -976,7 +988,7 @@ func (pg *PostgresDB) GetUserByID(id int64) (*User, error) { func (pg *PostgresDB) ListUsers() ([]*User, error) { rows, err := pg.pool.Query(pg.ctx, `SELECT id, username, password_hash, role, totp_secret, totp_enabled, - created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes FROM users ORDER BY id`) + created_at, last_login, COALESCE(is_server_admin, FALSE), totp_recovery_codes, COALESCE(auth_provider, 'local') FROM users ORDER BY id`) if err != nil { return nil, fmt.Errorf("db: ListUsers: %w", err) } @@ -1001,10 +1013,13 @@ func (pg *PostgresDB) UpdateUser(u *User) error { } else { recoveryCodes = u.TOTPRecoveryCodes } + if u.AuthProvider == "" { + u.AuthProvider = AuthProviderLocal + } _, err := pg.pool.Exec(pg.ctx, - `UPDATE users SET password_hash = $1, role = $2, totp_secret = $3, totp_enabled = $4, is_server_admin = $5, totp_recovery_codes = $6 - WHERE id = $7`, - u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled, u.IsServerAdmin, recoveryCodes, u.ID) + `UPDATE users SET password_hash = $1, role = $2, totp_secret = $3, totp_enabled = $4, is_server_admin = $5, totp_recovery_codes = $6, auth_provider = $7 + WHERE id = $8`, + u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled, u.IsServerAdmin, recoveryCodes, u.AuthProvider, u.ID) return err } diff --git a/betterdesk-server/db/sqlite.go b/betterdesk-server/db/sqlite.go index d8c02be1..9c66c01e 100644 --- a/betterdesk-server/db/sqlite.go +++ b/betterdesk-server/db/sqlite.go @@ -92,6 +92,7 @@ func (s *SQLiteDB) Migrate() error { username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', + auth_provider TEXT NOT NULL DEFAULT 'local', totp_secret TEXT DEFAULT '', totp_enabled INTEGER DEFAULT 0, totp_recovery_codes TEXT DEFAULT NULL, @@ -390,6 +391,7 @@ func (s *SQLiteDB) Migrate() error { {"peers", "display_name", `ALTER TABLE peers ADD COLUMN display_name TEXT DEFAULT ''`}, // users: is_server_admin flag (RBAC Phase 52) {"users", "is_server_admin", `ALTER TABLE users ADD COLUMN is_server_admin INTEGER DEFAULT 0`}, + {"users", "auth_provider", `ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'`}, // org_users: server_user_id for linking existing users (Issue #106) {"org_users", "server_user_id", `ALTER TABLE org_users ADD COLUMN server_user_id INTEGER DEFAULT 0`}, } @@ -1047,9 +1049,12 @@ func formatTime(t time.Time) string { func (s *SQLiteDB) CreateUser(u *User) error { s.mu.Lock() defer s.mu.Unlock() - res, err := s.db.Exec(`INSERT INTO users (username, password_hash, role, totp_secret, totp_enabled) - VALUES (?, ?, ?, ?, ?)`, - u.Username, u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled) + if u.AuthProvider == "" { + u.AuthProvider = AuthProviderLocal + } + res, err := s.db.Exec(`INSERT INTO users (username, password_hash, role, auth_provider, totp_secret, totp_enabled) + VALUES (?, ?, ?, ?, ?, ?)`, + u.Username, u.PasswordHash, u.Role, u.AuthProvider, u.TOTPSecret, u.TOTPEnabled) if err != nil { return fmt.Errorf("db: CreateUser: %w", err) } @@ -1063,9 +1068,9 @@ func (s *SQLiteDB) GetUser(username string) (*User, error) { defer s.mu.RUnlock() u := &User{} err := s.db.QueryRow(`SELECT id, username, password_hash, role, COALESCE(is_server_admin, 0), - totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users WHERE username = ?`, username).Scan( + COALESCE(auth_provider, 'local'), totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users WHERE username = ?`, username).Scan( &u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin, - &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin) + &u.AuthProvider, &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin) if err == sql.ErrNoRows { return nil, nil } @@ -1078,9 +1083,9 @@ func (s *SQLiteDB) GetUserByID(id int64) (*User, error) { defer s.mu.RUnlock() u := &User{} err := s.db.QueryRow(`SELECT id, username, password_hash, role, COALESCE(is_server_admin, 0), - totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users WHERE id = ?`, id).Scan( + COALESCE(auth_provider, 'local'), totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users WHERE id = ?`, id).Scan( &u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin, - &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin) + &u.AuthProvider, &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin) if err == sql.ErrNoRows { return nil, nil } @@ -1092,7 +1097,7 @@ func (s *SQLiteDB) ListUsers() ([]*User, error) { s.mu.RLock() defer s.mu.RUnlock() rows, err := s.db.Query(`SELECT id, username, password_hash, role, COALESCE(is_server_admin, 0), - totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users ORDER BY id`) + COALESCE(auth_provider, 'local'), totp_secret, totp_enabled, COALESCE(totp_recovery_codes, ''), created_at, last_login FROM users ORDER BY id`) if err != nil { return nil, fmt.Errorf("db: ListUsers: %w", err) } @@ -1101,7 +1106,7 @@ func (s *SQLiteDB) ListUsers() ([]*User, error) { for rows.Next() { u := &User{} if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin, - &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin); err != nil { + &u.AuthProvider, &u.TOTPSecret, &u.TOTPEnabled, &u.TOTPRecoveryCodes, &u.CreatedAt, &u.LastLogin); err != nil { return nil, err } users = append(users, u) @@ -1113,9 +1118,12 @@ func (s *SQLiteDB) ListUsers() ([]*User, error) { func (s *SQLiteDB) UpdateUser(u *User) error { s.mu.Lock() defer s.mu.Unlock() - _, err := s.db.Exec(`UPDATE users SET password_hash=?, role=?, is_server_admin=?, + if u.AuthProvider == "" { + u.AuthProvider = AuthProviderLocal + } + _, err := s.db.Exec(`UPDATE users SET password_hash=?, role=?, is_server_admin=?, auth_provider=?, totp_secret=?, totp_enabled=?, totp_recovery_codes=? WHERE id=?`, - u.PasswordHash, u.Role, u.IsServerAdmin, u.TOTPSecret, u.TOTPEnabled, u.TOTPRecoveryCodes, u.ID) + u.PasswordHash, u.Role, u.IsServerAdmin, u.AuthProvider, u.TOTPSecret, u.TOTPEnabled, u.TOTPRecoveryCodes, u.ID) return err } diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 742a5713..46dbaac8 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -743,6 +743,12 @@ "org_membership_section": "Organization Membership", "org_membership_hint": "A user can belong to multiple organizations. The organization role only controls permissions inside that organization and is independent of the server role above.", "column_organizations": "Organizations", + "provider": "Provider", + "provider_local": "Local", + "provider_ldap": "LDAP/AD", + "provider_oidc": "SSO", + "password_managed_by_provider": "Managed by identity provider", + "provider_managed_hint": "This account is managed by an external identity provider (LDAP/AD or SSO). The password and role are controlled by the provider and cannot be changed here.", "no_orgs_short": "—", "org_count_one": "{count} organization", "org_count_many": "{count} organizations", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index b8e4c91a..b3af7162 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -744,6 +744,12 @@ "org_membership_section": "Członkostwo w organizacjach", "org_membership_hint": "Użytkownik może należeć do wielu organizacji. Rola w organizacji obowiązuje tylko wewnątrz tej organizacji i jest niezależna od roli serwerowej powyżej.", "column_organizations": "Organizacje", + "provider": "Dostawca", + "provider_local": "Lokalny", + "provider_ldap": "LDAP/AD", + "provider_oidc": "SSO", + "password_managed_by_provider": "Zarządzane przez dostawcę tożsamości", + "provider_managed_hint": "To konto jest zarządzane przez zewnętrznego dostawcę tożsamości (LDAP/AD lub SSO). Hasło i rola są kontrolowane przez dostawcę i nie można ich tu zmienić.", "no_orgs_short": "—", "org_count_one": "{count} organizacja", "org_count_many": "{count} organizacji", diff --git a/web-nodejs/public/css/users.css b/web-nodejs/public/css/users.css index 40619eda..509c2a0c 100644 --- a/web-nodejs/public/css/users.css +++ b/web-nodejs/public/css/users.css @@ -279,6 +279,33 @@ color: var(--text-secondary, #8b949e); } +/* Authentication provider badge (Issue #148) */ +.provider-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: var(--radius-full); + font-size: var(--font-size-xs, 12px); + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.provider-badge.provider-local { + background: var(--bg-tertiary, #21262d); + color: var(--text-secondary, #8b949e); +} + +.provider-badge.provider-ldap { + background: rgba(14, 165, 233, 0.15); + color: #0ea5e9; +} + +.provider-badge.provider-oidc { + background: rgba(168, 85, 247, 0.15); + color: #a855f7; +} + /* Inline org badges in the users table column */ .user-orgs-cell { display: flex; diff --git a/web-nodejs/public/js/users.js b/web-nodejs/public/js/users.js index 5f115920..1fd592dc 100644 --- a/web-nodejs/public/js/users.js +++ b/web-nodejs/public/js/users.js @@ -320,6 +320,9 @@ }; const roleIcon = roleIcons[user.role] || 'person'; const roleLabelKey = 'users.role_' + user.role; + const provider = (user.auth_provider || 'local').toLowerCase(); + const providerLabel = _('users.provider_' + provider) || provider; + const isLocal = provider === 'local'; return `