From 0b520971d2da871ab53185d4ccd339b93140f620 Mon Sep 17 00:00:00 2001 From: SimonCHEN Date: Sun, 30 Aug 2026 02:04:20 +0800 Subject: [PATCH 01/16] Implement custom SSO (OIDC) login with full frontend and backend support - Add SSOProvider with OIDC discovery, PKCE, nonce/at_hash validation - Add encrypted client secret storage via Encryptor - Add auth_sso_enabled and sso_auto_provision_enabled settings - Add SSO config fields (issuer, scopes, button_label, disable_pkce, require_verified_email) - Add SSO button to login page with custom label support from public settings - Add SSO configuration UI to SystemAuthenticationPage with all OIDC fields - Add SSO error keys (sso_email_missing, sso_email_unverified, sso_account_not_provisioned) - Add i18n translations for SSO across all 9 locales - Add oauthConfigError utility for localized OAuth config error messages --- api/go.mod | 3 + api/go.sum | 6 + api/internal/crypto/crypto.go | 58 +++ api/internal/handler/auth.go | 13 + api/internal/handler/system_setting.go | 3 +- api/internal/model/errors.go | 2 + api/internal/model/oauth.go | 2 + api/internal/model/system_setting.go | 140 ++++++++ api/internal/service/auth.go | 88 +++-- api/internal/service/oauth.go | 45 +++ api/internal/service/oauth_sso.go | 398 +++++++++++++++++++++ api/internal/service/system_setting.go | 24 ++ web/package-lock.json | 60 ++-- web/src/api/systemSettings.ts | 6 + web/src/i18n/ar.json | 26 ++ web/src/i18n/de.json | 26 ++ web/src/i18n/en.json | 28 ++ web/src/i18n/es.json | 26 ++ web/src/i18n/fr.json | 26 ++ web/src/i18n/i18n.test.ts | 2 + web/src/i18n/ja.json | 26 ++ web/src/i18n/ko.json | 26 ++ web/src/i18n/pt.json | 26 ++ web/src/i18n/zh.json | 26 ++ web/src/pages/LoginPage.tsx | 39 +- web/src/pages/SystemAuthenticationPage.tsx | 147 +++++++- web/src/utils/oauthConfigError.ts | 42 +++ 27 files changed, 1232 insertions(+), 82 deletions(-) create mode 100644 api/internal/service/oauth_sso.go create mode 100644 web/src/utils/oauthConfigError.ts diff --git a/api/go.mod b/api/go.mod index 3d0b4c07..a8b646d1 100644 --- a/api/go.mod +++ b/api/go.mod @@ -3,6 +3,7 @@ module github.com/marcoshack/taskwondo go 1.25.5 require ( + github.com/coreos/go-oidc/v3 v3.20.0 github.com/go-chi/chi/v5 v5.2.5 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 @@ -16,6 +17,7 @@ require ( github.com/rs/zerolog v1.34.0 golang.org/x/crypto v0.48.0 golang.org/x/image v0.36.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/time v0.14.0 ) @@ -25,6 +27,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect diff --git a/api/go.sum b/api/go.sum index 7380b12e..872a257f 100644 --- a/api/go.sum +++ b/api/go.sum @@ -12,6 +12,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -34,6 +36,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -147,6 +151,8 @@ golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/api/internal/crypto/crypto.go b/api/internal/crypto/crypto.go index 03e9d86d..f4366686 100644 --- a/api/internal/crypto/crypto.go +++ b/api/internal/crypto/crypto.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" "io" @@ -87,3 +88,60 @@ func (e *Encryptor) Decrypt(encoded string) (string, error) { return string(plaintext), nil } + +// SealJSON marshals v, encrypts it with AES-256-GCM and returns it as an +// unpadded URL-safe base64 token, suitable for a query parameter such as an +// OAuth state. Confidentiality comes from the GCM tag as well as the cipher: +// a token that was not produced by this key cannot be forged. +func (e *Encryptor) SealJSON(v any) (string, error) { + plaintext, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("marshaling payload: %w", err) + } + + block, err := aes.NewCipher(e.key) + if err != nil { + return "", fmt.Errorf("creating cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("creating gcm: %w", err) + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generating nonce: %w", err) + } + + sealed := gcm.Seal(nonce, nonce, plaintext, nil) + return base64.RawURLEncoding.EncodeToString(sealed), nil +} + +// OpenJSON decrypts a token produced by SealJSON into v. It fails if the token +// was sealed with a different key, tampered with, or malformed. +func (e *Encryptor) OpenJSON(token string, v any) error { + data, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return fmt.Errorf("decoding token: %w", err) + } + block, err := aes.NewCipher(e.key) + if err != nil { + return fmt.Errorf("creating cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return fmt.Errorf("creating gcm: %w", err) + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return fmt.Errorf("token too short") + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return fmt.Errorf("decrypting token: %w", err) + } + if err := json.Unmarshal(plaintext, v); err != nil { + return fmt.Errorf("unmarshaling payload: %w", err) + } + return nil +} diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go index 7f518ecb..34fbebda 100644 --- a/api/internal/handler/auth.go +++ b/api/internal/handler/auth.go @@ -268,6 +268,19 @@ func (h *AuthHandler) OAuthCallback(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, CodeForbidden, "account is disabled") return } + // Service errors carrying an error_key are user-facing (localised by + // the client); anything else stays a generic message so provider + // internals don't leak into the login page. + if key, _ := model.ErrorKey(err); key != "" { + status := http.StatusUnauthorized + code := CodeOAuthError + if errors.Is(err, model.ErrForbidden) { + status = http.StatusForbidden + code = CodeForbidden + } + writeErrorFromService(w, status, code, err) + return + } log.Ctx(r.Context()).Error().Err(err).Msg(provider + " oauth callback failed") writeError(w, http.StatusUnauthorized, CodeOAuthError, provider+" authentication failed") return diff --git a/api/internal/handler/system_setting.go b/api/internal/handler/system_setting.go index 01ff2f08..bc930ba8 100644 --- a/api/internal/handler/system_setting.go +++ b/api/internal/handler/system_setting.go @@ -270,6 +270,7 @@ var validOAuthProviders = map[string]bool{ model.OAuthProviderGoogle: true, model.OAuthProviderGitHub: true, model.OAuthProviderMicrosoft: true, + model.OAuthProviderSSO: true, } // GetOAuthConfig handles GET /api/v1/admin/settings/oauth_config/{provider} @@ -332,7 +333,7 @@ func (h *SystemSettingHandler) SetOAuthConfig(w http.ResponseWriter, r *http.Req return } - if err := cfg.Validate(); err != nil { + if err := cfg.ValidateAs(provider); err != nil { handleSystemSettingError(w, r, err, "oauth config validation failed") return } diff --git a/api/internal/model/errors.go b/api/internal/model/errors.go index ea754154..c35fd202 100644 --- a/api/internal/model/errors.go +++ b/api/internal/model/errors.go @@ -16,6 +16,8 @@ var ( ErrValidation = errors.New("validation error") ErrInvalidTransition = errors.New("invalid transition") ErrOAuthAccountLinked = errors.New("oauth account already linked to another user") + ErrOAuthEmailMissing = errors.New("identity provider did not return an email address") + ErrOAuthEmailUnverified = errors.New("email address is not verified") ErrStatusIncompatible = errors.New("status incompatible with target workflow") ErrEmbeddingUnavailable = errors.New("embedding service unavailable") ErrFeatureDisabled = errors.New("feature is disabled") diff --git a/api/internal/model/oauth.go b/api/internal/model/oauth.go index a6be9f0a..c0b8b7f6 100644 --- a/api/internal/model/oauth.go +++ b/api/internal/model/oauth.go @@ -12,6 +12,8 @@ const ( OAuthProviderGoogle = "google" OAuthProviderGitHub = "github" OAuthProviderMicrosoft = "microsoft" + // OAuthProviderSSO is the generic OIDC provider configured by admins. + OAuthProviderSSO = "sso" ) // OAuthAccount represents a linked external identity. diff --git a/api/internal/model/system_setting.go b/api/internal/model/system_setting.go index 6387bc86..b0dc52e3 100644 --- a/api/internal/model/system_setting.go +++ b/api/internal/model/system_setting.go @@ -3,6 +3,8 @@ package model import ( "encoding/json" "fmt" + "net/url" + "strings" "time" ) @@ -24,6 +26,12 @@ const ( SettingAuthGoogleEnabled = "auth_google_enabled" SettingAuthGitHubEnabled = "auth_github_enabled" SettingAuthMicrosoftEnabled = "auth_microsoft_enabled" + SettingAuthSSOEnabled = "auth_sso_enabled" + + // SettingSSOAutoProvision gates account creation for SSO logins whose email + // does not match an existing user. When false (the default), SSO can only + // sign in users that already exist. + SettingSSOAutoProvision = "sso_auto_provision_enabled" // OAuth provider ordering (JSON array of provider names, e.g. ["discord","google","github"]) SettingOAuthProviderOrder = "oauth_provider_order" @@ -33,6 +41,7 @@ const ( SettingOAuthGoogleConfig = "oauth_google_config" SettingOAuthGitHubConfig = "oauth_github_config" SettingOAuthMicrosoftConfig = "oauth_microsoft_config" + SettingOAuthSSOConfig = "oauth_sso_config" // Deny lists (JSON arrays of strings) SettingReservedNamespaceSlugs = "reserved_namespace_slugs" @@ -98,9 +107,43 @@ func (c *SMTPConfig) Validate() error { // OAuthProviderConfig holds OAuth provider credentials stored as a system setting. // The enabled/disabled state is stored separately in auth_*_enabled settings. // The redirect URI is derived automatically from BaseURL + "/auth/{provider}/callback". +// +// The Issuer/Scopes/ButtonLabel/DisablePKCE/RequireVerifiedEmail fields are only +// used by the generic SSO provider (OAuthProviderSSO). They are omitted from the +// stored JSON for the built-in providers, whose behaviour is unchanged. type OAuthProviderConfig struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` + + // Issuer is the OIDC issuer URL used for discovery (SSO only). + Issuer string `json:"issuer,omitempty"` + // Scopes overrides the requested scopes; empty means DefaultSSOScopes. + Scopes []string `json:"scopes,omitempty"` + // ButtonLabel overrides the login button text (SSO only). + ButtonLabel string `json:"button_label,omitempty"` + // DisablePKCE turns off the S256 code challenge for IdPs that reject it. + DisablePKCE bool `json:"disable_pkce,omitempty"` + // RequireVerifiedEmail gates logins on the email_verified claim. nil = required. + RequireVerifiedEmail *bool `json:"require_verified_email,omitempty"` +} + +// DefaultSSOScopes are requested when Scopes is empty. +var DefaultSSOScopes = []string{"openid", "profile", "email"} + +// MaxSSOButtonLabel is the length cap for the login button override. +const MaxSSOButtonLabel = 40 + +// RequiresVerifiedEmail reports whether the email_verified claim must be true. +func (c *OAuthProviderConfig) RequiresVerifiedEmail() bool { + return c.RequireVerifiedEmail == nil || *c.RequireVerifiedEmail +} + +// ScopeList returns the configured scopes or the defaults. +func (c *OAuthProviderConfig) ScopeList() []string { + if len(c.Scopes) == 0 { + return DefaultSSOScopes + } + return c.Scopes } // Validate checks that all required fields are present. @@ -114,6 +157,80 @@ func (c *OAuthProviderConfig) Validate() error { return nil } +// ValidateAs validates the config in the context of a specific provider, +// enforcing the extra fields the generic SSO provider needs. +func (c *OAuthProviderConfig) ValidateAs(provider string) error { + if err := c.Validate(); err != nil { + return err + } + if provider != OAuthProviderSSO { + return nil + } + + issuer, err := NormalizeOIDCIssuer(c.Issuer) + if err != nil { + return err + } + c.Issuer = issuer + + label := strings.TrimSpace(c.ButtonLabel) + if len([]rune(label)) > MaxSSOButtonLabel { + return fmt.Errorf("%w: button_label must be %d characters or fewer", ErrValidation, MaxSSOButtonLabel) + } + c.ButtonLabel = label + + for _, s := range c.Scopes { + if s == "" || strings.ContainsAny(s, " \t") { + return fmt.Errorf("%w: scopes must be individual non-empty strings", ErrValidation) + } + } + if len(c.Scopes) > 0 && !containsString(c.Scopes, "openid") { + return fmt.Errorf("%w: scopes must include openid", ErrValidation) + } + return nil +} + +// NormalizeOIDCIssuer validates and canonicalises an OIDC issuer URL. +// Issuers are compared exactly by the discovery and verification code, so +// trailing slashes are stripped and only https (or http for local dev) is kept. +func NormalizeOIDCIssuer(raw string) (string, error) { + issuer := strings.TrimSpace(raw) + if issuer == "" { + return "", fmt.Errorf("%w: issuer is required", ErrValidation) + } + u, err := url.Parse(issuer) + if err != nil || u.Host == "" { + return "", fmt.Errorf("%w: issuer must be an absolute URL", ErrValidation) + } + if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || strings.HasPrefix(u.Host, "127."))) { + return "", fmt.Errorf("%w: issuer must use https", ErrValidation) + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("%w: issuer must not contain credentials, query or fragment", ErrValidation) + } + return strings.TrimRight(u.String(), "/"), nil +} + +func containsString(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +// KnownOAuthProviders lists every provider whose credentials live in an +// oauth__config setting and whose switch is an auth__enabled +// setting. Login-page ordering and enablement are driven off this list. +var KnownOAuthProviders = []string{ + OAuthProviderDiscord, + OAuthProviderGoogle, + OAuthProviderGitHub, + OAuthProviderMicrosoft, + OAuthProviderSSO, +} + // OAuthConfigSettingKey returns the system setting key for a given provider name. func OAuthConfigSettingKey(provider string) string { switch provider { @@ -125,6 +242,27 @@ func OAuthConfigSettingKey(provider string) string { return SettingOAuthGitHubConfig case OAuthProviderMicrosoft: return SettingOAuthMicrosoftConfig + case OAuthProviderSSO: + return SettingOAuthSSOConfig + default: + return "" + } +} + +// OAuthEnabledSettingKey returns the auth__enabled setting key for a +// provider name, or empty string for unknown providers. +func OAuthEnabledSettingKey(provider string) string { + switch provider { + case OAuthProviderDiscord: + return SettingAuthDiscordEnabled + case OAuthProviderGoogle: + return SettingAuthGoogleEnabled + case OAuthProviderGitHub: + return SettingAuthGitHubEnabled + case OAuthProviderMicrosoft: + return SettingAuthMicrosoftEnabled + case OAuthProviderSSO: + return SettingAuthSSOEnabled default: return "" } @@ -142,6 +280,8 @@ func OAuthEnabledToConfigKey(enabledKey string) string { return SettingOAuthGitHubConfig case SettingAuthMicrosoftEnabled: return SettingOAuthMicrosoftConfig + case SettingAuthSSOEnabled: + return SettingOAuthSSOConfig default: return "" } diff --git a/api/internal/service/auth.go b/api/internal/service/auth.go index d594bb9a..8a5180b7 100644 --- a/api/internal/service/auth.go +++ b/api/internal/service/auth.go @@ -116,6 +116,7 @@ type AuthService struct { emailSender EmailSender encryptor *crypto.Encryptor storage storage.Storage + ssoCache *SSODiscoveryCache baseURL string jwtSecret []byte jwtExpiry time.Duration @@ -142,6 +143,7 @@ func NewAuthService( jwtSecret: []byte(jwtSecret), jwtExpiry: jwtExpiry, providers: pm, + ssoCache: NewSSODiscoveryCache(), } } @@ -189,6 +191,7 @@ func (s *AuthService) getProvider(ctx context.Context, name string) OAuthProvide if err != nil { log.Ctx(ctx).Error().Err(err).Str("provider", name).Msg("failed to decrypt oauth client secret, falling back to static provider") } else { + cfg.ClientSecret = secret redirectURI := s.baseURL + "/auth/" + name + "/callback" switch name { case model.OAuthProviderDiscord: @@ -199,6 +202,8 @@ func (s *AuthService) getProvider(ctx context.Context, name string) OAuthProvide return NewGitHubProvider(cfg.ClientID, secret, redirectURI, nil) case model.OAuthProviderMicrosoft: return NewMicrosoftProvider(cfg.ClientID, secret, redirectURI, nil) + case model.OAuthProviderSSO: + return NewSSOProvider(cfg, redirectURI, s.encryptor, s.ssoCache, nil) } } } @@ -593,27 +598,17 @@ func (s *AuthService) SeedAdminUser(ctx context.Context, email, password string) // When a setting doesn't exist: OAuth defaults to enabled (backward compat), // email login defaults to enabled, email registration defaults to disabled. func (s *AuthService) EnabledProviders(ctx context.Context) map[string]bool { - result := make(map[string]bool, 4) + result := make(map[string]bool, len(model.KnownOAuthProviders)+2) // Check each known OAuth provider — configured via DB or static env vars - for _, name := range []string{model.OAuthProviderDiscord, model.OAuthProviderGoogle, model.OAuthProviderGitHub, model.OAuthProviderMicrosoft} { - if s.isOAuthConfigured(ctx, name) { - settingKey := "" - switch name { - case model.OAuthProviderDiscord: - settingKey = model.SettingAuthDiscordEnabled - case model.OAuthProviderGoogle: - settingKey = model.SettingAuthGoogleEnabled - case model.OAuthProviderGitHub: - settingKey = model.SettingAuthGitHubEnabled - case model.OAuthProviderMicrosoft: - settingKey = model.SettingAuthMicrosoftEnabled - } - if settingKey != "" { - result[name] = s.getBoolSetting(ctx, settingKey, true) - } else { - result[name] = true - } + for _, name := range model.KnownOAuthProviders { + if !s.isOAuthConfigured(ctx, name) { + continue + } + if settingKey := model.OAuthEnabledSettingKey(name); settingKey != "" { + result[name] = s.getBoolSetting(ctx, settingKey, true) + } else { + result[name] = true } } @@ -636,6 +631,10 @@ func (s *AuthService) isOAuthConfigured(ctx context.Context, name string) bool { if err == nil { var cfg model.OAuthProviderConfig if err := json.Unmarshal(setting.Value, &cfg); err == nil && cfg.ClientID != "" { + // The SSO provider cannot work without an issuer to discover. + if name == model.OAuthProviderSSO { + return cfg.Issuer != "" && s.encryptor != nil + } return true } } @@ -989,14 +988,36 @@ func (s *AuthService) OAuthURL(ctx context.Context, providerName string) (string return "", fmt.Errorf("oauth provider %q is not configured", providerName) } - state, err := s.generateOAuthState() + state, err := s.oauthState(ctx, provider) if err != nil { - return "", fmt.Errorf("generating state: %w", err) + return "", err } + // OIDC providers must reach the network for discovery to build the URL, and + // they need the per-login secrets that live inside the sealed state. + if contextual, ok := provider.(ContextualAuthURL); ok { + return contextual.AuthURLContext(ctx, state) + } return provider.AuthURL(state), nil } +// oauthState produces the anti-CSRF state parameter, delegating to the provider +// when it carries its own per-login secrets. +func (s *AuthService) oauthState(ctx context.Context, provider OAuthProvider) (string, error) { + if binder, ok := provider.(StateBinder); ok { + state, err := binder.NewState(ctx) + if err != nil { + return "", fmt.Errorf("generating %s state: %w", provider.Name(), err) + } + return state, nil + } + state, err := s.generateOAuthState() + if err != nil { + return "", fmt.Errorf("generating state: %w", err) + } + return state, nil +} + // OAuthCallback validates state, exchanges the code via the provider, and finds or creates a user. func (s *AuthService) OAuthCallback(ctx context.Context, providerName, code, state string) (string, *model.User, error) { provider := s.getProvider(ctx, providerName) @@ -1004,12 +1025,17 @@ func (s *AuthService) OAuthCallback(ctx context.Context, providerName, code, sta return "", nil, fmt.Errorf("oauth provider %q is not configured", providerName) } - if err := s.validateOAuthState(state); err != nil { + ctx, err := s.validateProviderState(ctx, provider, state) + if err != nil { return "", nil, fmt.Errorf("invalid state: %w", err) } userInfo, err := provider.ExchangeCode(ctx, code) if err != nil { + var ssoErr *SSOError + if errors.As(err, &ssoErr) { + return "", nil, model.NewKeyedError(ssoErr.Sentinel, ssoErr.Key, ssoErr.Message, nil) + } return "", nil, fmt.Errorf("exchanging code: %w", err) } @@ -1030,6 +1056,15 @@ func (s *AuthService) OAuthCallback(ctx context.Context, providerName, code, sta return token, user, nil } +// validateProviderState checks the state parameter and returns the context +// carrying any per-login secrets the provider unsealed from it. +func (s *AuthService) validateProviderState(ctx context.Context, provider OAuthProvider, state string) (context.Context, error) { + if binder, ok := provider.(StateBinder); ok { + return binder.ValidateState(ctx, state) + } + return ctx, s.validateOAuthState(state) +} + func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider string, info model.OAuthUserInfo) (*model.User, error) { // Case 1: OAuth account already linked — log in existing user. existing, err := s.oauthAccounts.GetByProviderUser(ctx, provider, info.ProviderUserID) @@ -1065,6 +1100,15 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider string // Case 3: Create new user. if user == nil { + // Automatic provisioning is off by default for the generic SSO + // provider: an administrator's identity provider must not be able to + // mint accounts here just by adding someone to a directory. + if provider == model.OAuthProviderSSO && + !s.getBoolSetting(ctx, model.SettingSSOAutoProvision, false) { + return nil, model.NewKeyedError(model.ErrForbidden, "sso_account_not_provisioned", + "no account exists for this single sign-on identity; an administrator must create it or enable automatic provisioning", nil) + } + email := info.Email if email == "" { email = provider + "_" + info.ProviderUserID + "@oauth.taskwondo.local" diff --git a/api/internal/service/oauth.go b/api/internal/service/oauth.go index 83122adb..032a4372 100644 --- a/api/internal/service/oauth.go +++ b/api/internal/service/oauth.go @@ -15,3 +15,48 @@ type OAuthProvider interface { // ExchangeCode exchanges an authorization code for user info. ExchangeCode(ctx context.Context, code string) (model.OAuthUserInfo, error) } + +// ContextualAuthURL is implemented by providers that must reach the network to +// build an authorization URL (OIDC discovery) and therefore need the request +// context rather than only the state string. +type ContextualAuthURL interface { + AuthURLContext(ctx context.Context, state string) (string, error) +} + +// StateBinder is implemented by providers that carry per-login secrets (an OIDC +// nonce and a PKCE verifier) through the authorization redirect. +// +// A provider that implements it takes over state generation and validation: +// AuthService calls NewState instead of its own HMAC state and ValidateState +// instead of validateOAuthState. ValidateState returns a context carrying the +// unsealed secrets, which the following ExchangeCode call reads back. Binding +// the state to the secrets this way is what makes the state parameter CSRF +// protection real for OIDC — an attacker who replays someone else's callback +// fails the nonce check rather than logging in as them. +type StateBinder interface { + NewState(ctx context.Context) (string, error) + ValidateState(ctx context.Context, state string) (context.Context, error) +} + +// SSOLabeledProvider is implemented by providers whose login button text is +// operator-configured rather than derived from a fixed i18n key. +type SSOLabeledProvider interface { + ButtonLabel() string +} + +type ssoFlowKey struct{} + +// ssoFlow carries the values unsealed from one login's state parameter. +type ssoFlow struct { + nonce string + verifier string +} + +func ssoFlowContext(ctx context.Context, flow ssoFlow) context.Context { + return context.WithValue(ctx, ssoFlowKey{}, flow) +} + +func ssoFlowFromContext(ctx context.Context) (ssoFlow, bool) { + f, ok := ctx.Value(ssoFlowKey{}).(ssoFlow) + return f, ok && f.nonce != "" +} diff --git a/api/internal/service/oauth_sso.go b/api/internal/service/oauth_sso.go new file mode 100644 index 00000000..7e7455d7 --- /dev/null +++ b/api/internal/service/oauth_sso.go @@ -0,0 +1,398 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/marcoshack/taskwondo/internal/crypto" + "github.com/marcoshack/taskwondo/internal/model" +) + +// ssoStateTTL bounds how long a login may sit at the identity provider. +const ssoStateTTL = 10 * time.Minute + +// ssoDiscoveryTTL is how long a discovery document is reused before refetching. +const ssoDiscoveryTTL = time.Hour + +// ssoDiscoveryMaxEntries caps the discovery cache so a changed issuer cannot +// grow it without bound. +const ssoDiscoveryMaxEntries = 8 + +// SSODiscoveryCache memoises OIDC discovery documents. The oidc.Provider value +// also lazily caches its JWKS key set, so reusing one across logins avoids a +// metadata and key fetch on every sign-in. Safe for concurrent use. +type SSODiscoveryCache struct { + mu sync.Mutex + entries map[string]*ssoDiscoveryEntry + now func() time.Time +} + +type ssoDiscoveryEntry struct { + provider *oidc.Provider + expires time.Time +} + +// NewSSODiscoveryCache creates an empty discovery cache. +func NewSSODiscoveryCache() *SSODiscoveryCache { + return &SSODiscoveryCache{ + entries: make(map[string]*ssoDiscoveryEntry), + now: time.Now, + } +} + +// get returns the cached provider for issuer, discovering it when absent or stale. +func (c *SSODiscoveryCache) get(ctx context.Context, issuer string, client *http.Client) (*oidc.Provider, error) { + c.mu.Lock() + if e, ok := c.entries[issuer]; ok && c.now().Before(e.expires) { + provider := e.provider + c.mu.Unlock() + return provider, nil + } + c.mu.Unlock() + + dctx := ctx + if client != nil { + dctx = oidc.ClientContext(ctx, client) + } + provider, err := oidc.NewProvider(dctx, issuer) + if err != nil { + return nil, fmt.Errorf("oidc discovery: %w", err) + } + + c.mu.Lock() + if len(c.entries) >= ssoDiscoveryMaxEntries { + c.entries = make(map[string]*ssoDiscoveryEntry) + } + c.entries[issuer] = &ssoDiscoveryEntry{provider: provider, expires: c.now().Add(ssoDiscoveryTTL)} + c.mu.Unlock() + + return provider, nil +} + +// invalidate drops the cached document for an issuer so the next attempt +// refetches it. Used when ID-token verification fails, which is what a rotated +// JWKS looks like before the cached keys go stale. +func (c *SSODiscoveryCache) invalidate(issuer string) { + c.mu.Lock() + delete(c.entries, issuer) + c.mu.Unlock() +} + +// SSOProvider implements OAuthProvider for a custom OpenID Connect identity +// provider configured by an administrator. Unlike the built-in providers it +// discovers its endpoints from the issuer URL, validates the ID token +// signature, and carries per-login secrets (nonce, PKCE verifier) inside the +// sealed state parameter. +// +// Account identity is resolved by email address in +// AuthService.findOrCreateOAuthUser; new accounts are gated by the +// sso_auto_provision_enabled setting. +type SSOProvider struct { + cfg model.OAuthProviderConfig + redirect string + httpClient *http.Client + sealer *crypto.Encryptor + cache *SSODiscoveryCache + now func() time.Time +} + +// NewSSOProvider creates a generic OIDC provider. sealer must not be nil: it +// seals the state parameter, and without it a login cannot be bound to the +// browser that started it. +func NewSSOProvider(cfg model.OAuthProviderConfig, redirectURI string, sealer *crypto.Encryptor, cache *SSODiscoveryCache, httpClient *http.Client) *SSOProvider { + if httpClient == nil { + httpClient = &http.Client{Timeout: 15 * time.Second} + } + if cache == nil { + cache = NewSSODiscoveryCache() + } + return &SSOProvider{ + cfg: cfg, + redirect: redirectURI, + httpClient: httpClient, + sealer: sealer, + cache: cache, + now: time.Now, + } +} + +func (p *SSOProvider) Name() string { return model.OAuthProviderSSO } + +// ButtonLabel returns the operator-supplied login button text, if configured. +func (p *SSOProvider) ButtonLabel() string { return p.cfg.ButtonLabel } + +// ssoState is the payload sealed into the OIDC state parameter. +type ssoState struct { + Provider string `json:"p"` + Nonce string `json:"n"` + Verifier string `json:"v,omitempty"` + Expires int64 `json:"e"` +} + +// NewState seals a fresh nonce and PKCE verifier into the state parameter. +func (p *SSOProvider) NewState(_ context.Context) (string, error) { + if p.sealer == nil { + return "", fmt.Errorf("sso provider: state sealer is not configured") + } + nonce, err := ssoRandomToken() + if err != nil { + return "", fmt.Errorf("generating sso nonce: %w", err) + } + state := ssoState{ + Provider: model.OAuthProviderSSO, + Nonce: nonce, + Expires: p.now().Add(ssoStateTTL).Unix(), + } + if !p.cfg.DisablePKCE { + state.Verifier = oauth2.GenerateVerifier() + } + sealed, err := p.sealer.SealJSON(state) + if err != nil { + return "", fmt.Errorf("sealing sso state: %w", err) + } + return sealed, nil +} + +// ValidateState unseals and checks the state parameter, returning a context +// carrying the nonce and verifier for the following ExchangeCode call. +func (p *SSOProvider) ValidateState(ctx context.Context, state string) (context.Context, error) { + if p.sealer == nil { + return ctx, fmt.Errorf("sso provider: state sealer is not configured") + } + var s ssoState + if err := p.sealer.OpenJSON(state, &s); err != nil { + return ctx, fmt.Errorf("decoding state: %w", err) + } + if s.Nonce == "" || s.Provider != model.OAuthProviderSSO { + return ctx, fmt.Errorf("malformed state") + } + if p.now().Unix() > s.Expires { + return ctx, fmt.Errorf("state expired") + } + return ssoFlowContext(ctx, ssoFlow{nonce: s.Nonce, verifier: s.Verifier}), nil +} + +// AuthURL is unused for SSO: building the URL requires provider discovery, so +// AuthService calls AuthURLContext through the ContextualAuthURL interface. +func (p *SSOProvider) AuthURL(state string) string { + url, err := p.AuthURLContext(context.Background(), state) + if err != nil { + return "" + } + return url +} + +// AuthURLContext discovers the provider and builds the authorization request, +// attaching the nonce and PKCE challenge recovered from the sealed state. +func (p *SSOProvider) AuthURLContext(ctx context.Context, state string) (string, error) { + flow, ok := ssoFlowFromContext(ctx) + if !ok { + if p.sealer == nil { + return "", fmt.Errorf("sso provider: state sealer is not configured") + } + var s ssoState + if err := p.sealer.OpenJSON(state, &s); err != nil { + return "", fmt.Errorf("decoding state: %w", err) + } + if s.Provider != model.OAuthProviderSSO || s.Nonce == "" { + return "", fmt.Errorf("malformed state") + } + flow = ssoFlow{nonce: s.Nonce, verifier: s.Verifier} + ctx = ssoFlowContext(ctx, flow) + } + + provider, err := p.discover(ctx) + if err != nil { + return "", err + } + + opts := []oauth2.AuthCodeOption{oidc.Nonce(flow.nonce)} + if flow.verifier != "" { + opts = append(opts, oauth2.S256ChallengeOption(flow.verifier)) + } + return p.oauthConfig(provider).AuthCodeURL(state, opts...), nil +} + +// ExchangeCode redeems the authorization code, verifies the ID token +// (signature, issuer, audience, expiry, nonce and at_hash) and, when the ID +// token omits the email claim, backfills it from the UserInfo endpoint. +func (p *SSOProvider) ExchangeCode(ctx context.Context, code string) (model.OAuthUserInfo, error) { + flow, ok := ssoFlowFromContext(ctx) + if !ok { + return model.OAuthUserInfo{}, fmt.Errorf("missing sso login context") + } + + provider, err := p.discover(ctx) + if err != nil { + return model.OAuthUserInfo{}, err + } + + ecfg := p.oauthConfig(provider) + exchangeCtx := oidc.ClientContext(ctx, p.httpClient) + + var opts []oauth2.AuthCodeOption + if flow.verifier != "" { + opts = append(opts, oauth2.VerifierOption(flow.verifier)) + } + oauth2Token, err := ecfg.Exchange(exchangeCtx, code, opts...) + if err != nil { + return model.OAuthUserInfo{}, fmt.Errorf("exchanging code: %w", err) + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return model.OAuthUserInfo{}, fmt.Errorf("token response did not contain an id_token") + } + + // Verifier (not VerifierContext) reuses the key set cached on the provider, + // so JWKS is fetched at most once per discovery refresh. + idToken, err := provider.Verifier(&oidc.Config{ClientID: p.cfg.ClientID}).Verify(exchangeCtx, rawIDToken) + if err != nil { + p.cache.invalidate(p.cfg.Issuer) + return model.OAuthUserInfo{}, fmt.Errorf("verifying id_token: %w", err) + } + if idToken.Nonce != flow.nonce { + return model.OAuthUserInfo{}, fmt.Errorf("id_token nonce mismatch") + } + // at_hash binds the ID token to the access token. Optional per spec, but + // when present it must match, otherwise a stolen access token is undetectable. + if idToken.AccessTokenHash != "" { + if err := idToken.VerifyAccessToken(oauth2Token.AccessToken); err != nil { + return model.OAuthUserInfo{}, fmt.Errorf("verifying access token hash: %w", err) + } + } + + var claims ssoClaims + if err := idToken.Claims(&claims); err != nil { + return model.OAuthUserInfo{}, fmt.Errorf("decoding id_token claims: %w", err) + } + + if claims.Email == "" && provider.UserInfoEndpoint() != "" { + ui, err := provider.UserInfo(exchangeCtx, oauth2.StaticTokenSource(oauth2Token)) + if err != nil { + return model.OAuthUserInfo{}, fmt.Errorf("fetching userinfo: %w", err) + } + claims.Email = ui.Email + claims.EmailVerified = ui.EmailVerified + if claims.Name == "" || claims.Picture == "" { + var extra struct { + Name string `json:"name"` + Picture string `json:"picture"` + PreferredUsername string `json:"preferred_username"` + } + if err := ui.Claims(&extra); err == nil { + claims.Name = firstNonEmpty(claims.Name, extra.Name) + claims.Picture = firstNonEmpty(claims.Picture, extra.Picture) + claims.PreferredUsername = firstNonEmpty(claims.PreferredUsername, extra.PreferredUsername) + } + } + } + + return p.userInfo(claims) +} + +// userInfo normalises the ID token claims into the shared OAuth user shape, +// applying the email policy configured for this provider. +func (p *SSOProvider) userInfo(claims ssoClaims) (model.OAuthUserInfo, error) { + email := strings.ToLower(strings.TrimSpace(claims.Email)) + if email == "" { + return model.OAuthUserInfo{}, &SSOError{ + Sentinel: model.ErrOAuthEmailMissing, + Key: "sso_email_missing", + Message: "the identity provider did not return an email address", + } + } + + // email_verified is only meaningful when the claim is present; the config + // switch defaults to requiring it, because an unverified address would let + // anyone at the IdP claim another user's mailbox and inherit their account. + verified := claims.EmailVerified || !p.cfg.RequiresVerifiedEmail() + if !verified { + return model.OAuthUserInfo{}, &SSOError{ + Sentinel: model.ErrOAuthEmailUnverified, + Key: "sso_email_unverified", + Message: "the identity provider reported this email address as unverified", + } + } + + display := firstNonEmpty(claims.Name, claims.Nickname, claims.PreferredUsername, email) + + return model.OAuthUserInfo{ + ProviderUserID: claims.Subject, + Email: email, + EmailVerified: true, + DisplayName: display, + AvatarURL: claims.Picture, + Username: claims.PreferredUsername, + RawAvatar: claims.Picture, + }, nil +} + +// ssoClaims are the OIDC standard claims consumed by the SSO provider. +type ssoClaims struct { + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Nickname string `json:"nickname"` + PreferredUsername string `json:"preferred_username"` + Picture string `json:"picture"` +} + +// SSOError carries a stable error key so the login page can localise why a +// single sign-in was rejected instead of showing a generic failure. +type SSOError struct { + Sentinel error + Key string + Message string +} + +func (e *SSOError) Error() string { return e.Message } +func (e *SSOError) Unwrap() error { return e.Sentinel } + +func (p *SSOProvider) discover(ctx context.Context) (*oidc.Provider, error) { + return p.cache.get(ctx, p.cfg.Issuer, p.httpClient) +} + +func (p *SSOProvider) oauthConfig(provider *oidc.Provider) *oauth2.Config { + return &oauth2.Config{ + ClientID: p.cfg.ClientID, + ClientSecret: p.cfg.ClientSecret, + RedirectURL: p.redirect, + Scopes: p.cfg.ScopeList(), + Endpoint: provider.Endpoint(), + } +} + +func ssoRandomToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generating random token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} + +var ( + _ OAuthProvider = (*SSOProvider)(nil) + _ StateBinder = (*SSOProvider)(nil) + _ ContextualAuthURL = (*SSOProvider)(nil) + _ SSOLabeledProvider = (*SSOProvider)(nil) +) diff --git a/api/internal/service/system_setting.go b/api/internal/service/system_setting.go index 9089b03f..a8c78c9f 100644 --- a/api/internal/service/system_setting.go +++ b/api/internal/service/system_setting.go @@ -147,6 +147,7 @@ func (s *SystemSettingService) GetPublic(ctx context.Context) (map[string]json.R model.SettingAuthGoogleEnabled, model.SettingAuthGitHubEnabled, model.SettingAuthMicrosoftEnabled, + model.SettingAuthSSOEnabled, model.SettingOAuthProviderOrder, model.SettingFeatureStatsTimeline, model.SettingFeatureSemanticSearch, @@ -166,9 +167,32 @@ func (s *SystemSettingService) GetPublic(ctx context.Context) (map[string]json.R result[key] = setting.Value } + if label := s.ssoButtonLabel(ctx); label != "" { + raw, err := json.Marshal(label) + if err != nil { + return nil, fmt.Errorf("marshaling sso button label: %w", err) + } + result["oauth_sso_button_label"] = raw + } + return result, nil } +// ssoButtonLabel returns the operator-configured login button text for the +// generic SSO provider. The config setting holds an encrypted client secret, +// so only this single plaintext field is ever published. +func (s *SystemSettingService) ssoButtonLabel(ctx context.Context) string { + setting, err := s.settings.Get(ctx, model.SettingOAuthSSOConfig) + if err != nil { + return "" + } + var cfg model.OAuthProviderConfig + if err := json.Unmarshal(setting.Value, &cfg); err != nil { + return "" + } + return cfg.ButtonLabel +} + // SeedDefaultLimits creates default values for max_projects_per_user and // max_namespaces_per_user if they do not already exist. func (s *SystemSettingService) SeedDefaultLimits(ctx context.Context) error { diff --git a/web/package-lock.json b/web/package-lock.json index 8e0a65e1..73eb451d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,38 +8,38 @@ "name": "web", "version": "0.0.0", "dependencies": { - "@tailwindcss/typography": "^0.5.19", - "@tanstack/react-query": "^5.90.21", - "axios": "^1.13.5", - "i18next": "^25.8.11", - "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^0.574.0", - "mermaid": "^11.12.3", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-easy-crop": "^5.5.6", - "react-i18next": "^16.5.4", - "react-markdown": "^10.1.0", - "react-router-dom": "^7.13.0", - "recharts": "^3.7.0", - "remark-gfm": "^4.0.1" + "@tailwindcss/typography": "0.5.19", + "@tanstack/react-query": "5.90.21", + "axios": "1.13.5", + "i18next": "25.8.11", + "i18next-browser-languagedetector": "8.2.1", + "lucide-react": "0.574.0", + "mermaid": "11.12.3", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-easy-crop": "5.5.6", + "react-i18next": "16.5.4", + "react-markdown": "10.1.0", + "react-router-dom": "7.13.0", + "recharts": "3.7.0", + "remark-gfm": "4.0.1" }, "devDependencies": { - "@eslint/js": "^9.39.1", - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24.10.1", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "tailwindcss": "^4.1.18", - "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.3.1", - "vitest": "^4.0.18" + "@eslint/js": "9.39.2", + "@tailwindcss/vite": "4.1.18", + "@types/node": "24.10.13", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.4", + "eslint": "9.39.2", + "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-refresh": "0.4.26", + "globals": "16.5.0", + "tailwindcss": "4.1.18", + "typescript": "5.9.3", + "typescript-eslint": "8.56.0", + "vite": "7.3.1", + "vitest": "4.0.18" } }, "node_modules/@antfu/install-pkg": { diff --git a/web/src/api/systemSettings.ts b/web/src/api/systemSettings.ts index 326ad8a5..42159c12 100644 --- a/web/src/api/systemSettings.ts +++ b/web/src/api/systemSettings.ts @@ -69,6 +69,12 @@ export async function testSMTPConfig(): Promise<{ message: string }> { export interface OAuthProviderConfig { client_id: string client_secret: string + // Generic OIDC / custom SSO only. Absent for the built-in providers. + issuer?: string + scopes?: string[] + button_label?: string + disable_pkce?: boolean + require_verified_email?: boolean } export async function getOAuthConfig(provider: string): Promise { diff --git a/web/src/i18n/ar.json b/web/src/i18n/ar.json index 3783ade4..f4681671 100644 --- a/web/src/i18n/ar.json +++ b/web/src/i18n/ar.json @@ -74,6 +74,7 @@ "admin.authentication.microsoft.description": "السماح للمستخدمين بتسجيل الدخول باستخدام حساب Microsoft الخاص بهم.", "admin.authentication.microsoft.title": "Microsoft", "admin.authentication.oauth.changeOrder": "تغيير الترتيب", + "admin.authentication.oauth.errorValidation": "خطأ في الإعدادات: {{detail}}", "admin.authentication.oauth.clientId": "معرّف العميل", "admin.authentication.oauth.clientSecret": "سر العميل", "admin.authentication.oauth.notConfigured": "غير مُهيأ. أضف بيانات الاعتماد لتفعيل هذا المزود.", @@ -81,6 +82,23 @@ "admin.authentication.oauth.redirectUriHint": "يتم تحديده تلقائيًا", "admin.authentication.oauth.saveError": "فشل في حفظ الإعدادات.", "admin.authentication.oauth.saved": "تم حفظ الإعدادات.", + "admin.authentication.sso.title": "SSO مخصص (OIDC)", + "admin.authentication.sso.description": "السماح للمستخدمين بتسجيل الدخول باستخدام موفر هوية OpenID Connect مخصص.", + "admin.authentication.sso.issuer": "عنوان URL للمُصدر", + "admin.authentication.sso.issuerPlaceholder": "https://idp.example.com", + "admin.authentication.sso.issuerHelp": "عنوان URL لمُصدر OIDC. يُستخدم لاكتشاف .well-known/openid-configuration.", + "admin.authentication.sso.scopes": "النطاقات", + "admin.authentication.sso.scopesPlaceholder": "openid, profile, email", + "admin.authentication.sso.scopesHelp": "مفصولة بمسافة أو فاصلة. \"openid\" مطلوب دائمًا. الافتراضي: openid, profile, email.", + "admin.authentication.sso.buttonLabel": "تسمية الزر", + "admin.authentication.sso.buttonLabelPlaceholder": "تسجيل الدخول باستخدام SSO", + "admin.authentication.sso.buttonLabelHelp": "النص المعروض على زر تسجيل الدخول. الحد الأقصى 40 حرفًا. اتركه فارغًا للاستخدام الافتراضي.", + "admin.authentication.sso.disablePKCE": "تعطيل PKCE", + "admin.authentication.sso.disablePKCEHelp": "تعطيل تحدي الرمز S256. قم بالتفعيل فقط إذا كان موفر الهوية الخاص بك لا يدعم PKCE.", + "admin.authentication.sso.requireVerifiedEmail": "طلب بريد إلكتروني موثّق", + "admin.authentication.sso.requireVerifiedEmailHelp": "السماح بتسجيل الدخول فقط عندما يؤكد موفر الهوية أن بريد المستخدم الإلكتروني موثّق.", + "admin.authentication.sso.autoProvision": "توفير تلقائي للحسابات", + "admin.authentication.sso.autoProvisionHelp": "إنشاء حسابات تلقائيًا للمستخدمين الذين يسجلون الدخول عبر SSO لأول مرة. عند التعطيل، يمكن فقط للحسابات الموجودة استخدام تسجيل الدخول عبر SSO.", "admin.authentication.section.emailPassword": "البريد الإلكتروني وكلمة المرور", "admin.authentication.section.oauth": "مزودو OAuth", "admin.authentication.section.oauthDescription": "قم بتكوين مزودي تسجيل الدخول الخارجيين. يحدد الترتيب أدناه كيفية ظهورهم في صفحة تسجيل الدخول.", @@ -370,6 +388,9 @@ "errors.display_name_required": "اسم العرض مطلوب.", "errors.email_already_exists": "يوجد مستخدم بهذا البريد الإلكتروني بالفعل.", "errors.last_login_method": "لا يمكنك إلغاء ربط طريقة تسجيل الدخول الوحيدة لديك. عيّن كلمة مرور أولاً.", + "errors.sso_email_missing": "فشل تسجيل الدخول عبر SSO: لم يتم إرجاع عنوان بريد إلكتروني من موفر الهوية.", + "errors.sso_email_unverified": "فشل تسجيل الدخول عبر SSO: عنوان بريدك الإلكتروني غير موثّق. يرجى التحقق من بريدك الإلكتروني مع موفر الهوية والمحاولة مرة أخرى.", + "errors.sso_account_not_provisioned": "فشل تسجيل الدخول عبر SSO: لا يوجد حساب لهذا البريد الإلكتروني. يرجى التواصل مع المسؤول.", "errors.namespace_limit_reached": "تم الوصول إلى حد ملكية مساحات الأسماء. تواصل مع المسؤول لزيادة الحد.", "errors.namespace_slug_in_use": "معرف مساحة الأسماء \"{{slug}}\" مستخدم بالفعل.", "errors.namespace_slug_invalid": "يجب أن يتكون معرف مساحة الأسماء من 2-30 حرفًا أبجديًا رقميًا صغيرًا أو شرطات، ويبدأ بحرف.", @@ -488,6 +509,11 @@ "login.noAccount": "ليس لديك حساب؟", "login.oauth.authenticating": "جارٍ المصادقة...", "login.oauth.backToLogin": "العودة إلى تسجيل الدخول", + "login.sso.button": "تسجيل الدخول باستخدام SSO", + "login.sso.error": "فشل بدء تسجيل الدخول عبر SSO. حاول مرة أخرى.", + "login.sso.authenticating": "جارٍ المصادقة مع SSO...", + "login.sso.callbackError": "فشلت مصادقة SSO. حاول مرة أخرى.", + "login.sso.backToLogin": "العودة إلى تسجيل الدخول", "login.oauth.callbackError": "فشلت المصادقة. حاول مرة أخرى.", "login.oauth.error": "فشل بدء تسجيل الدخول. حاول مرة أخرى.", "login.or": "أو المتابعة عبر", diff --git a/web/src/i18n/de.json b/web/src/i18n/de.json index 0386cf38..d2b5bade 100644 --- a/web/src/i18n/de.json +++ b/web/src/i18n/de.json @@ -74,6 +74,7 @@ "admin.authentication.microsoft.description": "Benutzern erlauben, sich mit ihrem Microsoft-Konto anzumelden.", "admin.authentication.microsoft.title": "Microsoft", "admin.authentication.oauth.changeOrder": "Reihenfolge ändern", + "admin.authentication.oauth.errorValidation": "Konfigurationsfehler: {{detail}}", "admin.authentication.oauth.clientId": "Client-ID", "admin.authentication.oauth.clientSecret": "Client-Geheimnis", "admin.authentication.oauth.notConfigured": "Nicht konfiguriert. Fügen Sie Zugangsdaten hinzu, um diesen Anbieter zu aktivieren.", @@ -81,6 +82,23 @@ "admin.authentication.oauth.redirectUriHint": "automatisch ermittelt", "admin.authentication.oauth.saveError": "Konfiguration konnte nicht gespeichert werden.", "admin.authentication.oauth.saved": "Konfiguration gespeichert.", + "admin.authentication.sso.title": "Benutzerdefinierte SSO (OIDC)", + "admin.authentication.sso.description": "Benutzern erlauben, sich mit einem benutzerdefinierten OpenID Connect-Identitätsanbieter anzumelden.", + "admin.authentication.sso.issuer": "Issuer-URL", + "admin.authentication.sso.issuerPlaceholder": "https://idp.example.com", + "admin.authentication.sso.issuerHelp": "Die OIDC-Issuer-URL. Wird für die .well-known/openid-configuration-Erkennung verwendet.", + "admin.authentication.sso.scopes": "Bereiche", + "admin.authentication.sso.scopesPlaceholder": "openid, profile, email", + "admin.authentication.sso.scopesHelp": "Durch Leerzeichen oder Komma getrennt. \"openid\" ist immer erforderlich. Standard: openid, profile, email.", + "admin.authentication.sso.buttonLabel": "Schaltflächenbeschriftung", + "admin.authentication.sso.buttonLabelPlaceholder": "Mit SSO anmelden", + "admin.authentication.sso.buttonLabelHelp": "Text, der auf der Anmeldeschaltfläche angezeigt wird. Max. 40 Zeichen. Leer lassen für Standard.", + "admin.authentication.sso.disablePKCE": "PKCE deaktivieren", + "admin.authentication.sso.disablePKCEHelp": "S256-Code-Challenge deaktivieren. Nur aktivieren, wenn Ihr Identitätsanbieter PKCE nicht unterstützt.", + "admin.authentication.sso.requireVerifiedEmail": "Verifizierte E-Mail erforderlich", + "admin.authentication.sso.requireVerifiedEmailHelp": "Anmeldung nur erlauben, wenn der Identitätsanbieter bestätigt, dass die E-Mail des Benutzers verifiziert ist.", + "admin.authentication.sso.autoProvision": "Konten automatisch bereitstellen", + "admin.authentication.sso.autoProvisionHelp": "Automatisch Konten für Benutzer erstellen, die sich zum ersten Mal über SSO anmelden. Wenn deaktiviert, können nur vorhandene Konten die SSO-Anmeldung verwenden.", "admin.authentication.section.emailPassword": "E-Mail & Passwort", "admin.authentication.section.oauth": "OAuth-Anbieter", "admin.authentication.section.oauthDescription": "Externe Anmeldeanbieter konfigurieren. Die Reihenfolge unten bestimmt die Anzeige auf der Anmeldeseite.", @@ -370,6 +388,9 @@ "errors.display_name_required": "Der Anzeigename ist erforderlich.", "errors.email_already_exists": "Ein Benutzer mit dieser E-Mail existiert bereits.", "errors.last_login_method": "Sie können Ihre einzige Anmeldemethode nicht trennen. Legen Sie zuerst ein Passwort fest.", + "errors.sso_email_missing": "SSO-Anmeldung fehlgeschlagen: Vom Identitätsanbieter wurde keine E-Mail-Adresse zurückgegeben.", + "errors.sso_email_unverified": "SSO-Anmeldung fehlgeschlagen: Ihre E-Mail-Adresse ist nicht verifiziert. Bitte verifizieren Sie Ihre E-Mail bei Ihrem Identitätsanbieter und versuchen Sie es erneut.", + "errors.sso_account_not_provisioned": "SSO-Anmeldung fehlgeschlagen: Für diese E-Mail existiert kein Konto. Bitte kontaktieren Sie einen Administrator.", "errors.namespace_limit_reached": "Namespace-Eigentumslimit erreicht. Kontaktieren Sie einen Administrator, um Ihr Limit zu erhöhen.", "errors.namespace_slug_in_use": "Der Namespace-Slug \"{{slug}}\" wird bereits verwendet.", "errors.namespace_slug_invalid": "Der Namespace-Slug muss 2-30 kleingeschriebene alphanumerische Zeichen oder Bindestriche enthalten und mit einem Buchstaben beginnen.", @@ -488,6 +509,11 @@ "login.noAccount": "Noch kein Konto?", "login.oauth.authenticating": "Authentifizierung...", "login.oauth.backToLogin": "Zurück zur Anmeldung", + "login.sso.button": "Mit SSO anmelden", + "login.sso.error": "SSO-Anmeldung konnte nicht gestartet werden. Bitte erneut versuchen.", + "login.sso.authenticating": "Authentifizierung mit SSO...", + "login.sso.callbackError": "SSO-Authentifizierung fehlgeschlagen. Bitte erneut versuchen.", + "login.sso.backToLogin": "Zurück zur Anmeldung", "login.oauth.callbackError": "Authentifizierung fehlgeschlagen. Bitte erneut versuchen.", "login.oauth.error": "Anmeldung konnte nicht gestartet werden. Bitte erneut versuchen.", "login.or": "oder weiter mit", diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 6401b97f..290c8398 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -99,6 +99,14 @@ "login.oauth.authenticating": "Authenticating...", "login.oauth.callbackError": "Authentication failed. Please try again.", "login.oauth.backToLogin": "Back to sign in", + "login.sso.button": "Sign in with SSO", + "login.sso.error": "Failed to start SSO login. Please try again.", + "login.sso.authenticating": "Authenticating with SSO...", + "login.sso.callbackError": "SSO authentication failed. Please try again.", + "login.sso.backToLogin": "Back to sign in", + "errors.sso_email_missing": "SSO login failed: no email address was returned by the identity provider.", + "errors.sso_email_unverified": "SSO login failed: your email address is not verified. Please verify your email with your identity provider and try again.", + "errors.sso_account_not_provisioned": "SSO login failed: no account exists for this email. Please contact an administrator.", "login.noAccount": "Don't have an account?", "login.createAccount": "Create an account", @@ -903,6 +911,26 @@ "admin.authentication.oauth.saveError": "Failed to save configuration.", "admin.authentication.oauth.notConfigured": "Not configured. Add credentials to enable this provider.", "admin.authentication.oauth.changeOrder": "Change order", + "admin.authentication.oauth.errorValidation": "Configuration error: {{detail}}", + + "admin.authentication.sso.title": "Custom SSO (OIDC)", + "admin.authentication.sso.description": "Allow users to sign in with a custom OpenID Connect identity provider.", + "admin.authentication.sso.issuer": "Issuer URL", + "admin.authentication.sso.issuerPlaceholder": "https://idp.example.com", + "admin.authentication.sso.issuerHelp": "The OIDC issuer URL. Used for .well-known/openid-configuration discovery.", + "admin.authentication.sso.scopes": "Scopes", + "admin.authentication.sso.scopesPlaceholder": "openid, profile, email", + "admin.authentication.sso.scopesHelp": "Space or comma separated. \"openid\" is always required. Defaults to: openid, profile, email.", + "admin.authentication.sso.buttonLabel": "Button label", + "admin.authentication.sso.buttonLabelPlaceholder": "Sign in with SSO", + "admin.authentication.sso.buttonLabelHelp": "Text shown on the login button. Max 40 characters. Leave empty for default.", + "admin.authentication.sso.disablePKCE": "Disable PKCE", + "admin.authentication.sso.disablePKCEHelp": "Disable S256 code challenge. Only enable if your identity provider does not support PKCE.", + "admin.authentication.sso.requireVerifiedEmail": "Require verified email", + "admin.authentication.sso.requireVerifiedEmailHelp": "Only allow login when the identity provider confirms the user's email is verified.", + + "admin.authentication.sso.autoProvision": "Auto-provision accounts", + "admin.authentication.sso.autoProvisionHelp": "Automatically create accounts for users who sign in via SSO for the first time. When disabled, only existing accounts can use SSO login.", "admin.features.title": "Features", "admin.features.description": "Enable or disable features globally across the platform.", diff --git a/web/src/i18n/es.json b/web/src/i18n/es.json index 5bf00c1f..cc98a701 100644 --- a/web/src/i18n/es.json +++ b/web/src/i18n/es.json @@ -74,6 +74,7 @@ "admin.authentication.microsoft.description": "Permitir a los usuarios iniciar sesión con su cuenta de Microsoft.", "admin.authentication.microsoft.title": "Microsoft", "admin.authentication.oauth.changeOrder": "Cambiar orden", + "admin.authentication.oauth.errorValidation": "Error de configuración: {{detail}}", "admin.authentication.oauth.clientId": "ID de cliente", "admin.authentication.oauth.clientSecret": "Secreto de cliente", "admin.authentication.oauth.notConfigured": "No configurado. Agregue credenciales para habilitar este proveedor.", @@ -81,6 +82,23 @@ "admin.authentication.oauth.redirectUriHint": "determinado automáticamente", "admin.authentication.oauth.saveError": "Error al guardar la configuración.", "admin.authentication.oauth.saved": "Configuración guardada.", + "admin.authentication.sso.title": "SSO personalizado (OIDC)", + "admin.authentication.sso.description": "Permitir a los usuarios iniciar sesión con un proveedor de identidad OpenID Connect personalizado.", + "admin.authentication.sso.issuer": "URL del emisor", + "admin.authentication.sso.issuerPlaceholder": "https://idp.example.com", + "admin.authentication.sso.issuerHelp": "La URL del emisor OIDC. Se utiliza para el descubrimiento de .well-known/openid-configuration.", + "admin.authentication.sso.scopes": "Ámbitos", + "admin.authentication.sso.scopesPlaceholder": "openid, profile, email", + "admin.authentication.sso.scopesHelp": "Separados por espacio o coma. \"openid\" siempre es obligatorio. Predeterminado: openid, profile, email.", + "admin.authentication.sso.buttonLabel": "Etiqueta del botón", + "admin.authentication.sso.buttonLabelPlaceholder": "Iniciar sesión con SSO", + "admin.authentication.sso.buttonLabelHelp": "Texto que se muestra en el botón de inicio de sesión. Máximo 40 caracteres. Dejar vacío para usar el predeterminado.", + "admin.authentication.sso.disablePKCE": "Desactivar PKCE", + "admin.authentication.sso.disablePKCEHelp": "Desactivar el desafío de código S256. Solo habilitar si su proveedor de identidad no admite PKCE.", + "admin.authentication.sso.requireVerifiedEmail": "Requerir correo electrónico verificado", + "admin.authentication.sso.requireVerifiedEmailHelp": "Solo permitir el inicio de sesión cuando el proveedor de identidad confirme que el correo electrónico del usuario está verificado.", + "admin.authentication.sso.autoProvision": "Aprovisionamiento automático de cuentas", + "admin.authentication.sso.autoProvisionHelp": "Crear automáticamente cuentas para usuarios que inician sesión mediante SSO por primera vez. Cuando está desactivado, solo las cuentas existentes pueden usar el inicio de sesión SSO.", "admin.authentication.section.emailPassword": "Correo electrónico y contraseña", "admin.authentication.section.oauth": "Proveedores OAuth", "admin.authentication.section.oauthDescription": "Configurar proveedores de inicio de sesión externos. El orden a continuación determina cómo aparecen en la página de inicio de sesión.", @@ -370,6 +388,9 @@ "errors.display_name_required": "El nombre para mostrar es obligatorio.", "errors.email_already_exists": "Ya existe un usuario con este correo electrónico.", "errors.last_login_method": "No puede desvincular su único método de inicio de sesión. Establezca una contraseña primero.", + "errors.sso_email_missing": "Error de inicio de sesión SSO: el proveedor de identidad no devolvió ninguna dirección de correo electrónico.", + "errors.sso_email_unverified": "Error de inicio de sesión SSO: su dirección de correo electrónico no está verificada. Verifique su correo electrónico con su proveedor de identidad e inténtelo de nuevo.", + "errors.sso_account_not_provisioned": "Error de inicio de sesión SSO: no existe una cuenta para este correo electrónico. Póngase en contacto con un administrador.", "errors.namespace_limit_reached": "Límite de propiedad de namespaces alcanzado. Contacte a un administrador para aumentar su límite.", "errors.namespace_slug_in_use": "El identificador de namespace \"{{slug}}\" ya está en uso.", "errors.namespace_slug_invalid": "El identificador del namespace debe tener 2-30 caracteres alfanuméricos en minúsculas o guiones, comenzando con una letra.", @@ -488,6 +509,11 @@ "login.noAccount": "¿No tienes una cuenta?", "login.oauth.authenticating": "Autenticando...", "login.oauth.backToLogin": "Volver al inicio de sesión", + "login.sso.button": "Iniciar sesión con SSO", + "login.sso.error": "Error al iniciar el inicio de sesión SSO. Inténtelo de nuevo.", + "login.sso.authenticating": "Autenticando con SSO...", + "login.sso.callbackError": "La autenticación SSO falló. Inténtelo de nuevo.", + "login.sso.backToLogin": "Volver al inicio de sesión", "login.oauth.callbackError": "Falló la autenticación. Intente de nuevo.", "login.oauth.error": "Error al iniciar sesión. Intente de nuevo.", "login.or": "o continuar con", diff --git a/web/src/i18n/fr.json b/web/src/i18n/fr.json index 897c0218..fd30a917 100644 --- a/web/src/i18n/fr.json +++ b/web/src/i18n/fr.json @@ -74,6 +74,7 @@ "admin.authentication.microsoft.description": "Permettre aux utilisateurs de se connecter avec leur compte Microsoft.", "admin.authentication.microsoft.title": "Microsoft", "admin.authentication.oauth.changeOrder": "Modifier l'ordre", + "admin.authentication.oauth.errorValidation": "Erreur de configuration : {{detail}}", "admin.authentication.oauth.clientId": "ID client", "admin.authentication.oauth.clientSecret": "Secret client", "admin.authentication.oauth.notConfigured": "Non configuré. Ajoutez des identifiants pour activer ce fournisseur.", @@ -81,6 +82,23 @@ "admin.authentication.oauth.redirectUriHint": "déterminé automatiquement", "admin.authentication.oauth.saveError": "Échec de l'enregistrement de la configuration.", "admin.authentication.oauth.saved": "Configuration enregistrée.", + "admin.authentication.sso.title": "SSO personnalisé (OIDC)", + "admin.authentication.sso.description": "Permettre aux utilisateurs de se connecter avec un fournisseur d'identité OpenID Connect personnalisé.", + "admin.authentication.sso.issuer": "URL de l'émetteur", + "admin.authentication.sso.issuerPlaceholder": "https://idp.example.com", + "admin.authentication.sso.issuerHelp": "L'URL de l'émetteur OIDC. Utilisée pour la découverte .well-known/openid-configuration.", + "admin.authentication.sso.scopes": "Portées", + "admin.authentication.sso.scopesPlaceholder": "openid, profile, email", + "admin.authentication.sso.scopesHelp": "Séparées par des espaces ou des virgules. \"openid\" est toujours requis. Par défaut : openid, profile, email.", + "admin.authentication.sso.buttonLabel": "Libellé du bouton", + "admin.authentication.sso.buttonLabelPlaceholder": "Se connecter avec SSO", + "admin.authentication.sso.buttonLabelHelp": "Texte affiché sur le bouton de connexion. Maximum 40 caractères. Laisser vide pour la valeur par défaut.", + "admin.authentication.sso.disablePKCE": "Désactiver PKCE", + "admin.authentication.sso.disablePKCEHelp": "Désactiver le défi de code S256. Activer uniquement si votre fournisseur d'identité ne prend pas en charge PKCE.", + "admin.authentication.sso.requireVerifiedEmail": "Exiger un e-mail vérifié", + "admin.authentication.sso.requireVerifiedEmailHelp": "N'autoriser la connexion que lorsque le fournisseur d'identité confirme que l'e-mail de l'utilisateur est vérifié.", + "admin.authentication.sso.autoProvision": "Approvisionnement automatique des comptes", + "admin.authentication.sso.autoProvisionHelp": "Créer automatiquement des comptes pour les utilisateurs qui se connectent via SSO pour la première fois. Lorsque désactivé, seuls les comptes existants peuvent utiliser la connexion SSO.", "admin.authentication.section.emailPassword": "E-mail et mot de passe", "admin.authentication.section.oauth": "Fournisseurs OAuth", "admin.authentication.section.oauthDescription": "Configurer les fournisseurs de connexion externes. L'ordre ci-dessous détermine leur affichage sur la page de connexion.", @@ -370,6 +388,9 @@ "errors.display_name_required": "Le nom d'affichage est obligatoire.", "errors.email_already_exists": "Un utilisateur avec cet e-mail existe déjà.", "errors.last_login_method": "Vous ne pouvez pas dissocier votre seule méthode de connexion. Définissez d'abord un mot de passe.", + "errors.sso_email_missing": "Échec de la connexion SSO : aucune adresse e-mail n'a été retournée par le fournisseur d'identité.", + "errors.sso_email_unverified": "Échec de la connexion SSO : votre adresse e-mail n'est pas vérifiée. Veuillez vérifier votre e-mail auprès de votre fournisseur d'identité et réessayer.", + "errors.sso_account_not_provisioned": "Échec de la connexion SSO : aucun compte n'existe pour cet e-mail. Veuillez contacter un administrateur.", "errors.namespace_limit_reached": "Limite de propriété de namespaces atteinte. Contactez un administrateur pour augmenter votre limite.", "errors.namespace_slug_in_use": "Le slug de namespace \"{{slug}}\" est déjà utilisé.", "errors.namespace_slug_invalid": "Le slug du namespace doit contenir 2 à 30 caractères alphanumériques minuscules ou tirets, commençant par une lettre.", @@ -488,6 +509,11 @@ "login.noAccount": "Vous n'avez pas de compte ?", "login.oauth.authenticating": "Authentification...", "login.oauth.backToLogin": "Retour à la connexion", + "login.sso.button": "Se connecter avec SSO", + "login.sso.error": "Échec du lancement de la connexion SSO. Veuillez réessayer.", + "login.sso.authenticating": "Authentification avec SSO...", + "login.sso.callbackError": "L'authentification SSO a échoué. Veuillez réessayer.", + "login.sso.backToLogin": "Retour à la connexion", "login.oauth.callbackError": "Échec de l'authentification. Veuillez réessayer.", "login.oauth.error": "Échec du lancement de la connexion. Veuillez réessayer.", "login.or": "ou continuer avec", diff --git a/web/src/i18n/i18n.test.ts b/web/src/i18n/i18n.test.ts index 6448ffed..6478b6c6 100644 --- a/web/src/i18n/i18n.test.ts +++ b/web/src/i18n/i18n.test.ts @@ -34,6 +34,8 @@ const SAME_VALUE_ALLOWED = new Set([ 'admin.integrations.smtp.smtpHostPlaceholder', 'admin.integrations.smtp.usernamePlaceholder', 'admin.integrations.smtp.encryptionStarttls', + 'admin.authentication.sso.issuerPlaceholder', + 'admin.authentication.sso.scopesPlaceholder', 'sla.durationPlaceholder', 'timeTracking.durationPlaceholder', 'projects.create.keyPlaceholder', diff --git a/web/src/i18n/ja.json b/web/src/i18n/ja.json index 402346e3..59ea99f5 100644 --- a/web/src/i18n/ja.json +++ b/web/src/i18n/ja.json @@ -74,6 +74,7 @@ "admin.authentication.microsoft.description": "ユーザーがMicrosoftアカウントでサインインできるようにします。", "admin.authentication.microsoft.title": "Microsoft", "admin.authentication.oauth.changeOrder": "順序を変更", + "admin.authentication.oauth.errorValidation": "設定エラー: {{detail}}", "admin.authentication.oauth.clientId": "クライアントID", "admin.authentication.oauth.clientSecret": "クライアントシークレット", "admin.authentication.oauth.notConfigured": "未設定です。このプロバイダーを有効にするには認証情報を追加してください。", @@ -81,6 +82,23 @@ "admin.authentication.oauth.redirectUriHint": "自動的に決定", "admin.authentication.oauth.saveError": "設定の保存に失敗しました。", "admin.authentication.oauth.saved": "設定を保存しました。", + "admin.authentication.sso.title": "カスタムSSO (OIDC)", + "admin.authentication.sso.description": "ユーザーがカスタムOpenID Connectアイデンティティプロバイダーでサインインできるようにします。", + "admin.authentication.sso.issuer": "発行者URL", + "admin.authentication.sso.issuerPlaceholder": "https://idp.example.com", + "admin.authentication.sso.issuerHelp": "OIDC発行者URL。.well-known/openid-configurationの検出に使用されます。", + "admin.authentication.sso.scopes": "スコープ", + "admin.authentication.sso.scopesPlaceholder": "openid, profile, email", + "admin.authentication.sso.scopesHelp": "スペースまたはカンマ区切り。\"openid\"は常に必須です。デフォルト: openid, profile, email。", + "admin.authentication.sso.buttonLabel": "ボタンラベル", + "admin.authentication.sso.buttonLabelPlaceholder": "SSOでサインイン", + "admin.authentication.sso.buttonLabelHelp": "ログインボタンに表示されるテキスト。最大40文字。空欄の場合はデフォルトが使用されます。", + "admin.authentication.sso.disablePKCE": "PKCEを無効にする", + "admin.authentication.sso.disablePKCEHelp": "S256コードチャレンジを無効にします。アイデンティティプロバイダーがPKCEをサポートしていない場合のみ有効にしてください。", + "admin.authentication.sso.requireVerifiedEmail": "確認済みメールアドレスを要求", + "admin.authentication.sso.requireVerifiedEmailHelp": "アイデンティティプロバイダーがユーザーのメールアドレスの確認済みを確認した場合のみログインを許可します。", + "admin.authentication.sso.autoProvision": "アカウントの自動プロビジョニング", + "admin.authentication.sso.autoProvisionHelp": "SSO経由で初めてサインインするユーザーのアカウントを自動的に作成します。無効にすると、既存のアカウントのみがSSOログインを使用できます。", "admin.authentication.section.emailPassword": "メールアドレスとパスワード", "admin.authentication.section.oauth": "OAuthプロバイダー", "admin.authentication.section.oauthDescription": "外部サインインプロバイダーを設定します。以下の順序がログインページでの表示順を決定します。", @@ -370,6 +388,9 @@ "errors.display_name_required": "表示名は必須です。", "errors.email_already_exists": "このメールアドレスのユーザーは既に存在します。", "errors.last_login_method": "唯一のサインイン方法のリンクは解除できません。先にパスワードを設定してください。", + "errors.sso_email_missing": "SSOログインに失敗しました: アイデンティティプロバイダーからメールアドレスが返されませんでした。", + "errors.sso_email_unverified": "SSOログインに失敗しました: メールアドレスが確認されていません。アイデンティティプロバイダーでメールアドレスを確認してから、再度お試しください。", + "errors.sso_account_not_provisioned": "SSOログインに失敗しました: このメールアドレスのアカウントは存在しません。管理者に連絡してください。", "errors.namespace_limit_reached": "名前空間の所有制限に達しました。管理者に連絡して制限を引き上げてください。", "errors.namespace_slug_in_use": "名前空間スラッグ「{{slug}}」は既に使用されています。", "errors.namespace_slug_invalid": "名前空間スラッグは2〜30文字の小文字英数字またはハイフンで、文字で始まる必要があります。", @@ -488,6 +509,11 @@ "login.noAccount": "アカウントをお持ちでないですか?", "login.oauth.authenticating": "認証中...", "login.oauth.backToLogin": "ログインに戻る", + "login.sso.button": "SSOでサインイン", + "login.sso.error": "SSOログインの開始に失敗しました。再度お試しください。", + "login.sso.authenticating": "SSOで認証中...", + "login.sso.callbackError": "SSO認証に失敗しました。再度お試しください。", + "login.sso.backToLogin": "ログインに戻る", "login.oauth.callbackError": "認証に失敗しました。再度お試しください。", "login.oauth.error": "ログインの開始に失敗しました。再度お試しください。", "login.or": "または次で続行", diff --git a/web/src/i18n/ko.json b/web/src/i18n/ko.json index adef0704..4eb1c2ca 100644 --- a/web/src/i18n/ko.json +++ b/web/src/i18n/ko.json @@ -74,6 +74,7 @@ "admin.authentication.microsoft.description": "사용자가 Microsoft 계정으로 로그인할 수 있도록 허용합니다.", "admin.authentication.microsoft.title": "Microsoft", "admin.authentication.oauth.changeOrder": "순서 변경", + "admin.authentication.oauth.errorValidation": "구성 오류: {{detail}}", "admin.authentication.oauth.clientId": "클라이언트 ID", "admin.authentication.oauth.clientSecret": "클라이언트 시크릿", "admin.authentication.oauth.notConfigured": "구성되지 않았습니다. 이 공급자를 활성화하려면 자격 증명을 추가하세요.", @@ -81,6 +82,23 @@ "admin.authentication.oauth.redirectUriHint": "자동으로 결정됨", "admin.authentication.oauth.saveError": "구성 저장에 실패했습니다.", "admin.authentication.oauth.saved": "구성이 저장되었습니다.", + "admin.authentication.sso.title": "사용자 정의 SSO (OIDC)", + "admin.authentication.sso.description": "사용자가 사용자 정의 OpenID Connect 아이덴티티 제공업체로 로그인할 수 있도록 허용합니다.", + "admin.authentication.sso.issuer": "발급자 URL", + "admin.authentication.sso.issuerPlaceholder": "https://idp.example.com", + "admin.authentication.sso.issuerHelp": "OIDC 발급자 URL. .well-known/openid-configuration 검색에 사용됩니다.", + "admin.authentication.sso.scopes": "범위", + "admin.authentication.sso.scopesPlaceholder": "openid, profile, email", + "admin.authentication.sso.scopesHelp": "공백 또는 쉼표로 구분됩니다. \"openid\"는 항상 필수입니다. 기본값: openid, profile, email.", + "admin.authentication.sso.buttonLabel": "버튼 라벨", + "admin.authentication.sso.buttonLabelPlaceholder": "SSO로 로그인", + "admin.authentication.sso.buttonLabelHelp": "로그인 버튼에 표시되는 텍스트. 최대 40자. 비워두면 기본값이 사용됩니다.", + "admin.authentication.sso.disablePKCE": "PKCE 비활성화", + "admin.authentication.sso.disablePKCEHelp": "S256 코드 챌린지를 비활성화합니다. 아이덴티티 제공업체가 PKCE를 지원하지 않는 경우에만 활성화하세요.", + "admin.authentication.sso.requireVerifiedEmail": "확인된 이메일 요구", + "admin.authentication.sso.requireVerifiedEmailHelp": "아이덴티티 제공업체가 사용자의 이메일이 확인되었음을 확인할 때만 로그인을 허용합니다.", + "admin.authentication.sso.autoProvision": "계정 자동 프로비저닝", + "admin.authentication.sso.autoProvisionHelp": "SSO를 통해 처음 로그인하는 사용자의 계정을 자동으로 생성합니다. 비활성화되면 기존 계정만 SSO 로그인을 사용할 수 있습니다.", "admin.authentication.section.emailPassword": "이메일 및 비밀번호", "admin.authentication.section.oauth": "OAuth 제공업체", "admin.authentication.section.oauthDescription": "외부 로그인 제공업체를 구성합니다. 아래 순서가 로그인 페이지에서의 표시 순서를 결정합니다.", @@ -370,6 +388,9 @@ "errors.display_name_required": "표시 이름은 필수입니다.", "errors.email_already_exists": "이 이메일의 사용자가 이미 존재합니다.", "errors.last_login_method": "유일한 로그인 수단은 연결을 해제할 수 없습니다. 먼저 비밀번호를 설정하세요.", + "errors.sso_email_missing": "SSO 로그인 실패: 아이덴티티 제공업체에서 이메일 주소가 반환되지 않았습니다.", + "errors.sso_email_unverified": "SSO 로그인 실패: 이메일 주소가 확인되지 않았습니다. 아이덴티티 제공업체에서 이메일을 확인한 후 다시 시도해 주세요.", + "errors.sso_account_not_provisioned": "SSO 로그인 실패: 이 이메일에 대한 계정이 존재하지 않습니다. 관리자에게 문의해 주세요.", "errors.namespace_limit_reached": "네임스페이스 소유 제한에 도달했습니다. 관리자에게 연락하여 제한을 늘려주세요.", "errors.namespace_slug_in_use": "네임스페이스 슬러그 \"{{slug}}\"은(는) 이미 사용 중입니다.", "errors.namespace_slug_invalid": "네임스페이스 슬러그는 2-30자의 소문자 영숫자 또는 하이픈이어야 하며 문자로 시작해야 합니다.", @@ -488,6 +509,11 @@ "login.noAccount": "계정이 없으신가요?", "login.oauth.authenticating": "인증 중...", "login.oauth.backToLogin": "로그인으로 돌아가기", + "login.sso.button": "SSO로 로그인", + "login.sso.error": "SSO 로그인을 시작하지 못했습니다. 다시 시도해 주세요.", + "login.sso.authenticating": "SSO로 인증 중...", + "login.sso.callbackError": "SSO 인증에 실패했습니다. 다시 시도해 주세요.", + "login.sso.backToLogin": "로그인으로 돌아가기", "login.oauth.callbackError": "인증에 실패했습니다. 다시 시도해 주세요.", "login.oauth.error": "로그인을 시작하지 못했습니다. 다시 시도해 주세요.", "login.or": "또는 다음으로 계속", diff --git a/web/src/i18n/pt.json b/web/src/i18n/pt.json index b9b5152a..ca61af52 100644 --- a/web/src/i18n/pt.json +++ b/web/src/i18n/pt.json @@ -74,6 +74,7 @@ "admin.authentication.microsoft.description": "Permitir que os usuários façam login com sua conta Microsoft.", "admin.authentication.microsoft.title": "Microsoft", "admin.authentication.oauth.changeOrder": "Alterar ordem", + "admin.authentication.oauth.errorValidation": "Erro de configuração: {{detail}}", "admin.authentication.oauth.clientId": "ID do cliente", "admin.authentication.oauth.clientSecret": "Segredo do cliente", "admin.authentication.oauth.notConfigured": "Não configurado. Adicione credenciais para habilitar este provedor.", @@ -81,6 +82,23 @@ "admin.authentication.oauth.redirectUriHint": "determinado automaticamente", "admin.authentication.oauth.saveError": "Falha ao salvar a configuração.", "admin.authentication.oauth.saved": "Configuração salva.", + "admin.authentication.sso.title": "SSO personalizado (OIDC)", + "admin.authentication.sso.description": "Permitir que os usuários façam login com um provedor de identidade OpenID Connect personalizado.", + "admin.authentication.sso.issuer": "URL do emissor", + "admin.authentication.sso.issuerPlaceholder": "https://idp.example.com", + "admin.authentication.sso.issuerHelp": "A URL do emissor OIDC. Usada para descoberta .well-known/openid-configuration.", + "admin.authentication.sso.scopes": "Escopos", + "admin.authentication.sso.scopesPlaceholder": "openid, profile, email", + "admin.authentication.sso.scopesHelp": "Separados por espaço ou vírgula. \"openid\" é sempre obrigatório. Padrão: openid, profile, email.", + "admin.authentication.sso.buttonLabel": "Rótulo do botão", + "admin.authentication.sso.buttonLabelPlaceholder": "Entrar com SSO", + "admin.authentication.sso.buttonLabelHelp": "Texto exibido no botão de login. Máximo de 40 caracteres. Deixe vazio para o padrão.", + "admin.authentication.sso.disablePKCE": "Desativar PKCE", + "admin.authentication.sso.disablePKCEHelp": "Desativar desafio de código S256. Ative apenas se seu provedor de identidade não suportar PKCE.", + "admin.authentication.sso.requireVerifiedEmail": "Exigir e-mail verificado", + "admin.authentication.sso.requireVerifiedEmailHelp": "Permitir login apenas quando o provedor de identidade confirmar que o e-mail do usuário está verificado.", + "admin.authentication.sso.autoProvision": "Provisionamento automático de contas", + "admin.authentication.sso.autoProvisionHelp": "Criar automaticamente contas para usuários que fazem login via SSO pela primeira vez. Quando desativado, apenas contas existentes podem usar o login SSO.", "admin.authentication.section.emailPassword": "E-mail e Senha", "admin.authentication.section.oauth": "Provedores OAuth", "admin.authentication.section.oauthDescription": "Configurar provedores de login externos. A ordem abaixo determina como aparecem na página de login.", @@ -370,6 +388,9 @@ "errors.display_name_required": "O nome de exibição é obrigatório.", "errors.email_already_exists": "Já existe um usuário com este e-mail.", "errors.last_login_method": "Você não pode desvincular seu único método de login. Defina uma senha primeiro.", + "errors.sso_email_missing": "Falha no login SSO: nenhum endereço de e-mail foi retornado pelo provedor de identidade.", + "errors.sso_email_unverified": "Falha no login SSO: seu endereço de e-mail não está verificado. Verifique seu e-mail com seu provedor de identidade e tente novamente.", + "errors.sso_account_not_provisioned": "Falha no login SSO: não existe uma conta para este e-mail. Entre em contato com um administrador.", "errors.namespace_limit_reached": "Limite de propriedade de namespaces atingido. Entre em contato com um administrador para aumentar seu limite.", "errors.namespace_slug_in_use": "O identificador de namespace \"{{slug}}\" já está em uso.", "errors.namespace_slug_invalid": "O identificador do namespace deve ter 2-30 caracteres alfanuméricos minúsculos ou hífens, começando com uma letra.", @@ -488,6 +509,11 @@ "login.noAccount": "Não tem uma conta?", "login.oauth.authenticating": "Autenticando...", "login.oauth.backToLogin": "Voltar ao login", + "login.sso.button": "Entrar com SSO", + "login.sso.error": "Falha ao iniciar o login SSO. Tente novamente.", + "login.sso.authenticating": "Autenticando com SSO...", + "login.sso.callbackError": "A autenticação SSO falhou. Tente novamente.", + "login.sso.backToLogin": "Voltar ao login", "login.oauth.callbackError": "Falha na autenticação. Tente novamente.", "login.oauth.error": "Falha ao iniciar login. Tente novamente.", "login.or": "ou continue com", diff --git a/web/src/i18n/zh.json b/web/src/i18n/zh.json index f1667340..8515f650 100644 --- a/web/src/i18n/zh.json +++ b/web/src/i18n/zh.json @@ -74,6 +74,7 @@ "admin.authentication.microsoft.description": "允许用户使用其 Microsoft 账户登录。", "admin.authentication.microsoft.title": "Microsoft", "admin.authentication.oauth.changeOrder": "更改顺序", + "admin.authentication.oauth.errorValidation": "配置错误:{{detail}}", "admin.authentication.oauth.clientId": "客户端 ID", "admin.authentication.oauth.clientSecret": "客户端密钥", "admin.authentication.oauth.notConfigured": "未配置。添加凭据以启用此提供商。", @@ -81,6 +82,23 @@ "admin.authentication.oauth.redirectUriHint": "自动确定", "admin.authentication.oauth.saveError": "保存配置失败。", "admin.authentication.oauth.saved": "配置已保存。", + "admin.authentication.sso.title": "自定义 SSO (OIDC)", + "admin.authentication.sso.description": "允许用户使用自定义 OpenID Connect 身份提供商登录。", + "admin.authentication.sso.issuer": "发行者 URL", + "admin.authentication.sso.issuerPlaceholder": "https://idp.example.com", + "admin.authentication.sso.issuerHelp": "OIDC 发行者 URL。用于 .well-known/openid-configuration 发现。", + "admin.authentication.sso.scopes": "作用域", + "admin.authentication.sso.scopesPlaceholder": "openid, profile, email", + "admin.authentication.sso.scopesHelp": "空格或逗号分隔。\"openid\" 始终必需。默认为:openid, profile, email。", + "admin.authentication.sso.buttonLabel": "按钮标签", + "admin.authentication.sso.buttonLabelPlaceholder": "使用 SSO 登录", + "admin.authentication.sso.buttonLabelHelp": "登录按钮上显示的文本。最多 40 个字符。留空使用默认值。", + "admin.authentication.sso.disablePKCE": "禁用 PKCE", + "admin.authentication.sso.disablePKCEHelp": "禁用 S256 代码挑战。仅当您的身份提供商不支持 PKCE 时才启用。", + "admin.authentication.sso.requireVerifiedEmail": "要求验证电子邮件", + "admin.authentication.sso.requireVerifiedEmailHelp": "仅当身份提供商确认用户电子邮件已验证时才允许登录。", + "admin.authentication.sso.autoProvision": "自动配置账户", + "admin.authentication.sso.autoProvisionHelp": "为首次通过 SSO 登录的用户自动创建账户。禁用时,仅现有账户可以使用 SSO 登录。", "admin.authentication.section.emailPassword": "电子邮件和密码", "admin.authentication.section.oauth": "OAuth 提供商", "admin.authentication.section.oauthDescription": "配置外部登录提供商。以下顺序决定了它们在登录页面上的显示方式。", @@ -370,6 +388,9 @@ "errors.display_name_required": "显示名称为必填项。", "errors.email_already_exists": "此电子邮件的用户已存在。", "errors.last_login_method": "无法取消关联您唯一的登录方式。请先设置密码。", + "errors.sso_email_missing": "SSO 登录失败:身份提供商未返回电子邮件地址。", + "errors.sso_email_unverified": "SSO 登录失败:您的电子邮件地址未验证。请在身份提供商处验证您的电子邮件并重试。", + "errors.sso_account_not_provisioned": "SSO 登录失败:此电子邮件没有账户。请联系管理员。", "errors.namespace_limit_reached": "命名空间所有权限制已达到。请联系管理员增加您的限制。", "errors.namespace_slug_in_use": "命名空间标识 \"{{slug}}\" 已被使用。", "errors.namespace_slug_invalid": "命名空间标识必须为2-30个小写字母数字字符或连字符,以字母开头。", @@ -488,6 +509,11 @@ "login.noAccount": "还没有账号?", "login.oauth.authenticating": "正在认证...", "login.oauth.backToLogin": "返回登录", + "login.sso.button": "使用 SSO 登录", + "login.sso.error": "无法启动 SSO 登录,请重试。", + "login.sso.authenticating": "正在使用 SSO 认证...", + "login.sso.callbackError": "SSO 认证失败,请重试。", + "login.sso.backToLogin": "返回登录", "login.oauth.callbackError": "认证失败,请重试。", "login.oauth.error": "无法启动登录,请重试。", "login.or": "或通过以下方式继续", diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 784db5db..d465311f 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -47,6 +47,13 @@ const OAUTH_PROVIDERS: Record = { ), }, + sso: { + icon: ( + + + + ), + }, } const PENDING_INVITE_KEY = 'taskwondo_pending_invite' @@ -114,7 +121,7 @@ export function LoginPage() { const providerOrder = Array.isArray(publicSettings?.oauth_provider_order) ? publicSettings.oauth_provider_order as string[] - : ['discord', 'google', 'github', 'microsoft'] + : ['discord', 'google', 'github', 'microsoft', 'sso'] const enabledProviders = providers ? Object.keys(OAUTH_PROVIDERS) @@ -205,18 +212,24 @@ export function LoginPage() { )}
- {enabledProviders.map((provider) => ( - - ))} + {enabledProviders.map((provider) => { + const ssoLabel = provider === 'sso' && typeof publicSettings?.oauth_sso_button_label === 'string' + ? (publicSettings.oauth_sso_button_label as string) + : '' + const label = ssoLabel || t(`login.${provider}.button`) + return ( + + ) + })}
)} diff --git a/web/src/pages/SystemAuthenticationPage.tsx b/web/src/pages/SystemAuthenticationPage.tsx index f1d11bf9..e914dda0 100644 --- a/web/src/pages/SystemAuthenticationPage.tsx +++ b/web/src/pages/SystemAuthenticationPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { usePublicSettings, useSetSystemSetting, useSMTPConfig, useOAuthConfig, useSetOAuthConfig } from '@/hooks/useSystemSettings' import type { OAuthProviderConfig } from '@/api/systemSettings' +import { getOAuthConfigError } from '@/utils/oauthConfigError' import { Toggle } from '@/components/ui/Toggle' import { Input } from '@/components/ui/Input' import { Spinner } from '@/components/ui/Spinner' @@ -15,6 +16,16 @@ const emptyConfig: OAuthProviderConfig = { client_secret: '', } +const emptySSOConfig: OAuthProviderConfig = { + client_id: '', + client_secret: '', + issuer: '', + scopes: [], + button_label: '', + disable_pkce: false, + require_verified_email: true, +} + function RedirectUriField({ provider }: { provider: string }) { const { t } = useTranslation() const [copied, setCopied] = useState(false) @@ -67,6 +78,8 @@ interface OAuthProviderDef { titleKey: string descriptionKey: string enabledSettingKey: string + /** Extra fields rendered for this provider beyond client id/secret. */ + sso?: boolean } const OAUTH_PROVIDERS: OAuthProviderDef[] = [ @@ -89,14 +102,15 @@ const OAUTH_PROVIDERS: OAuthProviderDef[] = [ enabledSettingKey: 'auth_github_enabled', }, { - provider: 'microsoft', - titleKey: 'admin.authentication.microsoft.title', - descriptionKey: 'admin.authentication.microsoft.description', - enabledSettingKey: 'auth_microsoft_enabled', + provider: 'sso', + titleKey: 'admin.authentication.sso.title', + descriptionKey: 'admin.authentication.sso.description', + enabledSettingKey: 'auth_sso_enabled', + sso: true, }, ] -const DEFAULT_PROVIDER_ORDER = ['discord', 'google', 'github', 'microsoft'] +const DEFAULT_PROVIDER_ORDER = ['discord', 'google', 'github', 'microsoft', 'sso'] function sortProviders(providers: OAuthProviderDef[], order: string[]): OAuthProviderDef[] { const orderMap = new Map(order.map((p, i) => [p, i])) @@ -112,6 +126,7 @@ function OAuthProviderCard({ titleKey, descriptionKey, enabledSettingKey, + sso, enabled, onToggleEnabled, isFirst, @@ -123,6 +138,7 @@ function OAuthProviderCard({ titleKey: string descriptionKey: string enabledSettingKey: string + sso: boolean enabled: boolean onToggleEnabled: (key: string, value: boolean) => void isFirst: boolean @@ -139,25 +155,28 @@ function OAuthProviderCard({ const [saveError, setSaveError] = useState('') const [secretTouched, setSecretTouched] = useState(false) - const cfg = localConfig ?? savedConfig ?? emptyConfig - const hasExistingConfig = !!(savedConfig && savedConfig.client_id) + const cfg = localConfig ?? savedConfig ?? (sso ? emptySSOConfig : emptyConfig) + // A custom SSO provider is unusable until its issuer can be discovered, so + // an issuer-less config counts as unconfigured even when credentials exist. + const hasExistingConfig = !!(savedConfig && savedConfig.client_id && (!sso || savedConfig.issuer)) const updateField = (field: K, value: OAuthProviderConfig[K]) => { - setLocalConfig((prev) => ({ ...(prev ?? savedConfig ?? emptyConfig), [field]: value })) + setLocalConfig((prev) => ({ ...(prev ?? savedConfig ?? (sso ? emptySSOConfig : emptyConfig)), [field]: value })) setSaved(false) setSaveError('') } const isDirty = localConfig !== null || secretTouched - const isFormComplete = () => { - return ( - cfg.client_id.trim() !== '' && - (cfg.client_secret !== '' || (hasExistingConfig && savedConfig?.client_secret === PASSWORD_MASK && !secretTouched)) - ) - } + const hasCredentials = + cfg.client_id.trim() !== '' && + (cfg.client_secret !== '' || + (hasExistingConfig && savedConfig?.client_secret === PASSWORD_MASK && !secretTouched)) - const canSave = isDirty && isFormComplete() + const canSave = + isDirty && + hasCredentials && + (!sso || (cfg.issuer ?? '').trim() !== '') const handleSave = () => { setSaved(false) @@ -174,7 +193,7 @@ function OAuthProviderCard({ setLocalConfig(null) setSecretTouched(false) }, - onError: () => setSaveError(t('admin.authentication.oauth.saveError')), + onError: (err) => setSaveError(getOAuthConfigError(err, t)), }) } @@ -255,6 +274,80 @@ function OAuthProviderCard({ }} /> + + {sso && ( + <> +
+
+ updateField('issuer', e.target.value)} + /> +

+ {t('admin.authentication.sso.issuerHelp')} +

+
+
+ { + const scopes = e.target.value + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0) + updateField('scopes', scopes) + }} + /> +

+ {t('admin.authentication.sso.scopesHelp')} +

+
+
+ updateField('button_label', e.target.value)} + maxLength={40} + /> +

+ {t('admin.authentication.sso.buttonLabelHelp')} +

+
+
+
+ +

+ {t('admin.authentication.sso.disablePKCEHelp')} +

+
+ updateField('disable_pkce', val)} + /> +
+
+
+ +

+ {t('admin.authentication.sso.requireVerifiedEmailHelp')} +

+
+ updateField('require_verified_email', val)} + /> +
+ + )} ) } @@ -301,8 +394,11 @@ export function SystemAuthenticationPage() { auth_google_enabled: settings.auth_google_enabled === true, auth_github_enabled: settings.auth_github_enabled === true, auth_microsoft_enabled: settings.auth_microsoft_enabled === true, + auth_sso_enabled: settings.auth_sso_enabled === true, } + const ssoAutoProvisionEnabled = settings.sso_auto_provision_enabled === true + const handleReorder = (index: number, direction: 'up' | 'down') => { const currentOrder = sortedProviders.map((p) => p.provider) const swapIdx = direction === 'up' ? index - 1 : index + 1 @@ -384,6 +480,7 @@ export function SystemAuthenticationPage() { titleKey={def.titleKey} descriptionKey={def.descriptionKey} enabledSettingKey={def.enabledSettingKey} + sso={def.sso ?? false} enabled={enabledMap[def.enabledSettingKey]} onToggleEnabled={handleToggle} isFirst={idx === 0} @@ -392,6 +489,24 @@ export function SystemAuthenticationPage() { onMoveDown={() => handleReorder(idx, 'down')} /> ))} + + {/* SSO Auto-provision setting */} +
+
+
+

