feat: add email verification check for cloud onboarding (#5636)

## Summary
- Added email verification flow for new users during onboarding
- Implemented invite code claiming with proper validation 
- Updated API endpoints from `/invite/` to `/invite_code/` for
consistency

## Changes

### Email Verification
- Added `CloudVerifyEmailView` component with email verification UI
- Added email verification check after login in `CloudLoginView`
- Added `isEmailVerified` property to Firebase auth store
- Users must verify email before claiming invite codes

### Invite Code Flow
- Enhanced `CloudClaimInviteView` with full claim invite functionality
- Updated `InviteCheckView` to route users based on email verification
status
- Modified API to return both `claimed` and `expired` status for invite
codes
- Added proper error handling and Sentry logging for invite operations

### API Updates
- Changed endpoint paths from `/invite/` to `/invite_code/` 
- Updated `getInviteCodeStatus()` to return `{ claimed: boolean;
expired: boolean }`
- Updated `claimInvite()` to return `{ success: boolean; message: string
}`

### UI/UX Improvements
- Added Korean translations for all new strings
- Improved button styling and layout in survey and waitlist views
- Added proper loading states and error handling

## Test Plan
- [ ] Test new user signup flow with email verification
- [ ] Test invite code validation (expired/claimed/valid codes)
- [ ] Test email verification redirect flow
- [ ] Test invite claiming after email verification
- [ ] Verify Korean translations display correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jin Yi
2025-09-21 12:29:56 +09:00
committed by GitHub
parent d3a5d9e995
commit 8ca541e850
15 changed files with 423 additions and 130 deletions

View File

@@ -1,27 +1,159 @@
<template>
<div
class="flex flex-col justify-center items-center h-screen font-mono text-black gap-4"
>
<h1 class="text-2xl">
{{ t('cloudClaimInvite_processingTitle') }}
</h1>
<button
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 cursor-pointer"
@click="onClaim"
>
{{ t('cloudClaimInvite_claimButton') }}
</button>
<div class="flex items-center bg-neutral-900 text-neutral-100">
<main class="w-full max-w-md px-6 py-12 text-center" role="main">
<!-- Title -->
<h1
class="text-white font-abcrom font-black italic uppercase my-0 text-3xl"
>
{{ t('cloudInvite_title') }}
</h1>
<!-- Subtitle -->
<p v-if="inviteCodeClaimed" class="mt-6 text-amber-500 leading-relaxed">
{{ t('cloudInvite_alreadyClaimed_prefix') }}
<strong>{{ userEmail }}</strong>
</p>
<p
v-else-if="inviteCodeExpired"
class="mt-6 text-amber-500 leading-relaxed"
>
{{ t('cloudInvite_expired_prefix') }}
</p>
<p v-else class="mt-6 text-neutral-300 leading-relaxed">
{{ t('cloudInvite_subtitle') }}
</p>
<div v-if="inviteCodeClaimed || inviteCodeExpired" class="mb-2">
<span
class="text-blue-400 no-underline cursor-pointer"
@click="onClickSupport"
>
{{ t('cloudInvite_contactLink') }}</span
>
<span class="text-neutral-400 ml-2">
{{ t('cloudInvite_contactLink_suffix') }}</span
>
</div>
<div>
<span
class="text-blue-400 no-underline cursor-pointer"
@click="onSwitchAccounts"
>
{{ t('cloudInvite_switchAccounts') }}</span
>
</div>
<!-- Signed in as -->
<section class="mt-10">
<p class="text-sm">
{{ t('cloudInvite_signedInAs') }}
</p>
<div class="mt-4 flex flex-col items-center justify-center gap-4">
<!-- Avatar box -->
<div
class="relative grid place-items-center h-28 w-28 rounded-2xl border border-neutral-700 bg-neutral-800 shadow-inner"
>
<span class="text-5xl font-semibold select-none">{{
userInitial
}}</span>
<!-- subtle ring to mimic screenshot gradient border -->
<span
class="pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-inset ring-neutral-600/40"
></span>
</div>
<div class="text-left">
<div class="text-sm break-all">
{{ userEmail }}
</div>
</div>
</div>
</section>
<Button
type="button"
:label="
processing
? t('cloudInvite_processing')
: t('cloudInvite_acceptButton')
"
class="w-full h-12 font-medium mt-12 text-white"
:disabled="processing || inviteCodeClaimed || inviteCodeExpired"
@click="onClaim"
/>
</main>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { claimInvite, getInviteCodeStatus } from '@/api/auth'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const onClaim = () => {
void router.push({ name: 'cloud-user-check' })
const processing = ref(false)
const inviteCodeExpired = ref(false)
const inviteCodeClaimed = ref(false)
const { userEmail } = useFirebaseAuthStore()
const inviteCode = computed(() => route.query.inviteCode as string)
const userInitial = computed(() => (userEmail?.[0] || 'U').toUpperCase())
const onSwitchAccounts = () => {
void router.push({
name: 'cloud-login',
query: { inviteCode: inviteCode.value }
})
}
const onClickSupport = () => {
window.open('https://support.comfy.org', '_blank', 'noopener')
}
const onClaim = async () => {
try {
try {
if (inviteCode.value) {
processing.value = true
const response = await claimInvite(inviteCode.value)
if (response.success) {
await router.push({ name: 'cloud-user-check' })
}
} else {
await router.push({ name: 'cloud-login' })
}
} catch (err) {
console.error('Failed to claim invite:', err)
} finally {
processing.value = false
}
} catch (e) {
console.error('Unexpected error in onClaim:', e)
}
}
onMounted(async () => {
try {
try {
const response = await getInviteCodeStatus(inviteCode.value)
inviteCodeExpired.value = response.expired
inviteCodeClaimed.value = response.claimed
} catch (err) {
console.error('Failed to fetch invite code status:', err)
await router.push({ name: 'cloud-login' })
return
}
} catch (e) {
console.error('Unexpected error in onMounted:', e)
await router.push({ name: 'cloud-login' })
}
})
</script>

View File

@@ -12,7 +12,7 @@ const route = useRoute()
const router = useRouter()
onMounted(async () => {
const inviteCode = route.params.inviteCode
const inviteCode = route.params.code
await router.push({
name: 'cloud-login',
query: {

View File

@@ -1,29 +1,44 @@
<template>
<div class="h-full flex items-center justify-center p-8">
<div class="lg:w-96 max-w-[100vw] p-2">
<div class="bg-[#2d2e32] p-4 rounded-lg">
<h4 class="m-0 pb-2 text-lg">
{{ t('cloudPrivateBeta_title') }}
</h4>
<p class="m-0 text-base leading-6">
{{ t('cloudPrivateBeta_desc') }}
</p>
</div>
<template v-if="!hasInviteCode">
<div class="bg-[#2d2e32] p-4 rounded-lg">
<h4 class="m-0 pb-2 text-lg">
{{ t('cloudPrivateBeta_title') }}
</h4>
<p class="m-0 text-base leading-6">
{{ t('cloudPrivateBeta_desc') }}
</p>
</div>
<!-- Header -->
<div class="flex flex-col gap-4 mt-6 mb-8">
<h1 class="text-xl font-medium leading-normal my-0">
{{ t('auth.login.title') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{ t('auth.login.newUser') }}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
<!-- Header -->
<div class="flex flex-col gap-4 mt-6 mb-8">
<h1 class="text-xl font-medium leading-normal my-0">
{{ t('auth.login.title') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{ t('auth.login.newUser') }}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</p>
</div>
</template>
<template v-else>
<div class="flex flex-col gap-1 mt-6 mb-8">
<h1
class="text-white font-abcrom font-black italic uppercase my-0 text-2xl"
>
</p>
</div>
{{ t('cloudStart_invited') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{ t('cloudStart_invited_signin') }}</span>
</p>
</div>
</template>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
@@ -61,7 +76,7 @@
</div>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
<p v-if="!hasInviteCode" class="mt-5 text-sm text-gray-600">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
@@ -72,6 +87,15 @@
{{ t('cloudWaitlist_contactLink') }}</a
>.
</p>
<p v-else class="mt-5 text-sm text-gray-600">
{{ t('cloudStart_invited_signup_title') }}
<span
class="text-blue-400 no-underline cursor-pointer"
@click="navigateToSignup"
>
{{ t('cloudStart_invited_signup_description') }}</span
>
</p>
</div>
</div>
</template>
@@ -80,13 +104,14 @@
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import CloudSignInForm from '@/platform/onboarding/cloud/components/CloudSignInForm.vue'
import { type SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { translateAuthError } from '@/utils/authErrorTranslation'
const { t } = useI18n()
@@ -96,23 +121,29 @@ const authActions = useFirebaseAuthActions()
const isSecureContext = window.isSecureContext
const authError = ref('')
const hasInviteCode = computed(() => !!route.query.inviteCode)
const navigateToSignup = () => {
void router.push({ name: 'cloud-signup', query: route.query })
}
const onSuccess = async () => {
// Check if there's an invite code
const inviteCode = route.query.inviteCode as string
if (inviteCode) {
// Handle invite code flow - go to invite check
await router.push({
name: 'cloud-invite-check',
query: { inviteCode }
})
const inviteCode = route.query.inviteCode as string | undefined
const { isEmailVerified } = useFirebaseAuthStore()
if (!isEmailVerified) {
await router.push({ name: 'cloud-verify-email', query: { inviteCode } })
} else {
// Normal login flow - go to user check
await router.push({ name: 'cloud-user-check' })
if (inviteCode) {
// Handle invite code flow - go to invite check
await router.push({
name: 'cloud-invite-check',
query: { inviteCode }
})
} else {
// Normal login flow - go to user check
await router.push({ name: 'cloud-user-check' })
}
}
}
@@ -148,4 +179,8 @@ const signInWithEmail = async (values: SignInData) => {
await onSuccess()
}
}
onMounted(async () => {
await authActions.logout()
})
</script>

View File

@@ -117,9 +117,7 @@ const navigateToLogin = () => {
}
const onSuccess = async () => {
// After successful signup, always go to user check
// The user check will handle routing based on their status
await router.push({ name: 'cloud-user-check' })
await router.push({ name: 'cloud-login', query: route.query })
}
// Custom error handler for inline display

View File

@@ -46,7 +46,7 @@
<Button
label="Next"
:disabled="!validStep1"
class="w-full h-10 bg-gray-800 border-none text-white"
class="w-full h-10 border-none text-white"
@click="goTo(2, activateCallback)"
/>
</div>
@@ -93,13 +93,13 @@
<Button
label="Back"
severity="secondary"
class="border border-white text-white flex-1"
class="text-white flex-1"
@click="goTo(1, activateCallback)"
/>
<Button
label="Next"
:disabled="!validStep2"
class="flex-1 h-10 bg-gray-800 border-none text-white"
class="flex-1 h-10 text-white"
@click="goTo(3, activateCallback)"
/>
</div>
@@ -146,13 +146,13 @@
<Button
label="Back"
severity="secondary"
class="border border-white text-white flex-1"
class="text-white flex-1"
@click="goTo(2, activateCallback)"
/>
<Button
label="Next"
:disabled="!validStep3"
class="flex-1 h-10 bg-gray-800 border-none text-white"
class="flex-1 h-10 border-none text-white"
@click="goTo(4, activateCallback)"
/>
</div>
@@ -191,14 +191,14 @@
<Button
label="Back"
severity="secondary"
class="border border-white text-white flex-1"
class="text-white flex-1"
@click="goTo(3, activateCallback)"
/>
<Button
label="Submit"
:disabled="!validStep4 || isSubmitting"
:loading="isSubmitting"
class="flex-1 h-10 bg-gray-800 border-none text-white"
class="flex-1 h-10 border-none text-white"
@click="onSubmitSurvey"
/>
</div>

View File

@@ -1,20 +1,108 @@
<template>
<div>
<h1>{{ t('cloudVerifyEmail_title') }}</h1>
<div class="px-6 py-8 max-w-[640px] mx-auto">
<!-- Back button -->
<button
type="button"
class="flex items-center justify-center size-10 rounded-lg bg-transparent border border-white text-foreground/80"
aria-label="{{ t('cloudVerifyEmail_back') }}"
@click="goBack"
>
<i class="pi pi-arrow-left" />
</button>
<!-- Title -->
<h1 class="mt-8 text-2xl font-semibold">
{{ t('cloudVerifyEmail_title') }}
</h1>
<!-- Body copy -->
<p class="mt-6 text-base text-foreground/80">
{{ t('cloudVerifyEmail_sent') }}
</p>
<p class="mt-3 text-base font-medium">{{ authStore.userEmail }}</p>
<p class="mt-6 text-base text-foreground/80">
{{ t('cloudVerifyEmail_clickToContinue') }}
</p>
<p class="mt-10 text-base text-foreground/80">
{{ t('cloudVerifyEmail_didntReceive') }}
<span class="text-blue-400 no-underline cursor-pointer" @click="onSend">
{{ t('cloudVerifyEmail_resend') }}</span
>
</p>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
// import { verifyEmail } from '@/api/auth'
import router from '@/router'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useToastStore } from '@/stores/toastStore'
const authStore = useFirebaseAuthStore()
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const goBack = async () => {
const inviteCode = route.query.inviteCode as string | undefined
const authStore = useFirebaseAuthStore()
// If the user is already verified (email link already clicked),
// continue to the next step automatically.
if (authStore.isEmailVerified) {
await router.push({
name: 'cloud-invite-check',
query: inviteCode ? { inviteCode } : {}
})
} else {
await router.push({
name: 'cloud-login',
query: {
inviteCode
}
})
}
}
async function onSend() {
try {
await authStore.verifyEmail()
useToastStore().add({
severity: 'success',
summary: t('cloudVerifyEmail_toast_success', {
email: authStore.userEmail
})
})
} catch (e) {
useToastStore().add({
severity: 'error',
summary: t('cloudVerifyEmail_toast_failed')
})
}
}
onMounted(async () => {
// verifyEmail()
await router.push({ name: 'cloud-invite-check' })
// When this screen loads via invite flow,
// ensure the invite code stays in the URL for the next step.
const inviteCode = route.query.inviteCode as string | undefined
// If the user is already verified (email link already clicked),
// continue to the next step automatically.
if (authStore.isEmailVerified) {
if (inviteCode) {
await router.push({
name: 'cloud-invite-check',
query: inviteCode ? { inviteCode } : {}
})
} else {
await router.push({ name: 'cloud-user-check' })
}
} else {
await onSend()
}
})
</script>

View File

@@ -5,21 +5,10 @@
{{ t('cloudWaitlist_titleLine1') }}<br />
{{ t('cloudWaitlist_titleLine2') }}
</h1>
<div class="max-w-[320px] text-lg font-light">
<div class="max-w-[320px] text-lg font-light m-auto">
<p class="text-white">
{{ t('cloudWaitlist_message') }}
</p>
<p class="text-white">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="text-blue-400 no-underline cursor-pointer"
target="_blank"
rel="noopener noreferrer"
>
{{ t('cloudWaitlist_contactLink') }}</a
>.
</p>
</div>
</div>
</div>

View File

@@ -6,8 +6,6 @@
import { nextTick, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getInviteCodeStatus } from '@/api/auth'
import CloudClaimInviteViewSkeleton from './skeletons/CloudClaimInviteViewSkeleton.vue'
const router = useRouter()
@@ -17,19 +15,17 @@ onMounted(async () => {
await nextTick()
const inviteCode = route.query.inviteCode as string
const inviteCodeStatus = await getInviteCodeStatus(inviteCode)
// TODO: should be deleted when api is ready
// if (!status.emailVerified) {
// await router.push({ name: 'cloud-verify-email' })
// return
// }
if (inviteCodeStatus.expired) {
await router.push({ name: 'cloud-sorry-contact-support' })
try {
// Basic guard: missing invite code -> send to support
if (!inviteCode || typeof inviteCode !== 'string') {
await router.push({ name: 'cloud-sorry-contact-support' })
return
}
await router.push({ name: 'cloud-claim-invite', query: { inviteCode } })
} catch (e) {
window.open('https://support.comfy.org', '_blank', 'noopener')
return
}
await router.push({ name: 'cloud-claim-invite' })
})
</script>

View File

@@ -17,7 +17,7 @@
/>
</div>
</div>
<div v-else class="flex items-center justify-center min-h-screen">
<div v-else class="flex items-center justify-center">
<ProgressSpinner class="w-8 h-8" />
</div>
</template>