fix: 完善邮箱快捷登录注册流程

This commit is contained in:
lyen1688
2026-05-06 20:50:41 +08:00
committed by lyen1688
parent 81edaa8986
commit e69256a706
5 changed files with 417 additions and 63 deletions
+101 -27
View File
@@ -11,31 +11,68 @@
</p>
</div>
<div v-else-if="needsInvitation" class="card p-6">
<div v-else-if="needsRegistrationCompletion" class="card p-6">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('auth.oidc.callbackTitle', { providerName }) }}
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ t('auth.oidc.invitationRequired', { providerName }) }}
{{ registrationHint }}
</p>
<div class="mt-6 space-y-4">
<input
v-model="invitationCode"
type="text"
class="input w-full"
:placeholder="t('auth.invitationCodePlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleSubmitInvitation"
/>
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
{{ invitationError }}
<div>
<label class="input-label">{{ t('auth.emailLabel') }}</label>
<input
class="input w-full"
type="email"
:value="registrationEmail"
readonly
disabled
/>
</div>
<div>
<label class="input-label">{{ t('auth.passwordLabel') }}</label>
<input
v-model="password"
type="password"
class="input w-full"
:placeholder="t('auth.createPasswordPlaceholder')"
:disabled="isSubmitting"
autocomplete="new-password"
@keyup.enter="handleSubmitRegistration"
/>
</div>
<div>
<label class="input-label">{{ t('auth.confirmPassword') }}</label>
<input
v-model="confirmPassword"
type="password"
class="input w-full"
:placeholder="t('auth.confirmPasswordPlaceholder')"
:disabled="isSubmitting"
autocomplete="new-password"
@keyup.enter="handleSubmitRegistration"
/>
</div>
<div v-if="invitationRequired">
<label class="input-label">{{ t('auth.invitationCodeLabel') }}</label>
<input
v-model="invitationCode"
type="text"
class="input w-full"
:placeholder="t('auth.invitationCodePlaceholder')"
:disabled="isSubmitting"
@keyup.enter="handleSubmitRegistration"
/>
</div>
<p v-if="registrationError" class="text-sm text-red-600 dark:text-red-400">
{{ registrationError }}
</p>
<button
class="btn btn-primary w-full"
type="button"
:disabled="isSubmitting || !invitationCode.trim()"
@click="handleSubmitInvitation"
:disabled="isSubmitting || !canSubmitRegistration"
@click="handleSubmitRegistration"
>
{{ isSubmitting ? t('common.processing') : t('auth.oidc.completeRegistration') }}
</button>
@@ -134,9 +171,13 @@ const appStore = useAppStore()
const authStore = useAuthStore()
const isProcessing = ref(false)
const isSubmitting = ref(false)
const needsInvitation = ref(false)
const needsRegistrationCompletion = ref(false)
const invitationRequired = ref(false)
const registrationEmail = ref('')
const password = ref('')
const confirmPassword = ref('')
const invitationCode = ref('')
const invitationError = ref('')
const registrationError = ref('')
const pendingProvider = ref<'github' | 'google'>('github')
const redirectTo = ref('/dashboard')
const invalidCallback = ref(false)
@@ -146,6 +187,9 @@ type EmailOAuthPendingCompletion = Partial<OAuthTokenResponse> & {
error?: string
provider?: string
redirect?: string
email?: string
resolved_email?: string
invitation_required?: boolean
}
const code = computed(() => (route.query.code as string) || '')
@@ -161,6 +205,18 @@ const fullUrl = computed(() => {
const providerName = computed(() =>
pendingProvider.value === 'google' ? 'Google' : 'GitHub'
)
const registrationHint = computed(() =>
invitationRequired.value
? t('auth.oidc.invitationRequired', { providerName: providerName.value })
: t('auth.oidc.completeRegistration')
)
const canSubmitRegistration = computed(() => {
if (!registrationEmail.value.trim()) return false
if (password.value.length < 6) return false
if (password.value !== confirmPassword.value) return false
if (invitationRequired.value && !invitationCode.value.trim()) return false
return true
})
function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : ''
@@ -247,8 +303,10 @@ async function resumePendingEmailOAuth() {
}
redirectTo.value = sanitizeRedirectPath(completionRedirect)
if (completion.error === 'invitation_required') {
needsInvitation.value = true
if (completion.error === 'invitation_required' || completion.error === 'registration_completion_required') {
invitationRequired.value = completion.error === 'invitation_required' || completion.invitation_required === true
registrationEmail.value = String(completion.resolved_email || completion.email || '').trim()
needsRegistrationCompletion.value = true
isProcessing.value = false
return
}
@@ -260,30 +318,46 @@ async function resumePendingEmailOAuth() {
appStore.showError(message)
invalidCallback.value = true
} finally {
if (!needsInvitation.value) {
if (!needsRegistrationCompletion.value) {
isProcessing.value = false
}
}
}
async function handleSubmitInvitation() {
invitationError.value = ''
async function handleSubmitRegistration() {
registrationError.value = ''
if (!registrationEmail.value.trim()) {
registrationError.value = t('auth.emailRequired')
return
}
if (password.value.length < 6) {
registrationError.value = t('auth.passwordMinLength')
return
}
if (password.value !== confirmPassword.value) {
registrationError.value = t('auth.passwordsDoNotMatch')
return
}
const code = invitationCode.value.trim()
if (!code) return
if (invitationRequired.value && !code) return
isSubmitting.value = true
try {
const payload: { password: string; invitation_code?: string; aff_code?: string } = {
password: password.value,
...oauthAffiliatePayload(loadOAuthAffiliateCode())
}
if (invitationRequired.value) {
payload.invitation_code = code
}
const { data } = await apiClient.post<OAuthTokenResponse>(
`/auth/oauth/${pendingProvider.value}/complete-registration`,
{
invitation_code: code,
...oauthAffiliatePayload(loadOAuthAffiliateCode())
}
payload
)
await finalizeTokenResponse(data, redirectTo.value)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
invitationError.value =
registrationError.value =
err.response?.data?.message || err.message || t('auth.oidc.completeRegistrationFailed')
} finally {
isSubmitting.value = false
@@ -161,6 +161,8 @@ describe('OAuthCallbackView', () => {
error: 'invitation_required',
provider: 'google',
redirect: '/dashboard',
resolved_email: 'pending@example.com',
invitation_required: true,
})
apiPostMock.mockResolvedValue({
data: {
@@ -171,14 +173,54 @@ describe('OAuthCallbackView', () => {
const wrapper = mount(OAuthCallbackView)
await vi.dynamicImportSettled()
const input = wrapper.find('input[type="text"]')
await input.setValue('INVITE456')
const passwordInputs = wrapper.findAll('input[type="password"]')
await passwordInputs[0].setValue('secret-123')
await passwordInputs[1].setValue('secret-123')
const invitationInput = wrapper.find('input[type="text"]')
await invitationInput.setValue('INVITE456')
await wrapper.findAll('button').at(0)?.trigger('click')
expect(apiPostMock).toHaveBeenCalledWith('/auth/oauth/google/complete-registration', {
password: 'secret-123',
invitation_code: 'INVITE456',
aff_code: 'AFF456',
})
expect(setTokenMock).toHaveBeenCalledWith('token-1')
})
it('completes email oauth registration with readonly email and without posting email', async () => {
routeState.path = '/auth/oauth/callback'
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'registration_completion_required',
provider: 'github',
redirect: '/dashboard',
resolved_email: 'verified@example.com',
invitation_required: false,
})
apiPostMock.mockResolvedValue({
data: {
access_token: 'token-2',
},
})
const wrapper = mount(OAuthCallbackView)
await vi.dynamicImportSettled()
const emailInput = wrapper.find('input[type="email"]')
expect(emailInput.exists()).toBe(true)
expect((emailInput.element as HTMLInputElement).value).toBe('verified@example.com')
expect(emailInput.attributes('readonly')).toBeDefined()
expect(emailInput.attributes('disabled')).toBeDefined()
const passwordInputs = wrapper.findAll('input[type="password"]')
await passwordInputs[0].setValue('secret-456')
await passwordInputs[1].setValue('secret-456')
await wrapper.findAll('button').at(0)?.trigger('click')
expect(apiPostMock).toHaveBeenCalledWith('/auth/oauth/github/complete-registration', {
password: 'secret-456',
})
expect(apiPostMock.mock.calls[0][1]).not.toHaveProperty('email')
expect(setTokenMock).toHaveBeenCalledWith('token-2')
})
})