+ {t('admin.authentication.sso.autoProvision')} +

+

+ {t('admin.authentication.sso.autoProvisionHelp')} +

+
+ handleToggle('sso_auto_provision_enabled', val)} + /> +
+
) } diff --git a/web/src/utils/oauthConfigError.ts b/web/src/utils/oauthConfigError.ts new file mode 100644 index 00000000..c0fdc070 --- /dev/null +++ b/web/src/utils/oauthConfigError.ts @@ -0,0 +1,42 @@ +import type { TFunction } from 'i18next' +import { isAxiosError } from 'axios' + +interface OAuthErrorBody { + error?: { + code?: string + error_key?: string + message?: string + } +} + +/** + * Localise a failed `PUT /admin/settings/oauth_config/{provider}`. + * + * The API rejects invalid configs as + * `{ code: 'VALIDATION_ERROR', message: 'validation error: issuer must use https' }` + * — the sentinel prefix followed by the field-level detail. The detail is + * English server text, so we translate the wrapper and append it only when no + * dedicated `errors.` entry exists, otherwise the raw message would + * leak untranslated server internals into the admin UI. + */ +export function getOAuthConfigError(err: unknown, t: TFunction): string { + const fallback = t('admin.authentication.oauth.saveError') + if (!isAxiosError(err)) return fallback + + const body = err.response?.data?.error + if (!body) return fallback + + if (body.error_key) { + const keyed = t(`errors.${body.error_key}`) + if (keyed !== `errors.${body.error_key}`) return keyed + } + + const message = body.message?.trim() + if (!message) return fallback + + if (body.code === 'VALIDATION_ERROR') { + const detail = message.replace(/^validation error:\s*/, '') + if (detail) return t('admin.authentication.oauth.errorValidation', { detail }) + } + return fallback +} From 8a21832bd254c4f6e7ea27fe23d8c90f79c8f911 Mon Sep 17 00:00:00 2001 From: SimonCHEN Date: Sun, 30 Aug 2026 10:12:12 +0800 Subject: [PATCH 02/16] UI/UX improvements: unify visual conventions and improve dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PageHeader, EmptyState, LoadingState reusable components - Optimize core pages (ProjectList, WorkItemList, etc.) - Unify dark mode border colors (gray-700 → gray-600) - Replace unnecessary shadows with borders - Standardize font hierarchy and spacing - Improve Chinese typography (line-height, font-size) --- web/src/components/AppShell.tsx | 10 +++--- web/src/components/AppSidebar.tsx | 8 ++--- web/src/components/CommandPalette.tsx | 10 +++--- web/src/components/EscalationListModal.tsx | 4 +-- web/src/components/KeyboardShortcutsModal.tsx | 2 +- web/src/components/OncallCalendar.tsx | 4 +-- web/src/components/OncallTab.tsx | 10 +++--- web/src/components/PreferencesSidebar.tsx | 2 +- web/src/components/SLAConfigModal.tsx | 2 +- web/src/components/SystemSettingsSidebar.tsx | 2 +- web/src/components/UserSearchInput.tsx | 2 +- .../components/stats/StatsTimelineChart.tsx | 2 +- web/src/components/ui/DataTable.tsx | 4 +-- web/src/components/ui/EmptyState.tsx | 28 ++++++++++++++++ .../components/ui/ExpandableConfigCard.tsx | 4 +-- web/src/components/ui/LoadingState.tsx | 18 ++++++++++ web/src/components/ui/MentionSearchModal.tsx | 6 ++-- web/src/components/ui/Modal.tsx | 2 +- web/src/components/ui/MultiSelect.tsx | 2 +- web/src/components/ui/PageHeader.tsx | 29 ++++++++++++++++ web/src/components/ui/RefreshButton.tsx | 2 +- web/src/components/ui/Tabs.tsx | 2 +- web/src/components/ui/TimezoneSelect.tsx | 2 +- .../components/workitems/ActivityTimeline.tsx | 12 +++---- .../components/workitems/AttachmentList.tsx | 4 +-- web/src/components/workitems/BoardView.tsx | 4 +-- web/src/components/workitems/CommentList.tsx | 6 ++-- .../components/workitems/DetailSidebar.tsx | 6 ++-- .../components/workitems/DiffPreviewModal.tsx | 10 +++--- .../components/workitems/FilePreviewModal.tsx | 2 +- web/src/components/workitems/RelationList.tsx | 2 +- .../components/workitems/SaveSearchModal.tsx | 2 +- .../workitems/SavedSearchSelector.tsx | 6 ++-- .../components/workitems/TimeEntryList.tsx | 4 +-- web/src/components/workitems/WatcherList.tsx | 4 +-- web/src/components/workitems/WorkItemForm.tsx | 4 +-- .../workitems/WorkItemMobileCard.tsx | 2 +- web/src/pages/APIKeysPage.tsx | 4 +-- web/src/pages/AppearancePage.tsx | 10 +++--- web/src/pages/AuthenticationPage.tsx | 2 +- web/src/pages/CliAuthorizePage.tsx | 2 +- web/src/pages/DirectoryUsersTab.tsx | 14 ++++---- web/src/pages/GeneralPage.tsx | 2 +- web/src/pages/InboxPage.tsx | 8 ++--- web/src/pages/MilestoneDashboardPage.tsx | 12 +++---- web/src/pages/MilestonesPage.tsx | 4 +-- web/src/pages/NamespaceSettingsPage.tsx | 6 ++-- web/src/pages/NotificationsPage.tsx | 6 ++-- web/src/pages/PortalTicketDetailPage.tsx | 8 ++--- web/src/pages/PortalTicketListPage.tsx | 2 +- web/src/pages/ProjectListPage.tsx | 33 +++++++++++-------- web/src/pages/ProjectOverviewPage.tsx | 6 ++-- web/src/pages/ProjectSettingsPage.tsx | 14 ++++---- web/src/pages/ProjectWorkflowsPage.tsx | 14 ++++---- web/src/pages/QueueSettingsPage.tsx | 4 +-- web/src/pages/QueueWorkItemsPage.tsx | 4 +-- web/src/pages/QueuesPage.tsx | 2 +- web/src/pages/SystemAPIKeysPage.tsx | 6 ++-- web/src/pages/SystemAuthenticationPage.tsx | 16 ++++----- web/src/pages/SystemDirectoryPage.tsx | 12 +++---- web/src/pages/SystemFeaturesPage.tsx | 14 +++----- web/src/pages/SystemGeneralPage.tsx | 10 ++---- web/src/pages/SystemIntegrationsPage.tsx | 10 ++---- web/src/pages/SystemWorkflowsPage.tsx | 6 ++-- web/src/pages/TeamDetailPage.tsx | 2 +- web/src/pages/TeamsPage.tsx | 2 +- web/src/pages/WatchlistPage.tsx | 2 +- web/src/pages/WorkItemDetailPage.tsx | 2 +- web/src/pages/WorkItemListPage.tsx | 13 ++++---- 69 files changed, 271 insertions(+), 206 deletions(-) create mode 100644 web/src/components/ui/EmptyState.tsx create mode 100644 web/src/components/ui/LoadingState.tsx create mode 100644 web/src/components/ui/PageHeader.tsx diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 3d77b14f..d6e53bb0 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -157,7 +157,7 @@ export function AppShell() { return (
-