fix(auth): require explicit choice for third-party signup
This commit is contained in:
@@ -325,80 +325,18 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if compatEmailUser != nil {
|
if err := h.createLinuxDoOAuthChoicePendingSession(
|
||||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
c,
|
||||||
Intent: "adopt_existing_user_by_email",
|
identityKey,
|
||||||
Identity: identityKey,
|
email,
|
||||||
TargetUserID: &compatEmailUser.ID,
|
email,
|
||||||
ResolvedEmail: compatEmailUser.Email,
|
redirectTo,
|
||||||
RedirectTo: redirectTo,
|
browserSessionKey,
|
||||||
BrowserSessionKey: browserSessionKey,
|
upstreamClaims,
|
||||||
UpstreamIdentityClaims: upstreamClaims,
|
compatEmail,
|
||||||
CompletionResponse: map[string]any{
|
compatEmailUser,
|
||||||
"redirect": redirectTo,
|
h.isForceEmailOnThirdPartySignup(c.Request.Context()),
|
||||||
"step": "bind_login_required",
|
); err != nil {
|
||||||
"email": compatEmailUser.Email,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
redirectToFrontendCallback(c, frontendCallback)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
|
||||||
if err := h.createOAuthEmailRequiredPendingSession(c, identityKey, redirectTo, browserSessionKey, upstreamClaims); err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
redirectToFrontendCallback(c, frontendCallback)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
|
||||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, service.ErrOAuthInvitationRequired) {
|
|
||||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
|
||||||
Intent: "login",
|
|
||||||
Identity: identityKey,
|
|
||||||
ResolvedEmail: email,
|
|
||||||
RedirectTo: redirectTo,
|
|
||||||
BrowserSessionKey: browserSessionKey,
|
|
||||||
UpstreamIdentityClaims: upstreamClaims,
|
|
||||||
CompletionResponse: map[string]any{
|
|
||||||
"error": "invitation_required",
|
|
||||||
"redirect": redirectTo,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
redirectToFrontendCallback(c, frontendCallback)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。
|
|
||||||
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
|
||||||
Intent: "login",
|
|
||||||
Identity: identityKey,
|
|
||||||
TargetUserID: &user.ID,
|
|
||||||
ResolvedEmail: email,
|
|
||||||
RedirectTo: redirectTo,
|
|
||||||
BrowserSessionKey: browserSessionKey,
|
|
||||||
UpstreamIdentityClaims: upstreamClaims,
|
|
||||||
CompletionResponse: map[string]any{
|
|
||||||
"access_token": tokenPair.AccessToken,
|
|
||||||
"refresh_token": tokenPair.RefreshToken,
|
|
||||||
"expires_in": tokenPair.ExpiresIn,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"redirect": redirectTo,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -431,6 +369,62 @@ func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email stri
|
|||||||
return userEntity, nil
|
return userEntity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
|
||||||
|
c *gin.Context,
|
||||||
|
identity service.PendingAuthIdentityKey,
|
||||||
|
suggestedEmail string,
|
||||||
|
resolvedEmail string,
|
||||||
|
redirectTo string,
|
||||||
|
browserSessionKey string,
|
||||||
|
upstreamClaims map[string]any,
|
||||||
|
compatEmail string,
|
||||||
|
compatEmailUser *dbent.User,
|
||||||
|
forceEmailOnSignup bool,
|
||||||
|
) error {
|
||||||
|
suggestionEmail := strings.TrimSpace(suggestedEmail)
|
||||||
|
canonicalEmail := strings.TrimSpace(resolvedEmail)
|
||||||
|
if suggestionEmail == "" {
|
||||||
|
suggestionEmail = canonicalEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
completionResponse := map[string]any{
|
||||||
|
"step": oauthPendingChoiceStep,
|
||||||
|
"adoption_required": true,
|
||||||
|
"redirect": strings.TrimSpace(redirectTo),
|
||||||
|
"email": suggestionEmail,
|
||||||
|
"resolved_email": canonicalEmail,
|
||||||
|
"existing_account_email": "",
|
||||||
|
"existing_account_bindable": false,
|
||||||
|
"create_account_allowed": true,
|
||||||
|
"force_email_on_signup": forceEmailOnSignup,
|
||||||
|
"choice_reason": "third_party_signup",
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(compatEmail) != "" {
|
||||||
|
completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
|
||||||
|
}
|
||||||
|
resolvedChoiceEmail := suggestionEmail
|
||||||
|
if compatEmailUser != nil {
|
||||||
|
completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
|
||||||
|
completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
|
||||||
|
completionResponse["existing_account_bindable"] = true
|
||||||
|
completionResponse["choice_reason"] = "compat_email_match"
|
||||||
|
resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
|
||||||
|
}
|
||||||
|
if forceEmailOnSignup && compatEmailUser == nil {
|
||||||
|
completionResponse["choice_reason"] = "force_email_on_signup"
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||||
|
Intent: oauthIntentLogin,
|
||||||
|
Identity: identity,
|
||||||
|
ResolvedEmail: resolvedChoiceEmail,
|
||||||
|
RedirectTo: redirectTo,
|
||||||
|
BrowserSessionKey: browserSessionKey,
|
||||||
|
UpstreamIdentityClaims: upstreamClaims,
|
||||||
|
CompletionResponse: completionResponse,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type completeLinuxDoOAuthRequest struct {
|
type completeLinuxDoOAuthRequest struct {
|
||||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const (
|
|||||||
oauthPendingSessionCookiePath = "/api/v1/auth/oauth"
|
oauthPendingSessionCookiePath = "/api/v1/auth/oauth"
|
||||||
oauthPendingSessionCookieName = "oauth_pending_session"
|
oauthPendingSessionCookieName = "oauth_pending_session"
|
||||||
oauthPendingCookieMaxAgeSec = 10 * 60
|
oauthPendingCookieMaxAgeSec = 10 * 60
|
||||||
|
oauthPendingChoiceStep = "choose_account_action_required"
|
||||||
|
|
||||||
oauthCompletionResponseKey = "completion_response"
|
oauthCompletionResponseKey = "completion_response"
|
||||||
)
|
)
|
||||||
@@ -431,8 +432,9 @@ func (h *AuthHandler) createOAuthEmailRequiredPendingSession(
|
|||||||
BrowserSessionKey: browserSessionKey,
|
BrowserSessionKey: browserSessionKey,
|
||||||
UpstreamIdentityClaims: upstreamClaims,
|
UpstreamIdentityClaims: upstreamClaims,
|
||||||
CompletionResponse: map[string]any{
|
CompletionResponse: map[string]any{
|
||||||
"redirect": redirectTo,
|
"redirect": strings.TrimSpace(redirectTo),
|
||||||
"step": "email_required",
|
"step": oauthPendingChoiceStep,
|
||||||
|
"adoption_required": true,
|
||||||
"force_email_on_signup": true,
|
"force_email_on_signup": true,
|
||||||
"email_binding_required": true,
|
"email_binding_required": true,
|
||||||
"existing_account_bindable": true,
|
"existing_account_bindable": true,
|
||||||
@@ -492,7 +494,7 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
|
|||||||
|
|
||||||
email := strings.TrimSpace(strings.ToLower(req.Email))
|
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
if existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email); err == nil && existingUser != nil {
|
if existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email); err == nil && existingUser != nil {
|
||||||
session, err = h.transitionPendingOAuthAccountToBindLogin(c, client, session, email, oauthAdoptionDecisionRequest{})
|
session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
@@ -1206,12 +1208,13 @@ func readPendingOAuthBrowserSession(c *gin.Context, h *AuthHandler) (*service.Au
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildPendingOAuthSessionStatusPayload(session *dbent.PendingAuthSession) gin.H {
|
func buildPendingOAuthSessionStatusPayload(session *dbent.PendingAuthSession) gin.H {
|
||||||
|
completionResponse := normalizePendingOAuthCompletionResponse(mergePendingCompletionResponse(session, nil))
|
||||||
payload := gin.H{
|
payload := gin.H{
|
||||||
"auth_result": "pending_session",
|
"auth_result": "pending_session",
|
||||||
"provider": strings.TrimSpace(session.ProviderType),
|
"provider": strings.TrimSpace(session.ProviderType),
|
||||||
"intent": strings.TrimSpace(session.Intent),
|
"intent": strings.TrimSpace(session.Intent),
|
||||||
}
|
}
|
||||||
for key, value := range mergePendingCompletionResponse(session, nil) {
|
for key, value := range completionResponse {
|
||||||
payload[key] = value
|
payload[key] = value
|
||||||
}
|
}
|
||||||
if email := strings.TrimSpace(session.ResolvedEmail); email != "" {
|
if email := strings.TrimSpace(session.ResolvedEmail); email != "" {
|
||||||
@@ -1220,38 +1223,58 @@ func buildPendingOAuthSessionStatusPayload(session *dbent.PendingAuthSession) gi
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) transitionPendingOAuthAccountToBindLogin(
|
func normalizePendingOAuthCompletionResponse(payload map[string]any) map[string]any {
|
||||||
|
normalized := clonePendingMap(payload)
|
||||||
|
step := strings.ToLower(strings.TrimSpace(pendingSessionStringValue(normalized, "step")))
|
||||||
|
switch step {
|
||||||
|
case "choice", "choose_account_action", "choose_account", "choose", "email_required", "bind_login_required":
|
||||||
|
normalized["step"] = oauthPendingChoiceStep
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(normalized, "step")), oauthPendingChoiceStep) {
|
||||||
|
normalized["adoption_required"] = true
|
||||||
|
}
|
||||||
|
if _, exists := normalized["adoption_required"]; !exists {
|
||||||
|
if _, hasChoiceFields := normalized["email_binding_required"]; hasChoiceFields {
|
||||||
|
normalized["adoption_required"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingOAuthChoiceCompletionResponse(session *dbent.PendingAuthSession, email string) map[string]any {
|
||||||
|
response := mergePendingCompletionResponse(session, map[string]any{
|
||||||
|
"step": oauthPendingChoiceStep,
|
||||||
|
"adoption_required": true,
|
||||||
|
"force_email_on_signup": true,
|
||||||
|
"email_binding_required": true,
|
||||||
|
"existing_account_bindable": true,
|
||||||
|
})
|
||||||
|
if email = strings.TrimSpace(email); email != "" {
|
||||||
|
response["email"] = email
|
||||||
|
response["resolved_email"] = email
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) transitionPendingOAuthAccountToChoiceState(
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
client *dbent.Client,
|
client *dbent.Client,
|
||||||
session *dbent.PendingAuthSession,
|
session *dbent.PendingAuthSession,
|
||||||
email string,
|
email string,
|
||||||
decision oauthAdoptionDecisionRequest,
|
|
||||||
) (*dbent.PendingAuthSession, error) {
|
) (*dbent.PendingAuthSession, error) {
|
||||||
existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email)
|
completionResponse := pendingOAuthChoiceCompletionResponse(session, email)
|
||||||
if err != nil {
|
session, err := updatePendingOAuthSessionProgress(
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
completionResponse := mergePendingCompletionResponse(session, map[string]any{
|
|
||||||
"step": "bind_login_required",
|
|
||||||
"email": email,
|
|
||||||
})
|
|
||||||
session, err = updatePendingOAuthSessionProgress(
|
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
client,
|
client,
|
||||||
session,
|
session,
|
||||||
"adopt_existing_user_by_email",
|
strings.TrimSpace(session.Intent),
|
||||||
email,
|
email,
|
||||||
&existingUser.ID,
|
nil,
|
||||||
completionResponse,
|
completionResponse,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, infraerrors.InternalServer("PENDING_AUTH_SESSION_UPDATE_FAILED", "failed to update pending oauth session").WithCause(err)
|
return nil, infraerrors.InternalServer("PENDING_AUTH_SESSION_UPDATE_FAILED", "failed to update pending oauth session").WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, decision); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,12 +1388,20 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
|
|||||||
|
|
||||||
email := strings.TrimSpace(strings.ToLower(req.Email))
|
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email)
|
existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email)
|
||||||
if err != nil && !errors.Is(err, service.ErrUserNotFound) {
|
if err != nil {
|
||||||
response.ErrorFrom(c, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable"))
|
switch {
|
||||||
return
|
case errors.Is(err, service.ErrUserNotFound):
|
||||||
|
existingUser = nil
|
||||||
|
case infraerrors.Code(err) >= http.StatusBadRequest && infraerrors.Code(err) < http.StatusInternalServerError:
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
response.ErrorFrom(c, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if existingUser != nil {
|
if existingUser != nil {
|
||||||
session, err = h.transitionPendingOAuthAccountToBindLogin(c, client, session, email, req.adoptionDecision())
|
session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
@@ -1393,7 +1424,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrEmailExists) {
|
if errors.Is(err, service.ErrEmailExists) {
|
||||||
session, err = h.transitionPendingOAuthAccountToBindLogin(c, client, session, email, req.adoptionDecision())
|
session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
@@ -1548,6 +1579,7 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
|
|||||||
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_COMPLETION_INVALID", "pending auth completion payload is invalid"))
|
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_COMPLETION_INVALID", "pending auth completion payload is invalid"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
payload = normalizePendingOAuthCompletionResponse(payload)
|
||||||
if strings.TrimSpace(session.RedirectTo) != "" {
|
if strings.TrimSpace(session.RedirectTo) != "" {
|
||||||
if _, exists := payload["redirect"]; !exists {
|
if _, exists := payload["redirect"]; !exists {
|
||||||
payload["redirect"] = session.RedirectTo
|
payload["redirect"] = session.RedirectTo
|
||||||
|
|||||||
@@ -420,27 +420,6 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if compatEmailUser != nil {
|
|
||||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
|
||||||
Intent: "adopt_existing_user_by_email",
|
|
||||||
Identity: identityRef,
|
|
||||||
TargetUserID: &compatEmailUser.ID,
|
|
||||||
ResolvedEmail: compatEmailUser.Email,
|
|
||||||
RedirectTo: redirectTo,
|
|
||||||
BrowserSessionKey: browserSessionKey,
|
|
||||||
UpstreamIdentityClaims: upstreamClaims,
|
|
||||||
CompletionResponse: map[string]any{
|
|
||||||
"redirect": redirectTo,
|
|
||||||
"step": "bind_login_required",
|
|
||||||
"email": compatEmailUser.Email,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
redirectToFrontendCallback(c, frontendCallback)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.RequireEmailVerified {
|
if cfg.RequireEmailVerified {
|
||||||
if emailVerified == nil || !*emailVerified {
|
if emailVerified == nil || !*emailVerified {
|
||||||
@@ -450,7 +429,18 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
||||||
if err := h.createOAuthEmailRequiredPendingSession(c, identityRef, redirectTo, browserSessionKey, upstreamClaims); err != nil {
|
if err := h.createOIDCOAuthChoicePendingSession(
|
||||||
|
c,
|
||||||
|
identityRef,
|
||||||
|
email,
|
||||||
|
email,
|
||||||
|
redirectTo,
|
||||||
|
browserSessionKey,
|
||||||
|
upstreamClaims,
|
||||||
|
compatEmail,
|
||||||
|
compatEmailUser,
|
||||||
|
true,
|
||||||
|
); err != nil {
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -458,48 +448,18 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
if err := h.createOIDCOAuthChoicePendingSession(
|
||||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
c,
|
||||||
if err != nil {
|
identityRef,
|
||||||
if errors.Is(err, service.ErrOAuthInvitationRequired) {
|
email,
|
||||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
email,
|
||||||
Intent: "login",
|
redirectTo,
|
||||||
Identity: identityRef,
|
browserSessionKey,
|
||||||
ResolvedEmail: email,
|
upstreamClaims,
|
||||||
RedirectTo: redirectTo,
|
compatEmail,
|
||||||
BrowserSessionKey: browserSessionKey,
|
compatEmailUser,
|
||||||
UpstreamIdentityClaims: upstreamClaims,
|
h.isForceEmailOnThirdPartySignup(c.Request.Context()),
|
||||||
CompletionResponse: map[string]any{
|
); err != nil {
|
||||||
"error": "invitation_required",
|
|
||||||
"redirect": redirectTo,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
redirectToFrontendCallback(c, frontendCallback)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
|
||||||
Intent: "login",
|
|
||||||
Identity: identityRef,
|
|
||||||
TargetUserID: &user.ID,
|
|
||||||
ResolvedEmail: email,
|
|
||||||
RedirectTo: redirectTo,
|
|
||||||
BrowserSessionKey: browserSessionKey,
|
|
||||||
UpstreamIdentityClaims: upstreamClaims,
|
|
||||||
CompletionResponse: map[string]any{
|
|
||||||
"access_token": tokenPair.AccessToken,
|
|
||||||
"refresh_token": tokenPair.RefreshToken,
|
|
||||||
"expires_in": tokenPair.ExpiresIn,
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"redirect": redirectTo,
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -530,6 +490,65 @@ func (h *AuthHandler) findOIDCCompatEmailUser(ctx context.Context, email string)
|
|||||||
return userEntity, nil
|
return userEntity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) createOIDCOAuthChoicePendingSession(
|
||||||
|
c *gin.Context,
|
||||||
|
identity service.PendingAuthIdentityKey,
|
||||||
|
suggestedEmail string,
|
||||||
|
resolvedEmail string,
|
||||||
|
redirectTo string,
|
||||||
|
browserSessionKey string,
|
||||||
|
upstreamClaims map[string]any,
|
||||||
|
compatEmail string,
|
||||||
|
compatEmailUser *dbent.User,
|
||||||
|
forceEmailOnSignup bool,
|
||||||
|
) error {
|
||||||
|
suggestionEmail := strings.TrimSpace(suggestedEmail)
|
||||||
|
canonicalEmail := strings.TrimSpace(resolvedEmail)
|
||||||
|
if suggestionEmail == "" {
|
||||||
|
suggestionEmail = canonicalEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
completionResponse := map[string]any{
|
||||||
|
"step": oauthPendingChoiceStep,
|
||||||
|
"adoption_required": true,
|
||||||
|
"redirect": strings.TrimSpace(redirectTo),
|
||||||
|
"email": suggestionEmail,
|
||||||
|
"resolved_email": canonicalEmail,
|
||||||
|
"existing_account_email": "",
|
||||||
|
"existing_account_bindable": false,
|
||||||
|
"create_account_allowed": true,
|
||||||
|
"force_email_on_signup": forceEmailOnSignup,
|
||||||
|
"choice_reason": "third_party_signup",
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(compatEmail) != "" {
|
||||||
|
completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
|
||||||
|
}
|
||||||
|
if compatEmailUser != nil {
|
||||||
|
completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
|
||||||
|
completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
|
||||||
|
completionResponse["existing_account_bindable"] = true
|
||||||
|
completionResponse["choice_reason"] = "compat_email_match"
|
||||||
|
}
|
||||||
|
if forceEmailOnSignup && compatEmailUser == nil {
|
||||||
|
completionResponse["choice_reason"] = "force_email_on_signup"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedChoiceEmail := suggestionEmail
|
||||||
|
if compatEmailUser != nil {
|
||||||
|
resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||||
|
Intent: oauthIntentLogin,
|
||||||
|
Identity: identity,
|
||||||
|
ResolvedEmail: resolvedChoiceEmail,
|
||||||
|
RedirectTo: redirectTo,
|
||||||
|
BrowserSessionKey: browserSessionKey,
|
||||||
|
UpstreamIdentityClaims: upstreamClaims,
|
||||||
|
CompletionResponse: completionResponse,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type completeOIDCOAuthRequest struct {
|
type completeOIDCOAuthRequest struct {
|
||||||
InvitationCode string `json:"invitation_code" binding:"required"`
|
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ type wechatOAuthConfig struct {
|
|||||||
scope string
|
scope string
|
||||||
redirectURI string
|
redirectURI string
|
||||||
frontendCallback string
|
frontendCallback string
|
||||||
|
openEnabled bool
|
||||||
|
mpEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type wechatOAuthTokenResponse struct {
|
type wechatOAuthTokenResponse struct {
|
||||||
@@ -209,11 +211,18 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
|||||||
|
|
||||||
unionid := strings.TrimSpace(firstNonEmpty(userInfo.UnionID, tokenResp.UnionID))
|
unionid := strings.TrimSpace(firstNonEmpty(userInfo.UnionID, tokenResp.UnionID))
|
||||||
openid := strings.TrimSpace(firstNonEmpty(userInfo.OpenID, tokenResp.OpenID))
|
openid := strings.TrimSpace(firstNonEmpty(userInfo.OpenID, tokenResp.OpenID))
|
||||||
if unionid == "" {
|
providerSubject := unionid
|
||||||
|
if providerSubject == "" {
|
||||||
|
if cfg.requiresUnionID() {
|
||||||
|
redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_unionid", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
providerSubject = openid
|
||||||
|
}
|
||||||
|
if providerSubject == "" {
|
||||||
redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_unionid", "")
|
redirectOAuthError(c, frontendCallback, "provider_error", "wechat_missing_unionid", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
providerSubject := unionid
|
|
||||||
|
|
||||||
username := firstNonEmpty(userInfo.Nickname, wechatFallbackUsername(providerSubject))
|
username := firstNonEmpty(userInfo.Nickname, wechatFallbackUsername(providerSubject))
|
||||||
email := wechatSyntheticEmail(providerSubject)
|
email := wechatSyntheticEmail(providerSubject)
|
||||||
@@ -284,7 +293,18 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
||||||
if err := h.createOAuthEmailRequiredPendingSession(c, identityRef, redirectTo, browserSessionKey, upstreamClaims); err != nil {
|
if err := h.createWeChatChoicePendingSession(
|
||||||
|
c,
|
||||||
|
identityRef,
|
||||||
|
email,
|
||||||
|
email,
|
||||||
|
redirectTo,
|
||||||
|
browserSessionKey,
|
||||||
|
upstreamClaims,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
true,
|
||||||
|
); err != nil {
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -292,17 +312,18 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
if err := h.createWeChatChoicePendingSession(
|
||||||
if err != nil {
|
c,
|
||||||
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, err, nil); err != nil {
|
identityRef,
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
email,
|
||||||
return
|
email,
|
||||||
}
|
redirectTo,
|
||||||
redirectToFrontendCallback(c, frontendCallback)
|
browserSessionKey,
|
||||||
return
|
upstreamClaims,
|
||||||
}
|
"",
|
||||||
|
nil,
|
||||||
if err := h.createWeChatPendingSession(c, normalizedIntent, providerSubject, email, redirectTo, browserSessionKey, upstreamClaims, tokenPair, nil, nil); err != nil {
|
false,
|
||||||
|
); err != nil {
|
||||||
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -600,6 +621,65 @@ func (h *AuthHandler) createWeChatPendingSession(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) createWeChatChoicePendingSession(
|
||||||
|
c *gin.Context,
|
||||||
|
identity service.PendingAuthIdentityKey,
|
||||||
|
suggestedEmail string,
|
||||||
|
resolvedEmail string,
|
||||||
|
redirectTo string,
|
||||||
|
browserSessionKey string,
|
||||||
|
upstreamClaims map[string]any,
|
||||||
|
compatEmail string,
|
||||||
|
compatEmailUser *dbent.User,
|
||||||
|
forceEmailOnSignup bool,
|
||||||
|
) error {
|
||||||
|
suggestionEmail := strings.TrimSpace(suggestedEmail)
|
||||||
|
canonicalEmail := strings.TrimSpace(resolvedEmail)
|
||||||
|
if suggestionEmail == "" {
|
||||||
|
suggestionEmail = canonicalEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
completionResponse := map[string]any{
|
||||||
|
"step": oauthPendingChoiceStep,
|
||||||
|
"adoption_required": true,
|
||||||
|
"redirect": strings.TrimSpace(redirectTo),
|
||||||
|
"email": suggestionEmail,
|
||||||
|
"resolved_email": canonicalEmail,
|
||||||
|
"existing_account_email": "",
|
||||||
|
"existing_account_bindable": false,
|
||||||
|
"create_account_allowed": true,
|
||||||
|
"force_email_on_signup": forceEmailOnSignup,
|
||||||
|
"choice_reason": "third_party_signup",
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(compatEmail) != "" {
|
||||||
|
completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
|
||||||
|
}
|
||||||
|
if compatEmailUser != nil {
|
||||||
|
completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
|
||||||
|
completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
|
||||||
|
completionResponse["existing_account_bindable"] = true
|
||||||
|
completionResponse["choice_reason"] = "compat_email_match"
|
||||||
|
}
|
||||||
|
if forceEmailOnSignup {
|
||||||
|
completionResponse["choice_reason"] = "force_email_on_signup"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedChoiceEmail := suggestionEmail
|
||||||
|
if compatEmailUser != nil {
|
||||||
|
resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||||
|
Intent: oauthIntentLogin,
|
||||||
|
Identity: identity,
|
||||||
|
ResolvedEmail: resolvedChoiceEmail,
|
||||||
|
RedirectTo: redirectTo,
|
||||||
|
BrowserSessionKey: browserSessionKey,
|
||||||
|
UpstreamIdentityClaims: upstreamClaims,
|
||||||
|
CompletionResponse: completionResponse,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) createWeChatBindPendingSession(
|
func (h *AuthHandler) createWeChatBindPendingSession(
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
cfg wechatOAuthConfig,
|
cfg wechatOAuthConfig,
|
||||||
@@ -874,7 +954,7 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return wechatOAuthConfig{}, err
|
return wechatOAuthConfig{}, err
|
||||||
}
|
}
|
||||||
if effective.Mode != mode {
|
if !effective.SupportsMode(mode) {
|
||||||
return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
|
return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,7 +964,9 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string,
|
|||||||
appSecret: strings.TrimSpace(effective.AppSecret),
|
appSecret: strings.TrimSpace(effective.AppSecret),
|
||||||
redirectURI: firstNonEmpty(strings.TrimSpace(effective.RedirectURL), resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback")),
|
redirectURI: firstNonEmpty(strings.TrimSpace(effective.RedirectURL), resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback")),
|
||||||
frontendCallback: firstNonEmpty(strings.TrimSpace(effective.FrontendRedirectURL), wechatOAuthDefaultFrontendCB),
|
frontendCallback: firstNonEmpty(strings.TrimSpace(effective.FrontendRedirectURL), wechatOAuthDefaultFrontendCB),
|
||||||
scope: firstNonEmpty(strings.TrimSpace(effective.Scopes), service.DefaultWeChatConnectScopesForMode(mode)),
|
scope: effective.ScopeForMode(mode),
|
||||||
|
openEnabled: effective.OpenEnabled,
|
||||||
|
mpEnabled: effective.MPEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch mode {
|
switch mode {
|
||||||
@@ -900,6 +982,10 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string,
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg wechatOAuthConfig) requiresUnionID() bool {
|
||||||
|
return cfg.openEnabled && cfg.mpEnabled
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) wechatOAuthFrontendCallback(ctx context.Context) string {
|
func (h *AuthHandler) wechatOAuthFrontendCallback(ctx context.Context) string {
|
||||||
if h != nil && h.settingSvc != nil {
|
if h != nil && h.settingSvc != nil {
|
||||||
cfg, err := h.settingSvc.GetWeChatConnectOAuthConfig(ctx)
|
cfg, err := h.settingSvc.GetWeChatConnectOAuthConfig(ctx)
|
||||||
|
|||||||
@@ -65,6 +65,36 @@ func TestWeChatOAuthStartRedirectsAndSetsPendingCookies(t *testing.T) {
|
|||||||
require.NotEmpty(t, findCookie(cookies, oauthPendingBrowserCookieName))
|
require.NotEmpty(t, findCookie(cookies, oauthPendingBrowserCookieName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWeChatOAuthStart_AllowsOpenModeWhenBothCapabilitiesEnabled(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, map[string]string{
|
||||||
|
service.SettingKeyWeChatConnectEnabled: "true",
|
||||||
|
service.SettingKeyWeChatConnectAppID: "wx-shared-app",
|
||||||
|
service.SettingKeyWeChatConnectAppSecret: "wx-shared-secret",
|
||||||
|
service.SettingKeyWeChatConnectMode: "mp",
|
||||||
|
service.SettingKeyWeChatConnectScopes: "snsapi_base",
|
||||||
|
service.SettingKeyWeChatConnectOpenEnabled: "true",
|
||||||
|
service.SettingKeyWeChatConnectMPEnabled: "true",
|
||||||
|
service.SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
|
||||||
|
service.SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
|
||||||
|
})
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/start?mode=open&redirect=/billing", nil)
|
||||||
|
c.Request.Host = "api.example.com"
|
||||||
|
|
||||||
|
handler.WeChatOAuthStart(c)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusFound, recorder.Code)
|
||||||
|
location := recorder.Header().Get("Location")
|
||||||
|
require.NotEmpty(t, location)
|
||||||
|
require.Contains(t, location, "open.weixin.qq.com")
|
||||||
|
require.Contains(t, location, "connect/qrconnect")
|
||||||
|
require.Contains(t, location, "scope=snsapi_login")
|
||||||
|
}
|
||||||
|
|
||||||
func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) {
|
func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) {
|
||||||
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
originalAccessTokenURL := wechatOAuthAccessTokenURL
|
||||||
originalUserInfoURL := wechatOAuthUserInfoURL
|
originalUserInfoURL := wechatOAuthUserInfoURL
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
v-if="
|
v-if="
|
||||||
needsInvitation ||
|
needsInvitation ||
|
||||||
needsAdoptionConfirmation ||
|
needsAdoptionConfirmation ||
|
||||||
|
needsChooser ||
|
||||||
needsCreateAccount ||
|
needsCreateAccount ||
|
||||||
needsBindLogin ||
|
needsBindLogin ||
|
||||||
needsTotpChallenge
|
needsTotpChallenge
|
||||||
@@ -109,6 +110,42 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsChooser">
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Choose how to continue
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{
|
||||||
|
pendingAccountEmail
|
||||||
|
? `Suggested email: ${pendingAccountEmail}`
|
||||||
|
: 'Choose whether to bind an existing account or create a new one.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToBindLoginMode()"
|
||||||
|
>
|
||||||
|
Bind existing account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToCreateAccountMode"
|
||||||
|
>
|
||||||
|
Create new account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsCreateAccount">
|
<template v-else-if="needsCreateAccount">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Enter an email address to create your account and continue.
|
Enter an email address to create your account and continue.
|
||||||
@@ -275,7 +312,7 @@ const suggestedAvatarUrl = ref('')
|
|||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
|
const pendingAccountAction = ref<'none' | 'choose_account_action' | 'create_account' | 'bind_login'>('none')
|
||||||
const pendingAccountEmail = ref('')
|
const pendingAccountEmail = ref('')
|
||||||
const bindLoginEmail = ref('')
|
const bindLoginEmail = ref('')
|
||||||
const bindLoginPassword = ref('')
|
const bindLoginPassword = ref('')
|
||||||
@@ -290,12 +327,17 @@ const totpError = ref('')
|
|||||||
const totpUserEmailMasked = ref('')
|
const totpUserEmailMasked = ref('')
|
||||||
|
|
||||||
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||||
|
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
|
||||||
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||||
|
|
||||||
type LinuxDoPendingActionResponse = PendingOAuthExchangeResponse & {
|
type LinuxDoPendingActionResponse = PendingOAuthExchangeResponse & {
|
||||||
step?: string
|
step?: string
|
||||||
|
intent?: string
|
||||||
email?: string
|
email?: string
|
||||||
resolved_email?: string
|
resolved_email?: string
|
||||||
|
pending_email?: string
|
||||||
|
existing_account_email?: string
|
||||||
|
suggested_email?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistPendingAuthSession(redirect?: string) {
|
function persistPendingAuthSession(redirect?: string) {
|
||||||
@@ -392,12 +434,34 @@ function hasSuggestedProfile(completion: {
|
|||||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPendingAccountEmail(completion: LinuxDoPendingActionResponse): string {
|
function normalizedPendingState(value: string | null | undefined): string {
|
||||||
return (completion.email || completion.resolved_email || '').trim()
|
return value?.trim().toLowerCase() || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePendingAccountAction(completion: LinuxDoPendingActionResponse): 'none' | 'create_account' | 'bind_login' {
|
function extractPendingAccountEmail(completion: LinuxDoPendingActionResponse): string {
|
||||||
const raw = (completion.step || completion.error || '').trim().toLowerCase()
|
return (
|
||||||
|
completion.pending_email ||
|
||||||
|
completion.existing_account_email ||
|
||||||
|
completion.email ||
|
||||||
|
completion.resolved_email ||
|
||||||
|
completion.suggested_email ||
|
||||||
|
''
|
||||||
|
).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePendingAccountAction(
|
||||||
|
completion: LinuxDoPendingActionResponse
|
||||||
|
): 'none' | 'choose_account_action' | 'create_account' | 'bind_login' {
|
||||||
|
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
|
||||||
|
if (
|
||||||
|
raw === 'choice' ||
|
||||||
|
raw === 'choose_account_action_required' ||
|
||||||
|
raw === 'choose_account_action' ||
|
||||||
|
raw === 'choose_account' ||
|
||||||
|
raw === 'choose'
|
||||||
|
) {
|
||||||
|
return 'choose_account_action'
|
||||||
|
}
|
||||||
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
||||||
return 'create_account'
|
return 'create_account'
|
||||||
}
|
}
|
||||||
@@ -418,6 +482,14 @@ function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
|
|||||||
totpUserEmailMasked.value = ''
|
totpUserEmailMasked.value = ''
|
||||||
|
|
||||||
const email = extractPendingAccountEmail(completion)
|
const email = extractPendingAccountEmail(completion)
|
||||||
|
if (action === 'choose_account_action') {
|
||||||
|
pendingAccountEmail.value = email
|
||||||
|
bindLoginEmail.value = email
|
||||||
|
bindLoginPassword.value = ''
|
||||||
|
canReturnToCreateAccount.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'create_account') {
|
if (action === 'create_account') {
|
||||||
pendingAccountEmail.value = email
|
pendingAccountEmail.value = email
|
||||||
canReturnToCreateAccount.value = true
|
canReturnToCreateAccount.value = true
|
||||||
@@ -470,28 +542,6 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
|
|||||||
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCreateAccountRecoveryError(error: unknown): boolean {
|
|
||||||
const data = (error as {
|
|
||||||
response?: {
|
|
||||||
data?: {
|
|
||||||
reason?: string
|
|
||||||
error?: string
|
|
||||||
code?: string
|
|
||||||
step?: string
|
|
||||||
intent?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).response?.data
|
|
||||||
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
|
|
||||||
.map(value => value?.trim().toLowerCase())
|
|
||||||
.filter((value): value is string => Boolean(value))
|
|
||||||
|
|
||||||
return states.includes('email_exists') ||
|
|
||||||
states.includes('bind_login_required') ||
|
|
||||||
states.includes('bind_login') ||
|
|
||||||
states.includes('adopt_existing_user_by_email')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||||
@@ -601,10 +651,6 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
|||||||
})
|
})
|
||||||
await finalizePendingAccountResponse(data)
|
await finalizePendingAccountResponse(data)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (isCreateAccountRecoveryError(e)) {
|
|
||||||
switchToBindLoginMode(payload.email)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
v-if="
|
v-if="
|
||||||
needsInvitation ||
|
needsInvitation ||
|
||||||
needsAdoptionConfirmation ||
|
needsAdoptionConfirmation ||
|
||||||
|
needsChooser ||
|
||||||
needsCreateAccount ||
|
needsCreateAccount ||
|
||||||
needsBindLogin ||
|
needsBindLogin ||
|
||||||
needsTotpChallenge
|
needsTotpChallenge
|
||||||
@@ -118,6 +119,42 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsChooser">
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Choose how to continue
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
{{
|
||||||
|
pendingAccountEmail
|
||||||
|
? `Suggested email: ${pendingAccountEmail}`
|
||||||
|
: `Choose whether to bind an existing ${providerName} account or create a new one.`
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToBindLoginMode()"
|
||||||
|
>
|
||||||
|
Bind existing account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToCreateAccountMode"
|
||||||
|
>
|
||||||
|
Create new account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsCreateAccount">
|
<template v-else-if="needsCreateAccount">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Enter an email address to create your account and continue.
|
Enter an email address to create your account and continue.
|
||||||
@@ -284,7 +321,7 @@ const suggestedAvatarUrl = ref('')
|
|||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
|
const pendingAccountAction = ref<'none' | 'choose_account_action' | 'create_account' | 'bind_login'>('none')
|
||||||
const pendingAccountEmail = ref('')
|
const pendingAccountEmail = ref('')
|
||||||
const bindLoginEmail = ref('')
|
const bindLoginEmail = ref('')
|
||||||
const bindLoginPassword = ref('')
|
const bindLoginPassword = ref('')
|
||||||
@@ -299,6 +336,7 @@ const totpError = ref('')
|
|||||||
const totpUserEmailMasked = ref('')
|
const totpUserEmailMasked = ref('')
|
||||||
|
|
||||||
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||||
|
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
|
||||||
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||||
|
|
||||||
type PendingOidcCompletion = PendingOAuthExchangeResponse & {
|
type PendingOidcCompletion = PendingOAuthExchangeResponse & {
|
||||||
@@ -307,6 +345,7 @@ type PendingOidcCompletion = PendingOAuthExchangeResponse & {
|
|||||||
resolved_email?: string
|
resolved_email?: string
|
||||||
existing_account_email?: string
|
existing_account_email?: string
|
||||||
email?: string
|
email?: string
|
||||||
|
suggested_email?: string
|
||||||
provider_fallback?: string
|
provider_fallback?: string
|
||||||
intent?: string
|
intent?: string
|
||||||
requires_2fa?: boolean
|
requires_2fa?: boolean
|
||||||
@@ -430,12 +469,24 @@ function extractPendingAccountEmail(completion: PendingOidcCompletion): string {
|
|||||||
completion.existing_account_email ||
|
completion.existing_account_email ||
|
||||||
completion.resolved_email ||
|
completion.resolved_email ||
|
||||||
completion.email ||
|
completion.email ||
|
||||||
|
completion.suggested_email ||
|
||||||
''
|
''
|
||||||
).trim()
|
).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePendingAccountAction(completion: PendingOidcCompletion): 'none' | 'create_account' | 'bind_login' {
|
function resolvePendingAccountAction(
|
||||||
|
completion: PendingOidcCompletion
|
||||||
|
): 'none' | 'choose_account_action' | 'create_account' | 'bind_login' {
|
||||||
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
|
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
|
||||||
|
if (
|
||||||
|
raw === 'choice' ||
|
||||||
|
raw === 'choose_account_action_required' ||
|
||||||
|
raw === 'choose_account_action' ||
|
||||||
|
raw === 'choose_account' ||
|
||||||
|
raw === 'choose'
|
||||||
|
) {
|
||||||
|
return 'choose_account_action'
|
||||||
|
}
|
||||||
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
||||||
return 'create_account'
|
return 'create_account'
|
||||||
}
|
}
|
||||||
@@ -462,6 +513,14 @@ function applyPendingAccountAction(completion: PendingOidcCompletion) {
|
|||||||
totpUserEmailMasked.value = ''
|
totpUserEmailMasked.value = ''
|
||||||
|
|
||||||
const email = extractPendingAccountEmail(completion)
|
const email = extractPendingAccountEmail(completion)
|
||||||
|
if (action === 'choose_account_action') {
|
||||||
|
pendingAccountEmail.value = email
|
||||||
|
bindLoginEmail.value = email
|
||||||
|
bindLoginPassword.value = ''
|
||||||
|
canReturnToCreateAccount.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'create_account') {
|
if (action === 'create_account') {
|
||||||
pendingAccountEmail.value = email
|
pendingAccountEmail.value = email
|
||||||
canReturnToCreateAccount.value = true
|
canReturnToCreateAccount.value = true
|
||||||
@@ -514,28 +573,6 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
|
|||||||
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCreateAccountRecoveryError(error: unknown): boolean {
|
|
||||||
const data = (error as {
|
|
||||||
response?: {
|
|
||||||
data?: {
|
|
||||||
reason?: string
|
|
||||||
error?: string
|
|
||||||
code?: string
|
|
||||||
step?: string
|
|
||||||
intent?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).response?.data
|
|
||||||
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
|
|
||||||
.map(value => value?.trim().toLowerCase())
|
|
||||||
.filter((value): value is string => Boolean(value))
|
|
||||||
|
|
||||||
return states.includes('email_exists') ||
|
|
||||||
states.includes('bind_login_required') ||
|
|
||||||
states.includes('bind_login') ||
|
|
||||||
states.includes('adopt_existing_user_by_email')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||||
@@ -645,10 +682,6 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
|||||||
})
|
})
|
||||||
await finalizePendingAccountResponse(data)
|
await finalizePendingAccountResponse(data)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (isCreateAccountRecoveryError(e)) {
|
|
||||||
switchToBindLoginMode(payload.email)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
needsInvitation ||
|
needsInvitation ||
|
||||||
|
needsChooser ||
|
||||||
needsAdoptionConfirmation ||
|
needsAdoptionConfirmation ||
|
||||||
needsCreateAccount ||
|
needsCreateAccount ||
|
||||||
needsBindLogin ||
|
needsBindLogin ||
|
||||||
@@ -103,7 +104,7 @@
|
|||||||
{{
|
{{
|
||||||
isSubmitting
|
isSubmitting
|
||||||
? t('auth.oidc.completing')
|
? t('auth.oidc.completing')
|
||||||
: t('auth.oidc.completeRegistration')
|
: t('auth.oidc.completeRegistration')
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -147,6 +148,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsChooser">
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Choose how to continue
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
Pick whether to bind an existing account or create a new one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-testid="wechat-choice-bind-existing"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToBindLoginMode()"
|
||||||
|
>
|
||||||
|
Bind existing account
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-testid="wechat-choice-create-account"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToCreateAccountMode()"
|
||||||
|
>
|
||||||
|
Create new account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsAdoptionConfirmation">
|
<template v-else-if="needsAdoptionConfirmation">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Review the {{ providerName }} profile details before continuing.
|
Review the {{ providerName }} profile details before continuing.
|
||||||
@@ -168,13 +206,46 @@
|
|||||||
@submit="handleCreateAccount"
|
@submit="handleCreateAccount"
|
||||||
@switch-to-bind="switchToBindLoginMode"
|
@switch-to-bind="switchToBindLoginMode"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
v-if="showBackToChooser"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToChoiceMode"
|
||||||
|
>
|
||||||
|
Back to options
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsBindLogin">
|
<template v-else-if="needsBindLogin">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Log in to an existing account to bind this {{ providerName }} sign-in.
|
Bind this {{ providerName }} sign-in to an existing account.
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-3">
|
<div
|
||||||
|
v-if="hasCurrentAuthToken"
|
||||||
|
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
Bind the current account
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
Bind this WeChat identity to the account currently signed in on this browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-testid="existing-account-submit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="handleBindCurrentAccount"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? t('common.processing') : 'Bind current account' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
<input
|
<input
|
||||||
v-model="bindLoginEmail"
|
v-model="bindLoginEmail"
|
||||||
data-testid="wechat-bind-login-email"
|
data-testid="wechat-bind-login-email"
|
||||||
@@ -201,20 +272,15 @@
|
|||||||
>
|
>
|
||||||
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
|
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="canReturnToCreateAccount"
|
|
||||||
class="btn btn-secondary w-full"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="switchToCreateAccountMode"
|
|
||||||
>
|
|
||||||
Use a different email
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<transition name="fade">
|
<button
|
||||||
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
v-if="showBackToChooser"
|
||||||
{{ accountActionError }}
|
class="btn btn-secondary w-full"
|
||||||
</p>
|
:disabled="isSubmitting"
|
||||||
</transition>
|
@click="switchToChoiceMode"
|
||||||
|
>
|
||||||
|
Back to options
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsTotpChallenge">
|
<template v-else-if="needsTotpChallenge">
|
||||||
@@ -253,6 +319,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ accountActionError }}
|
||||||
|
</p>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
@@ -314,6 +386,7 @@ const appStore = useAppStore()
|
|||||||
const isProcessing = ref(true)
|
const isProcessing = ref(true)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const needsInvitation = ref(false)
|
const needsInvitation = ref(false)
|
||||||
|
const needsChooser = ref(false)
|
||||||
const invitationCode = ref('')
|
const invitationCode = ref('')
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const invitationError = ref('')
|
const invitationError = ref('')
|
||||||
@@ -325,13 +398,12 @@ const existingAccountEmail = ref('')
|
|||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
|
const pendingAccountAction = ref<'none' | 'choice' | 'create_account' | 'bind_login'>('none')
|
||||||
const pendingAccountEmail = ref('')
|
const pendingAccountEmail = ref('')
|
||||||
const bindLoginEmail = ref('')
|
const bindLoginEmail = ref('')
|
||||||
const bindLoginPassword = ref('')
|
const bindLoginPassword = ref('')
|
||||||
const legacyPendingOAuthToken = ref('')
|
const legacyPendingOAuthToken = ref('')
|
||||||
const accountActionError = ref('')
|
const accountActionError = ref('')
|
||||||
const canReturnToCreateAccount = ref(false)
|
|
||||||
const needsTotpChallenge = ref(false)
|
const needsTotpChallenge = ref(false)
|
||||||
const totpTempToken = ref('')
|
const totpTempToken = ref('')
|
||||||
const totpCode = ref('')
|
const totpCode = ref('')
|
||||||
@@ -340,12 +412,17 @@ const totpUserEmailMasked = ref('')
|
|||||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||||
|
|
||||||
const providerName = 'WeChat'
|
const providerName = 'WeChat'
|
||||||
|
const showBackToChooser = computed(
|
||||||
|
() => pendingAccountAction.value === 'create_account' || pendingAccountAction.value === 'bind_login'
|
||||||
|
)
|
||||||
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||||
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||||
const hasCurrentAuthToken = computed(() => Boolean(getAuthToken()))
|
const hasCurrentAuthToken = computed(() => Boolean(getAuthToken()))
|
||||||
|
|
||||||
type PendingWeChatCompletion = PendingOAuthExchangeResponse & {
|
type PendingWeChatCompletion = PendingOAuthExchangeResponse & {
|
||||||
step?: string
|
step?: string
|
||||||
|
status?: string
|
||||||
|
state?: string
|
||||||
pending_email?: string
|
pending_email?: string
|
||||||
resolved_email?: string
|
resolved_email?: string
|
||||||
existing_account_email?: string
|
existing_account_email?: string
|
||||||
@@ -489,11 +566,6 @@ function resolveWeChatStartURL(intent: 'bind_current_user' | 'adopt_existing_use
|
|||||||
intent,
|
intent,
|
||||||
})
|
})
|
||||||
|
|
||||||
const email = existingAccountEmail.value.trim()
|
|
||||||
if (email) {
|
|
||||||
params.set('email', email)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${normalized}/auth/oauth/wechat/start?${params.toString()}`
|
return `${normalized}/auth/oauth/wechat/start?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +574,7 @@ function buildExistingAccountResumePath(): string | null {
|
|||||||
if (!mode) {
|
if (!mode) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
wechat_bind_existing: '1',
|
wechat_bind_existing: '1',
|
||||||
redirect: resolveRedirectTarget(),
|
redirect: resolveRedirectTarget(),
|
||||||
@@ -538,26 +611,31 @@ function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<stri
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExistingAccountBinding() {
|
async function handleBindCurrentAccount() {
|
||||||
const unavailableMessage = resolveConfiguredWeChatOAuthMode() === null
|
const unavailableMessage = resolveConfiguredWeChatOAuthMode() === null
|
||||||
? resolveWeChatOAuthUnavailableMessage()
|
? resolveWeChatOAuthUnavailableMessage()
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
|
const startURL = resolveWeChatStartURL('bind_current_user')
|
||||||
|
if (!startURL) {
|
||||||
|
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
|
||||||
|
appStore.showError(errorMessage.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareOAuthBindAccessTokenCookie()
|
||||||
|
window.location.href = startURL
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExistingAccountBinding() {
|
||||||
if (getAuthToken()) {
|
if (getAuthToken()) {
|
||||||
const startURL = resolveWeChatStartURL('bind_current_user')
|
await handleBindCurrentAccount()
|
||||||
if (!startURL) {
|
|
||||||
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
|
|
||||||
appStore.showError(errorMessage.value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prepareOAuthBindAccessTokenCookie()
|
|
||||||
window.location.href = startURL
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumePath = buildExistingAccountResumePath()
|
const resumePath = buildExistingAccountResumePath()
|
||||||
if (!resumePath) {
|
if (!resumePath) {
|
||||||
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
|
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
|
||||||
appStore.showError(errorMessage.value)
|
appStore.showError(errorMessage.value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -606,17 +684,29 @@ function extractPendingAccountEmail(completion: PendingWeChatCompletion): string
|
|||||||
|
|
||||||
function resolvePendingAccountAction(
|
function resolvePendingAccountAction(
|
||||||
completion: PendingWeChatCompletion
|
completion: PendingWeChatCompletion
|
||||||
): 'none' | 'create_account' | 'bind_login' {
|
): 'none' | 'choice' | 'create_account' | 'bind_login' {
|
||||||
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
|
const raw = normalizedPendingState(
|
||||||
|
completion.step || completion.status || completion.state || completion.error || completion.intent
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
raw === 'choice' ||
|
||||||
|
raw === 'choose_account_action_required' ||
|
||||||
|
raw === 'choose_account_action' ||
|
||||||
|
raw === 'choose_account' ||
|
||||||
|
raw === 'choose' ||
|
||||||
|
raw === 'existing_account' ||
|
||||||
|
raw === 'existing_account_required' ||
|
||||||
|
raw === 'existing_account_binding_required' ||
|
||||||
|
raw === 'adopt_existing_user_by_email'
|
||||||
|
) {
|
||||||
|
return 'choice'
|
||||||
|
}
|
||||||
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
||||||
return 'create_account'
|
return 'create_account'
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
raw === 'bind_login_required' ||
|
raw === 'bind_login_required' ||
|
||||||
raw === 'bind_login' ||
|
raw === 'bind_login'
|
||||||
raw === 'existing_account_binding_required' ||
|
|
||||||
raw === 'existing_account_required' ||
|
|
||||||
raw === 'adopt_existing_user_by_email'
|
|
||||||
) {
|
) {
|
||||||
return 'bind_login'
|
return 'bind_login'
|
||||||
}
|
}
|
||||||
@@ -627,6 +717,7 @@ function applyPendingAccountAction(completion: PendingWeChatCompletion) {
|
|||||||
const action = resolvePendingAccountAction(completion)
|
const action = resolvePendingAccountAction(completion)
|
||||||
pendingAccountAction.value = action
|
pendingAccountAction.value = action
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
|
needsChooser.value = false
|
||||||
needsTotpChallenge.value = false
|
needsTotpChallenge.value = false
|
||||||
totpTempToken.value = ''
|
totpTempToken.value = ''
|
||||||
totpCode.value = ''
|
totpCode.value = ''
|
||||||
@@ -634,20 +725,22 @@ function applyPendingAccountAction(completion: PendingWeChatCompletion) {
|
|||||||
totpUserEmailMasked.value = ''
|
totpUserEmailMasked.value = ''
|
||||||
|
|
||||||
const email = extractPendingAccountEmail(completion)
|
const email = extractPendingAccountEmail(completion)
|
||||||
|
pendingAccountEmail.value = email
|
||||||
if (action === 'create_account') {
|
if (action === 'create_account') {
|
||||||
pendingAccountEmail.value = email
|
|
||||||
canReturnToCreateAccount.value = true
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'bind_login') {
|
if (action === 'bind_login') {
|
||||||
bindLoginEmail.value = email
|
bindLoginEmail.value = email
|
||||||
bindLoginPassword.value = ''
|
bindLoginPassword.value = ''
|
||||||
canReturnToCreateAccount.value = true
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
canReturnToCreateAccount.value = false
|
if (action === 'choice') {
|
||||||
|
needsChooser.value = true
|
||||||
|
bindLoginPassword.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
|
function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
|
||||||
@@ -656,6 +749,7 @@ function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingAccountAction.value = 'none'
|
pendingAccountAction.value = 'none'
|
||||||
|
needsChooser.value = false
|
||||||
needsInvitation.value = false
|
needsInvitation.value = false
|
||||||
needsAdoptionConfirmation.value = false
|
needsAdoptionConfirmation.value = false
|
||||||
needsTotpChallenge.value = true
|
needsTotpChallenge.value = true
|
||||||
@@ -669,18 +763,26 @@ function applyTotpChallenge(completion: PendingWeChatCompletion): boolean {
|
|||||||
|
|
||||||
function switchToBindLoginMode(nextEmail?: string) {
|
function switchToBindLoginMode(nextEmail?: string) {
|
||||||
pendingAccountAction.value = 'bind_login'
|
pendingAccountAction.value = 'bind_login'
|
||||||
|
needsChooser.value = false
|
||||||
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
|
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
|
||||||
bindLoginPassword.value = ''
|
bindLoginPassword.value = ''
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
canReturnToCreateAccount.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToCreateAccountMode() {
|
function switchToCreateAccountMode() {
|
||||||
pendingAccountAction.value = 'create_account'
|
pendingAccountAction.value = 'create_account'
|
||||||
|
needsChooser.value = false
|
||||||
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
|
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
|
||||||
accountActionError.value = ''
|
accountActionError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchToChoiceMode() {
|
||||||
|
pendingAccountAction.value = 'choice'
|
||||||
|
needsChooser.value = true
|
||||||
|
bindLoginPassword.value = ''
|
||||||
|
accountActionError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function getRequestErrorMessage(error: unknown, fallback: string): string {
|
function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||||
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||||
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||||
@@ -705,7 +807,9 @@ function isCreateAccountRecoveryError(error: unknown): boolean {
|
|||||||
return states.includes('email_exists') ||
|
return states.includes('email_exists') ||
|
||||||
states.includes('bind_login_required') ||
|
states.includes('bind_login_required') ||
|
||||||
states.includes('bind_login') ||
|
states.includes('bind_login') ||
|
||||||
states.includes('adopt_existing_user_by_email')
|
states.includes('adopt_existing_user_by_email') ||
|
||||||
|
states.includes('existing_account_required') ||
|
||||||
|
states.includes('existing_account_binding_required')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||||
@@ -818,7 +922,10 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
|||||||
await finalizePendingAccountResponse(data)
|
await finalizePendingAccountResponse(data)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (isCreateAccountRecoveryError(e)) {
|
if (isCreateAccountRecoveryError(e)) {
|
||||||
switchToBindLoginMode(payload.email)
|
switchToChoiceMode()
|
||||||
|
pendingAccountEmail.value = payload.email.trim()
|
||||||
|
bindLoginEmail.value = payload.email.trim()
|
||||||
|
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
@@ -878,20 +985,15 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof route.query.email === 'string') {
|
if (typeof route.query.email === 'string') {
|
||||||
existingAccountEmail.value = route.query.email
|
const email = route.query.email.trim()
|
||||||
|
existingAccountEmail.value = email
|
||||||
|
bindLoginEmail.value = email
|
||||||
|
pendingAccountEmail.value = email
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.query.wechat_bind_existing === '1') {
|
if (route.query.wechat_bind_existing === '1') {
|
||||||
if (getAuthToken()) {
|
if (getAuthToken()) {
|
||||||
const startURL = resolveWeChatStartURL('bind_current_user')
|
await handleBindCurrentAccount()
|
||||||
if (!startURL) {
|
|
||||||
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
|
|
||||||
appStore.showError(errorMessage.value)
|
|
||||||
isProcessing.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prepareOAuthBindAccessTokenCookie()
|
|
||||||
window.location.href = startURL
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user