From a2059e1ed327df884fbf4f62d4958e98245e49f5 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:29:16 +0700 Subject: [PATCH] feat(auth): portal redirect-only login, surface-aware OIDC error bounces, password-ops guard - /support/login auto-redirects to OIDC when it is the only enabled transport, mirroring the staff login; suppressed on an ?oidcError bounce and the break-glass ?direct=1 form. Portal-origin logins provision customer-type users, so the redirect can never mint staff accounts - both auto-redirects wait for the session probe (ready && !session), so an already-signed-in visitor is routed to their destination instead of being shipped to the IdP for a needless re-authentication - failed OIDC round-trips bounce back to the surface that started them: /support/login?oidcError= for /support/* targets, /dashboard/login otherwise (recovered from the signed state on callback failures), so a portal customer is never stranded on the staff login page - both login pages render a persistent DOS ID failure notice with a retry action instead of a transient toast - while password login is disabled, password mutations (/api/dashboard/user/reset_password, /api/dashboard/change_password) reject any target that is not on the break-glass allowlist, so SSO-only deployments never accumulate dormant local passwords - tests: error-redirect surface routing, password-ops guard matrix --- docs/ARCHITECTURE.md | 2 +- internal/handlers/api/auth_handler.go | 23 ++- .../api/auth_handler_internal_test.go | 41 +++++ internal/handlers/dashboard/user_handler.go | 5 +- internal/pkg/i18nx/locales/en-US.yml | 1 + internal/pkg/i18nx/locales/zh-CN.yml | 1 + internal/services/oidc_login_service.go | 21 ++- internal/services/oidc_login_service_test.go | 2 +- internal/services/user_service.go | 18 +- .../user_service_password_guard_test.go | 160 ++++++++++++++++++ .../support/login/_components/login-page.tsx | 45 ++++- web/components/login-form.tsx | 37 ++-- web/messages/en-US.json | 1 + web/messages/vi-VN.json | 1 + web/messages/zh-CN.json | 1 + 15 files changed, 322 insertions(+), 37 deletions(-) create mode 100644 internal/handlers/api/auth_handler_internal_test.go create mode 100644 internal/services/user_service_password_guard_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8a1e97a7..69e1dfb1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -206,7 +206,7 @@ Application roles are derived from claims, never defaulted to admin: ##### SSO-Only Mode & Break-Glass -When `passwordLoginEnabled: false` and OIDC is the only staff transport, `/dashboard/login` auto-redirects to the provider. The redirect is suppressed when the provider bounced back with `?oidcError=` (prevents a loop), in WxWork-only environments, or via `?direct=1` with the break-glass allowlist configured (`auth.breakGlassEmails`, env `BREAK_GLASS_LOGIN_EMAILS`): allowlisted admin emails keep password login reachable for IdP outages. In this mode the default-password bootstrap admin (`admin` / `ChangeMe123!`) is not seeded, and its emailless account can never pass the email-only allowlist. The support portal is never auto-redirected (it serves customer accounts); note that customer portal sign-in shares the staff password endpoint, so in SSO-only mode only break-glass principals can password-sign in there. +When `passwordLoginEnabled: false` and OIDC is the only staff transport, `/dashboard/login` auto-redirects to the provider. The redirect is suppressed when the provider bounced back with `?oidcError=` (prevents a loop), in WxWork-only environments, or via `?direct=1` with the break-glass allowlist configured (`auth.breakGlassEmails`, env `BREAK_GLASS_LOGIN_EMAILS`): allowlisted admin emails keep password login reachable for IdP outages. In this mode the default-password bootstrap admin (`admin` / `ChangeMe123!`) is not seeded, and its emailless account can never pass the email-only allowlist. The support portal follows the same rule: with OIDC as its only enabled transport, `/support/login` auto-redirects as well, and portal OIDC logins provision customer-type users (see the next section), so the redirect can never mint staff accounts. A failed round-trip bounces back to the surface that started it, `/support/login?oidcError=` for portal targets and `/dashboard/login?oidcError=` otherwise, so the error guard cannot loop across surfaces. The break-glass `?direct=1` form stays reachable on both surfaces, and while password login is disabled the password-mutation endpoints (`/api/dashboard/user/reset_password`, `/api/dashboard/change_password`) reject any target that is not on the break-glass allowlist, so an SSO-only deployment never accumulates dormant local passwords that would come alive if the switch were flipped back. ##### Portal OIDC Login Provisions Customers diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go index c81029e7..98473844 100644 --- a/internal/handlers/api/auth_handler.go +++ b/internal/handlers/api/auth_handler.go @@ -100,9 +100,10 @@ func WxWorkExchange(ctx *gin.Context) { } func OIDCLogin(ctx *gin.Context) { - loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(ctx.Query("next")) + next := ctx.Query("next") + loginURL, err := services.OIDCLoginService.BuildOIDCLoginURL(next) if err != nil { - ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error()))) + ctx.Redirect(http.StatusFound, oidcLoginErrorRedirect(next, loginErrorMessage(err.Error()))) return } ctx.Redirect(http.StatusFound, loginURL) @@ -116,7 +117,8 @@ func OIDCCallback(ctx *gin.Context) { if desc != "" { errMsg += ": " + desc } - ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(errMsg)) + ctx.Redirect(http.StatusFound, oidcLoginErrorRedirect( + services.OIDCLoginService.NextFromState(ctx.Query("state")), errMsg)) return } @@ -137,7 +139,8 @@ func OIDCCallback(ctx *gin.Context) { ) if err != nil { slog.Error("oidc callback login failed", "error", err) - ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error()))) + ctx.Redirect(http.StatusFound, oidcLoginErrorRedirect( + services.OIDCLoginService.NextFromState(ctx.Query("state")), loginErrorMessage(err.Error()))) return } ctx.Redirect(http.StatusFound, "/dashboard/login/oidc/callback?ticket="+url.QueryEscape(ticket)+"&next="+url.QueryEscape(next)) @@ -217,6 +220,18 @@ func wxWorkErrorMessage(message string) string { return loginErrorMessage(message) } +// oidcLoginErrorRedirect routes a failed OIDC round-trip back to the login +// surface that started it: the support portal for /support/* targets, the +// staff dashboard otherwise. This keeps the IdP error bounce and the +// auto-redirect loop guard on the same page. +func oidcLoginErrorRedirect(next, message string) string { + loginPath := "/dashboard/login" + if services.IsSupportPortalNext(next) { + loginPath = "/support/login" + } + return loginPath + "?oidcError=" + url.QueryEscape(message) +} + func loginErrorMessage(message string) string { if idx := strings.Index(message, ": "); idx >= 0 { message = message[idx+2:] diff --git a/internal/handlers/api/auth_handler_internal_test.go b/internal/handlers/api/auth_handler_internal_test.go new file mode 100644 index 00000000..c0cd1613 --- /dev/null +++ b/internal/handlers/api/auth_handler_internal_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/url" + "testing" +) + +// A failed OIDC round-trip must bounce back to the login surface that started +// it: the support portal for /support/* targets, the staff dashboard +// otherwise. Routing a portal failure to the staff page would strand the +// customer there, and retrying from that page would provision them as an +// employee instead of a customer. +func TestOIDCLoginErrorRedirectTargetsOriginSurface(t *testing.T) { + const plainMessage = "sign-in failed" + cases := []struct { + name string + next string + message string + portal bool + }{ + {name: "staff target", next: "/dashboard", message: plainMessage}, + {name: "empty next falls back to staff", next: "", message: plainMessage}, + {name: "portal path", next: "/support/community", message: plainMessage, portal: true}, + {name: "portal root", next: "/support", message: plainMessage, portal: true}, + {name: "padded portal path", next: " /support/tickets", message: plainMessage, portal: true}, + {name: "portal lookalike is not portal", next: "/supportevil", message: plainMessage}, + {name: "untrusted next is never reflected", next: "https://evil.example", message: plainMessage}, + {name: "message is escaped", next: "/support", message: "boom & breakdown", portal: true}, + } + + for _, tc := range cases { + loginPath := "/dashboard/login" + if tc.portal { + loginPath = "/support/login" + } + expected := loginPath + "?oidcError=" + url.QueryEscape(tc.message) + if got := oidcLoginErrorRedirect(tc.next, tc.message); got != expected { + t.Fatalf("%s: oidcLoginErrorRedirect(%q) = %q, want %q", tc.name, tc.next, got, expected) + } + } +} diff --git a/internal/handlers/dashboard/user_handler.go b/internal/handlers/dashboard/user_handler.go index d0127d47..f71b98e0 100644 --- a/internal/handlers/dashboard/user_handler.go +++ b/internal/handlers/dashboard/user_handler.go @@ -2,6 +2,7 @@ package dashboard import ( "agent-desk/internal/builders" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" @@ -171,7 +172,7 @@ func UserPostReset_password(ctx *gin.Context) { httpx.WriteJSON(ctx, err) return } - password, err := services.UserService.ResetPassword(req.UserID, operator) + password, err := services.UserService.ResetPassword(req.UserID, operator, config.Current().Auth) if err != nil { httpx.WriteJSON(ctx, err) return @@ -200,7 +201,7 @@ func UserPostChange_password(ctx *gin.Context) { httpx.WriteJSON(ctx, err) return } - if err := services.UserService.ChangeOwnPassword(req.Password, principal); err != nil { + if err := services.UserService.ChangeOwnPassword(req.Password, principal, config.Current().Auth); err != nil { httpx.WriteJSON(ctx, err) return } diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index 1ba2cbe5..5d339d4e 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -231,6 +231,7 @@ error.e0231: "No matching WeCom channel was found." error.e0232: "No schedules were generated." error.auth.expired: "Your session has expired. Please sign in again." error.auth.passwordLoginDisabled: "Username and password login is disabled. Please use SSO to sign in." +error.auth.passwordChangeDisabled: "Password sign-in is disabled, so only break-glass accounts may keep a local password." error.auth.invalidSignature: "Invalid request signature or secret token." error.e0234: "No available AI configuration is configured." error.e0235: "No available embedding model is configured." diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index 699be781..089f4651 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -231,6 +231,7 @@ error.e0231: "未找到匹配的企业微信接入渠道" error.e0232: "未生成任何排班" error.auth.expired: "未登录或登录已过期" error.auth.passwordLoginDisabled: "账号密码登录已禁用,请使用第三方登录。" +error.auth.passwordChangeDisabled: "账号密码登录已禁用,仅 break-glass 应急账号可保留本地密码。" error.auth.invalidSignature: "请求签名或凭证 Token 无效" error.e0234: "未配置可用的 AI 配置" error.e0235: "未配置可用的 Embedding 模型" diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go index bd75e7dd..31684fd8 100644 --- a/internal/services/oidc_login_service.go +++ b/internal/services/oidc_login_service.go @@ -47,7 +47,7 @@ func (s *oidcLoginService) LoginByOIDC(ctx context.Context, code, state string, if err != nil { return "", "", err } - loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent, isSupportPortalNext(next)) + loginResp, err := s.loginWithOIDCProfile(profile, authCfg, clientIP, userAgent, IsSupportPortalNext(next)) if err != nil { return "", "", err } @@ -62,13 +62,26 @@ func (s *oidcLoginService) ExchangeOIDCLoginTicket(ticket string) (*response.Log return oidcclient.ConsumeLoginTicket(ticket) } -// isSupportPortalNext reports whether the OIDC round-trip started from the +// NextFromState recovers the sanitized redirect target carried by a signed +// OIDC state. Handlers use it after a failed round-trip so an IdP error +// bounces back to the login surface that started the flow. It returns an +// empty string when the state is missing, expired, or fails verification. +func (s *oidcLoginService) NextFromState(state string) string { + next, _, err := oidcclient.ParseState(state) + if err != nil { + return "" + } + return next +} + +// IsSupportPortalNext reports whether the OIDC round-trip started from the // customer support portal rather than the staff dashboard. The portal always // targets /support/* paths (see getSupportLoginDestination), so the signed // state's next path identifies the entry surface without extra parameters. // Portal-origin logins provision customer-type users without staff roles; -// only the staff surface (/dashboard/login) grants staff access. -func isSupportPortalNext(next string) bool { +// only the staff surface (/dashboard/login) grants staff access. Handlers +// also use it to route failed round-trips back to the originating surface. +func IsSupportPortalNext(next string) bool { next = strings.TrimSpace(next) return next == "/support" || strings.HasPrefix(next, "/support/") } diff --git a/internal/services/oidc_login_service_test.go b/internal/services/oidc_login_service_test.go index 9030c50c..60398fe1 100644 --- a/internal/services/oidc_login_service_test.go +++ b/internal/services/oidc_login_service_test.go @@ -284,7 +284,7 @@ func TestIsSupportPortalNext(t *testing.T) { } for _, tc := range cases { - if got := isSupportPortalNext(tc.next); got != tc.expected { + if got := IsSupportPortalNext(tc.next); got != tc.expected { t.Fatalf("isSupportPortalNext(%q) = %v, want %v", tc.next, got, tc.expected) } } diff --git a/internal/services/user_service.go b/internal/services/user_service.go index 86c3e4d4..446e27e7 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" @@ -220,22 +221,22 @@ func (s *userService) UpdateStatus(id int64, status int, operator *dto.AuthPrinc return nil } -func (s *userService) ResetPassword(userID int64, operator *dto.AuthPrincipal) (string, error) { +func (s *userService) ResetPassword(userID int64, operator *dto.AuthPrincipal, authCfg config.AuthConfig) (string, error) { password, err := utils.GenerateRandomPassword(12) if err != nil { return "", err } - if err = s.changePassword(userID, password, operator); err != nil { + if err = s.changePassword(userID, password, operator, authCfg); err != nil { return "", err } return password, nil } -func (s *userService) ChangeOwnPassword(password string, operator *dto.AuthPrincipal) error { +func (s *userService) ChangeOwnPassword(password string, operator *dto.AuthPrincipal, authCfg config.AuthConfig) error { if operator == nil || operator.UserID <= 0 { return errorsx.UnauthorizedI18n("error.auth.expired") } - return s.changePassword(operator.UserID, password, operator) + return s.changePassword(operator.UserID, password, operator, authCfg) } func (s *userService) AssignRoles(userID int64, roleIDs []int64, operator *dto.AuthPrincipal) error { @@ -282,11 +283,18 @@ func (s *userService) replaceUserRolesDB(db *gorm.DB, userID int64, roleIDs []in return nil } -func (s *userService) changePassword(userID int64, password string, operator *dto.AuthPrincipal) error { +func (s *userService) changePassword(userID int64, password string, operator *dto.AuthPrincipal, authCfg config.AuthConfig) error { user := s.Get(userID) if user == nil || user.DeletedAt != nil { return errorsx.InvalidParamI18n("error.e0255") } + // SSO-only invariant: a local password is only ever usable by break-glass + // principals, so never set one for anyone else. Otherwise password resets + // would plant dormant credentials that come alive the moment password + // login is re-enabled. + if !authCfg.IsPasswordLoginEnabled() && !isBreakGlassUser(authCfg, user) { + return errorsx.ForbiddenI18n("error.auth.passwordChangeDisabled") + } if operator != nil && operator.UserID != userID && !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin)) { var superAdminCount int64 if err := sqls.DB().Model(&models.UserRole{}). diff --git a/internal/services/user_service_password_guard_test.go b/internal/services/user_service_password_guard_test.go new file mode 100644 index 00000000..d1b64a81 --- /dev/null +++ b/internal/services/user_service_password_guard_test.go @@ -0,0 +1,160 @@ +package services + +import ( + "testing" + "time" + + "agent-desk/internal/models" + "agent-desk/internal/pkg/config" + "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/enums" + "agent-desk/internal/pkg/errorsx" + + "gorm.io/gorm" +) + +func createPasswordGuardTestUser(t *testing.T, db *gorm.DB, username string, email *string) *models.User { + t.Helper() + now := time.Now() + user := &models.User{ + Username: username, + Nickname: username, + Email: email, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + UpdatedAt: now, + }, + } + if err := db.Create(user).Error; err != nil { + t.Fatalf("create password guard test user: %v", err) + } + return user +} + +func ssoOnlyAuthConfig(allowlist string) config.AuthConfig { + disabled := false + return config.AuthConfig{ + PasswordLoginEnabled: &disabled, + BreakGlassEmails: allowlist, + TokenTTLHours: 2, + } +} + +// In SSO-only mode a local password exists solely for break-glass principals. +// Everyone else must be refused, otherwise password resets plant dormant +// credentials that come alive the moment password login is re-enabled. +func TestChangeOwnPasswordRejectedInSSOOnlyModeForNonBreakGlassUser(t *testing.T) { + db := setupAuthServiceTestDB(t) + email := "staff@dos.ai" + user := createPasswordGuardTestUser(t, db, "staffuser", &email) + + err := UserService.ChangeOwnPassword("fresh-password", &dto.AuthPrincipal{ + UserID: user.ID, + Username: user.Username, + }, ssoOnlyAuthConfig("joy@dos.ai")) + if !hasCode(err, errorsx.CodeAuthForbidden) { + t.Fatalf("expected forbidden error for non-break-glass user, got %v", err) + } + + var reloaded models.User + if err := db.Take(&reloaded, "id = ?", user.ID).Error; err != nil { + t.Fatalf("reload user: %v", err) + } + if reloaded.Password != user.Password { + t.Fatal("password must stay unchanged when the guard rejects the change") + } +} + +// The emailless seeded bootstrap admin can never satisfy the email-only +// allowlist, so its password can no longer be rotated in SSO-only mode. +func TestChangeOwnPasswordRejectedInSSOOnlyModeForEmaillessUser(t *testing.T) { + db := setupAuthServiceTestDB(t) + user := createPasswordGuardTestUser(t, db, "admin", nil) + + err := UserService.ChangeOwnPassword("fresh-password", &dto.AuthPrincipal{ + UserID: user.ID, + Username: user.Username, + }, ssoOnlyAuthConfig("joy@dos.ai")) + if !hasCode(err, errorsx.CodeAuthForbidden) { + t.Fatalf("expected forbidden error for emailless user, got %v", err) + } +} + +func TestChangeOwnPasswordAllowedForBreakGlassUserInSSOOnlyMode(t *testing.T) { + db := setupAuthServiceTestDB(t) + email := "joy@dos.ai" + user := createPasswordGuardTestUser(t, db, "joy", &email) + + if err := UserService.ChangeOwnPassword("fresh-password", &dto.AuthPrincipal{ + UserID: user.ID, + Username: user.Username, + }, ssoOnlyAuthConfig("joy@dos.ai")); err != nil { + t.Fatalf("allowlisted admin must keep the break-glass password path: %v", err) + } + + var reloaded models.User + if err := db.Take(&reloaded, "id = ?", user.ID).Error; err != nil { + t.Fatalf("reload user: %v", err) + } + if reloaded.Password == user.Password || reloaded.Password == "" { + t.Fatal("password must be updated for the allowlisted break-glass user") + } +} + +// With password login enabled (mixed mode) the guard must not interfere. +func TestChangeOwnPasswordUnaffectedInMixedMode(t *testing.T) { + db := setupAuthServiceTestDB(t) + email := "staff@dos.ai" + user := createPasswordGuardTestUser(t, db, "staffuser", &email) + + if err := UserService.ChangeOwnPassword("fresh-password", &dto.AuthPrincipal{ + UserID: user.ID, + Username: user.Username, + }, config.AuthConfig{TokenTTLHours: 2}); err != nil { + t.Fatalf("mixed mode password change must keep working: %v", err) + } +} + +// The admin-driven reset endpoint goes through the same choke point: in +// SSO-only mode only break-glass targets may receive a local password. +func TestResetPasswordRejectedInSSOOnlyModeForNonBreakGlassTarget(t *testing.T) { + db := setupAuthServiceTestDB(t) + targetEmail := "staff@dos.ai" + target := createPasswordGuardTestUser(t, db, "target", &targetEmail) + operator := createPasswordGuardTestUser(t, db, "operator", nil) + + _, err := UserService.ResetPassword(target.ID, &dto.AuthPrincipal{ + UserID: operator.ID, + Username: operator.Username, + }, ssoOnlyAuthConfig("joy@dos.ai")) + if !hasCode(err, errorsx.CodeAuthForbidden) { + t.Fatalf("expected forbidden error for non-break-glass reset target, got %v", err) + } + + var reloaded models.User + if err := db.Take(&reloaded, "id = ?", target.ID).Error; err != nil { + t.Fatalf("reload target: %v", err) + } + if reloaded.Password != target.Password { + t.Fatal("target password must stay unchanged when the guard rejects the reset") + } +} + +func TestResetPasswordAllowedInSSOOnlyModeForBreakGlassTarget(t *testing.T) { + db := setupAuthServiceTestDB(t) + targetEmail := "joy@dos.ai" + target := createPasswordGuardTestUser(t, db, "target", &targetEmail) + operator := createPasswordGuardTestUser(t, db, "operator", nil) + + password, err := UserService.ResetPassword(target.ID, &dto.AuthPrincipal{ + UserID: operator.ID, + Username: operator.Username, + }, ssoOnlyAuthConfig("joy@dos.ai")) + if err != nil { + t.Fatalf("reset for a break-glass target must succeed: %v", err) + } + if password == "" { + t.Fatal("expected a generated password to be returned") + } +} diff --git a/web/app/(support)/support/login/_components/login-page.tsx b/web/app/(support)/support/login/_components/login-page.tsx index b6c2e4c3..0757c065 100644 --- a/web/app/(support)/support/login/_components/login-page.tsx +++ b/web/app/(support)/support/login/_components/login-page.tsx @@ -41,13 +41,33 @@ export function SupportLoginPage() { const oidcError = searchParams.get("oidcError") const passwordLoginEnabled = publicConfig?.passwordLoginEnabled !== false // Break-glass door: with password login disabled suite-wide, an allowlisted - // admin can still reach the password form via ?direct=1. The portal is - // never auto-redirected: it primarily serves customer accounts. + // admin can still reach the password form via ?direct=1. const isBreakGlassRequested = searchParams.get("direct") === "1" && publicConfig?.breakGlassLoginEnabled === true const showPasswordForm = passwordLoginEnabled || isBreakGlassRequested const providerCount = Number(publicConfig?.wxworkEnabled) + Number(publicConfig?.oidcEnabled) const hasAnyLoginMethod = showPasswordForm || providerCount > 0 + // Redirect-only mode: when OIDC is the only login transport, skip the + // chooser and go straight to the provider, exactly like the staff login. + // Suppressed for the break-glass form, an IdP error bounce (otherwise + // this would loop), and while the session probe is still in flight (an + // already-signed-in customer goes to their destination instead of the + // IdP). Portal OIDC logins provision customer-type users. + const shouldRedirectToOIDC = Boolean( + ready && + !session && + publicConfig && + publicConfig.oidcEnabled && + !publicConfig.passwordLoginEnabled && + !publicConfig.wxworkEnabled && + !isBreakGlassRequested && + !oidcError, + ) + + useEffect(() => { + if (!shouldRedirectToOIDC) return + window.location.href = `/api/auth/oidc_login?next=${encodeURIComponent(nextDestination)}` + }, [shouldRedirectToOIDC, nextDestination]) useEffect(() => { if (ready && session) router.replace(nextDestination) @@ -61,10 +81,6 @@ export function SupportLoginPage() { if (wxworkError) toast.error(wxworkError) }, [wxworkError]) - useEffect(() => { - if (oidcError) toast.error(oidcError) - }, [oidcError]) - useEffect(() => { setIsWxWorkEnv(detectWxWorkEnvironment()) }, []) @@ -128,11 +144,26 @@ export function SupportLoginPage() { {publicConfigError ? ( } title={t("auth.optionsLoadFailed")} description={publicConfigError} destructive /> ) : null} + {publicConfig && shouldRedirectToOIDC ? ( + } title={t("auth.redirectingToOidc")} /> + ) : null} {publicConfig && !hasAnyLoginMethod ? ( } title={t("supportPublic.login.noMethodsTitle")} description={t("supportPublic.login.noMethodsDescription")} /> ) : null} - {publicConfig && hasAnyLoginMethod ? ( + {publicConfig && hasAnyLoginMethod && !shouldRedirectToOIDC ? (
+ {oidcError ? ( +
+
+ + {t("auth.oidcFailed")} +
+

{oidcError}

+ +
+ ) : null} {showPasswordForm ? (
(null) @@ -69,12 +69,6 @@ export function LoginForm({ } }, [wxworkError]) - useEffect(() => { - if (oidcError) { - toast.error(oidcError) - } - }, [oidcError]) - useEffect(() => { setIsWxWorkEnv(detectWxWorkEnvironment()) }, []) @@ -103,10 +97,13 @@ export function LoginForm({ // Redirect-only mode: when OIDC is the only login transport, skip the // chooser and go straight to the provider. Suppressed for the break-glass - // form, an IdP error bounce (otherwise this would loop), and WxWork-only - // environments. + // form, an IdP error bounce (otherwise this would loop), WxWork-only + // environments, and while the session probe is still in flight (an + // already-signed-in visitor goes to their destination instead of the IdP). const shouldRedirectToOIDC = Boolean( - publicConfig && + ready && + !session && + publicConfig && publicConfig.oidcEnabled && !publicConfig.passwordLoginEnabled && !publicConfig.wxworkEnabled && @@ -121,6 +118,10 @@ export function LoginForm({ window.location.href = `/api/auth/oidc_login?next=${encodeURIComponent(redirectPath)}` }, [shouldRedirectToOIDC, redirectPath]) + function startOIDCLogin() { + window.location.href = `/api/auth/oidc_login?next=${encodeURIComponent(redirectPath)}` + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault() if (!showPasswordForm) { @@ -216,6 +217,18 @@ export function LoginForm({ {t("auth.loginDescription", { brand: publicConfig.companyName || t("app.brand") })}

+ {oidcError ? ( +
+
+ + {t("auth.oidcFailed")} +
+

{oidcError}

+ +
+ ) : null} {showPasswordForm ? ( <> @@ -294,9 +307,7 @@ export function LoginForm({ type="button" variant="outline" aria-label={t("auth.oidcSignIn")} - onClick={() => { - window.location.href = `/api/auth/oidc_login?next=${encodeURIComponent(redirectPath)}` - }} + onClick={startOIDCLogin} > {t("auth.oidcSignIn")} diff --git a/web/messages/en-US.json b/web/messages/en-US.json index ee3b5823..d1cf3a05 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -253,6 +253,7 @@ "wxworkSignIn": "Sign in with WeCom", "oidcSignIn": "Sign in with DOS ID", "redirectingToOidc": "Redirecting to DOS ID...", + "retry": "Try again", "noAccount": "Don't have an account?", "signUp": "Sign up", "termsPrefix": "By clicking continue, you agree to our", diff --git a/web/messages/vi-VN.json b/web/messages/vi-VN.json index 078fa719..527baebe 100644 --- a/web/messages/vi-VN.json +++ b/web/messages/vi-VN.json @@ -253,6 +253,7 @@ "wxworkSignIn": "Sign in with WeCom", "oidcSignIn": "Sign in with DOS ID", "redirectingToOidc": "Đang chuyển hướng đến DOS ID...", + "retry": "Thử lại", "noAccount": "Don't have an account?", "signUp": "Sign up", "termsPrefix": "By clicking continue, you agree to our", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 5ac2753f..f5de5ce3 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -253,6 +253,7 @@ "wxworkSignIn": "企业微信登录", "oidcSignIn": "OIDC 登录", "redirectingToOidc": "正在跳转到 DOS ID...", + "retry": "重试", "noAccount": "还没有账号?", "signUp": "注册", "termsPrefix": "点击继续即表示你同意我们的",