fix: 完善邮箱快捷登录注册流程
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user