mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 04:44:49 +00:00
fix issue #836 linux.do注册无需邀请码
This commit is contained in:
@@ -211,8 +211,22 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
email = linuxDoSyntheticEmail(subject)
|
email = linuxDoSyntheticEmail(subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username)
|
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
||||||
|
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrOAuthInvitationRequired) {
|
||||||
|
pendingToken, tokenErr := h.authService.CreatePendingOAuthToken(email, username)
|
||||||
|
if tokenErr != nil {
|
||||||
|
redirectOAuthError(c, frontendCallback, "login_failed", "service_error", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fragment := url.Values{}
|
||||||
|
fragment.Set("error", "invitation_required")
|
||||||
|
fragment.Set("pending_oauth_token", pendingToken)
|
||||||
|
fragment.Set("redirect", redirectTo)
|
||||||
|
redirectWithFragment(c, frontendCallback, fragment)
|
||||||
|
return
|
||||||
|
}
|
||||||
// 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。
|
// 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。
|
||||||
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
||||||
return
|
return
|
||||||
@@ -227,6 +241,45 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
|||||||
redirectWithFragment(c, frontendCallback, fragment)
|
redirectWithFragment(c, frontendCallback, fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type completeLinuxDoOAuthRequest struct {
|
||||||
|
PendingOAuthToken string `json:"pending_oauth_token" binding:"required"`
|
||||||
|
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteLinuxDoOAuthRegistration completes a pending OAuth registration by validating
|
||||||
|
// the invitation code and creating the user account.
|
||||||
|
// POST /api/v1/auth/oauth/linuxdo/complete-registration
|
||||||
|
func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
|
||||||
|
var req completeLinuxDoOAuthRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email, username, err := h.authService.VerifyPendingOAuthToken(req.PendingOAuthToken)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "INVALID_TOKEN", "message": "invalid or expired registration token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenPair, _, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||||
|
if err != nil {
|
||||||
|
statusCode := http.StatusBadRequest
|
||||||
|
c.JSON(statusCode, gin.H{
|
||||||
|
"error": infraerrors.Reason(err),
|
||||||
|
"message": infraerrors.Message(err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"access_token": tokenPair.AccessToken,
|
||||||
|
"refresh_token": tokenPair.RefreshToken,
|
||||||
|
"expires_in": tokenPair.ExpiresIn,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
|
func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
|
||||||
if h != nil && h.settingSvc != nil {
|
if h != nil && h.settingSvc != nil {
|
||||||
return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx)
|
return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx)
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ func RegisterAuthRoutes(
|
|||||||
}), h.Auth.ResetPassword)
|
}), h.Auth.ResetPassword)
|
||||||
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
||||||
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
||||||
|
auth.POST("/oauth/linuxdo/complete-registration",
|
||||||
|
rateLimiter.LimitWithOptions("oauth-linuxdo-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||||
|
FailureMode: middleware.RateLimitFailClose,
|
||||||
|
}),
|
||||||
|
h.Auth.CompleteLinuxDoOAuthRegistration,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公开设置(无需认证)
|
// 公开设置(无需认证)
|
||||||
|
|||||||
@@ -21,24 +21,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
|
ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
|
||||||
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
|
ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
|
||||||
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
|
ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
|
||||||
ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
|
ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
|
||||||
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
|
ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
|
||||||
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
|
ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
|
||||||
ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired")
|
ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired")
|
||||||
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
|
ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
|
||||||
ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
|
ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
|
||||||
ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token")
|
ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token")
|
||||||
ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
|
ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
|
||||||
ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
|
ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
|
||||||
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
|
ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
|
||||||
ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
|
ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
|
||||||
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
|
ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
|
||||||
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
|
ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
|
||||||
ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
|
ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
|
||||||
ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code")
|
ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code")
|
||||||
|
ErrOAuthInvitationRequired = infraerrors.Forbidden("OAUTH_INVITATION_REQUIRED", "invitation code required to complete oauth registration")
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
|
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
|
||||||
@@ -523,9 +524,10 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username
|
|||||||
return token, user, nil
|
return token, user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair
|
// LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。
|
||||||
// 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token
|
// 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token。
|
||||||
func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username string) (*TokenPair, *User, error) {
|
// invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。
|
||||||
|
func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode string) (*TokenPair, *User, error) {
|
||||||
// 检查 refreshTokenCache 是否可用
|
// 检查 refreshTokenCache 是否可用
|
||||||
if s.refreshTokenCache == nil {
|
if s.refreshTokenCache == nil {
|
||||||
return nil, nil, errors.New("refresh token cache not configured")
|
return nil, nil, errors.New("refresh token cache not configured")
|
||||||
@@ -552,6 +554,22 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
|||||||
return nil, nil, ErrRegDisabled
|
return nil, nil, ErrRegDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否需要邀请码
|
||||||
|
var invitationRedeemCode *RedeemCode
|
||||||
|
if s.settingService != nil && s.settingService.IsInvitationCodeEnabled(ctx) {
|
||||||
|
if invitationCode == "" {
|
||||||
|
return nil, nil, ErrOAuthInvitationRequired
|
||||||
|
}
|
||||||
|
redeemCode, err := s.redeemRepo.GetByCode(ctx, invitationCode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, ErrInvitationCodeInvalid
|
||||||
|
}
|
||||||
|
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
|
||||||
|
return nil, nil, ErrInvitationCodeInvalid
|
||||||
|
}
|
||||||
|
invitationRedeemCode = redeemCode
|
||||||
|
}
|
||||||
|
|
||||||
randomPassword, err := randomHexString(32)
|
randomPassword, err := randomHexString(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.LegacyPrintf("service.auth", "[Auth] Failed to generate random password for oauth signup: %v", err)
|
logger.LegacyPrintf("service.auth", "[Auth] Failed to generate random password for oauth signup: %v", err)
|
||||||
@@ -593,6 +611,11 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
|||||||
} else {
|
} else {
|
||||||
user = newUser
|
user = newUser
|
||||||
s.assignDefaultSubscriptions(ctx, user.ID)
|
s.assignDefaultSubscriptions(ctx, user.ID)
|
||||||
|
if invitationRedeemCode != nil {
|
||||||
|
if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil {
|
||||||
|
logger.LegacyPrintf("service.auth", "[Auth] Failed to mark invitation code as used for oauth user %d: %v", user.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.LegacyPrintf("service.auth", "[Auth] Database error during oauth login: %v", err)
|
logger.LegacyPrintf("service.auth", "[Auth] Database error during oauth login: %v", err)
|
||||||
@@ -618,6 +641,55 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
|
|||||||
return tokenPair, user, nil
|
return tokenPair, user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pendingOAuthTokenTTL is the validity period for pending OAuth tokens.
|
||||||
|
const pendingOAuthTokenTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
type pendingOAuthClaims struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePendingOAuthToken generates a short-lived JWT that carries the OAuth identity
|
||||||
|
// while waiting for the user to supply an invitation code.
|
||||||
|
func (s *AuthService) CreatePendingOAuthToken(email, username string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := &pendingOAuthClaims{
|
||||||
|
Email: email,
|
||||||
|
Username: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(pendingOAuthTokenTTL)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(s.cfg.JWT.Secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPendingOAuthToken validates a pending OAuth token and returns the embedded identity.
|
||||||
|
// Returns ErrInvalidToken when the token is invalid or expired.
|
||||||
|
func (s *AuthService) VerifyPendingOAuthToken(tokenStr string) (email, username string, err error) {
|
||||||
|
if len(tokenStr) > maxTokenLength {
|
||||||
|
return "", "", ErrInvalidToken
|
||||||
|
}
|
||||||
|
parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
|
||||||
|
token, parseErr := parser.ParseWithClaims(tokenStr, &pendingOAuthClaims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(s.cfg.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
if parseErr != nil {
|
||||||
|
return "", "", ErrInvalidToken
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(*pendingOAuthClaims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return "", "", ErrInvalidToken
|
||||||
|
}
|
||||||
|
return claims.Email, claims.Username, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int64) {
|
func (s *AuthService) assignDefaultSubscriptions(ctx context.Context, userID int64) {
|
||||||
if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 {
|
if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -335,6 +335,28 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete LinuxDo OAuth registration by supplying an invitation code
|
||||||
|
* @param pendingOAuthToken - Short-lived JWT from the OAuth callback
|
||||||
|
* @param invitationCode - Invitation code entered by the user
|
||||||
|
* @returns Token pair on success
|
||||||
|
*/
|
||||||
|
export async function completeLinuxDoOAuthRegistration(
|
||||||
|
pendingOAuthToken: string,
|
||||||
|
invitationCode: string
|
||||||
|
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
||||||
|
const { data } = await apiClient.post<{
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expires_in: number
|
||||||
|
token_type: string
|
||||||
|
}>('/auth/oauth/linuxdo/complete-registration', {
|
||||||
|
pending_oauth_token: pendingOAuthToken,
|
||||||
|
invitation_code: invitationCode
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export const authAPI = {
|
export const authAPI = {
|
||||||
login,
|
login,
|
||||||
login2FA,
|
login2FA,
|
||||||
@@ -357,7 +379,8 @@ export const authAPI = {
|
|||||||
forgotPassword,
|
forgotPassword,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
revokeAllSessions
|
revokeAllSessions,
|
||||||
|
completeLinuxDoOAuthRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
export default authAPI
|
export default authAPI
|
||||||
|
|||||||
@@ -433,7 +433,12 @@ export default {
|
|||||||
callbackProcessing: 'Completing login, please wait...',
|
callbackProcessing: 'Completing login, please wait...',
|
||||||
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
|
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
|
||||||
callbackMissingToken: 'Missing login token, please try again.',
|
callbackMissingToken: 'Missing login token, please try again.',
|
||||||
backToLogin: 'Back to Login'
|
backToLogin: 'Back to Login',
|
||||||
|
invitationRequired: 'This Linux.do account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
|
||||||
|
invalidPendingToken: 'The registration token has expired. Please sign in with Linux.do again.',
|
||||||
|
completeRegistration: 'Complete Registration',
|
||||||
|
completing: 'Completing registration…',
|
||||||
|
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
code: 'Code',
|
code: 'Code',
|
||||||
|
|||||||
@@ -432,7 +432,12 @@ export default {
|
|||||||
callbackProcessing: '正在验证登录信息,请稍候...',
|
callbackProcessing: '正在验证登录信息,请稍候...',
|
||||||
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
|
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
|
||||||
callbackMissingToken: '登录信息缺失,请返回重试。',
|
callbackMissingToken: '登录信息缺失,请返回重试。',
|
||||||
backToLogin: '返回登录'
|
backToLogin: '返回登录',
|
||||||
|
invitationRequired: '该 Linux.do 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
|
||||||
|
invalidPendingToken: '注册凭证已失效,请重新使用 Linux.do 登录。',
|
||||||
|
completeRegistration: '完成注册',
|
||||||
|
completing: '正在完成注册...',
|
||||||
|
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
code: '授权码',
|
code: '授权码',
|
||||||
|
|||||||
@@ -10,6 +10,36 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="needsInvitation" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ t('auth.linuxdo.invitationRequired') }}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
v-model="invitationCode"
|
||||||
|
type="text"
|
||||||
|
class="input w-full"
|
||||||
|
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleSubmitInvitation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ invitationError }}
|
||||||
|
</p>
|
||||||
|
</transition>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting || !invitationCode.trim()"
|
||||||
|
@click="handleSubmitInvitation"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? t('auth.linuxdo.completing') : t('auth.linuxdo.completeRegistration') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
@@ -41,6 +71,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
|
import { completeLinuxDoOAuthRegistration } from '@/api/auth'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -52,6 +83,14 @@ const appStore = useAppStore()
|
|||||||
const isProcessing = ref(true)
|
const isProcessing = ref(true)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
// Invitation code flow state
|
||||||
|
const needsInvitation = ref(false)
|
||||||
|
const pendingOAuthToken = ref('')
|
||||||
|
const invitationCode = ref('')
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const invitationError = ref('')
|
||||||
|
const redirectTo = ref('/dashboard')
|
||||||
|
|
||||||
function parseFragmentParams(): URLSearchParams {
|
function parseFragmentParams(): URLSearchParams {
|
||||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||||
@@ -67,6 +106,34 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSubmitInvitation() {
|
||||||
|
invitationError.value = ''
|
||||||
|
if (!invitationCode.value.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const tokenData = await completeLinuxDoOAuthRegistration(
|
||||||
|
pendingOAuthToken.value,
|
||||||
|
invitationCode.value.trim()
|
||||||
|
)
|
||||||
|
if (tokenData.refresh_token) {
|
||||||
|
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||||
|
}
|
||||||
|
if (tokenData.expires_in) {
|
||||||
|
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
||||||
|
}
|
||||||
|
await authStore.setToken(tokenData.access_token)
|
||||||
|
appStore.showSuccess(t('auth.loginSuccess'))
|
||||||
|
await router.replace(redirectTo.value)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||||
|
invitationError.value =
|
||||||
|
err.response?.data?.message || err.message || t('auth.linuxdo.completeRegistrationFailed')
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const params = parseFragmentParams()
|
const params = parseFragmentParams()
|
||||||
|
|
||||||
@@ -80,6 +147,19 @@ onMounted(async () => {
|
|||||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
if (error === 'invitation_required') {
|
||||||
|
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
|
||||||
|
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
|
||||||
|
if (!pendingOAuthToken.value) {
|
||||||
|
errorMessage.value = t('auth.linuxdo.invalidPendingToken')
|
||||||
|
appStore.showError(errorMessage.value)
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needsInvitation.value = true
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
errorMessage.value = errorDesc || error
|
errorMessage.value = errorDesc || error
|
||||||
appStore.showError(errorMessage.value)
|
appStore.showError(errorMessage.value)
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user