mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 23:20:07 +00:00
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:
@@ -88,10 +88,10 @@ export async function getUserCloudStatus(): Promise<UserCloudStatus> {
|
||||
|
||||
export async function getInviteCodeStatus(
|
||||
inviteCode: string
|
||||
): Promise<{ expired: boolean }> {
|
||||
): Promise<{ claimed: boolean; expired: boolean }> {
|
||||
try {
|
||||
const response = await api.fetchApi(
|
||||
`/invite/${encodeURIComponent(inviteCode)}/status`,
|
||||
`/invite_code/${encodeURIComponent(inviteCode)}/status`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -105,22 +105,22 @@ export async function getInviteCodeStatus(
|
||||
)
|
||||
captureApiError(
|
||||
error,
|
||||
'/invite/{code}/status',
|
||||
'/invite_code/{code}/status',
|
||||
'http_error',
|
||||
response.status,
|
||||
undefined,
|
||||
{
|
||||
api: {
|
||||
method: 'GET',
|
||||
endpoint: `/invite/${inviteCode}/status`,
|
||||
endpoint: `/invite_code/${inviteCode}/status`,
|
||||
status_code: response.status,
|
||||
status_text: response.statusText
|
||||
},
|
||||
extra: {
|
||||
invite_code_length: inviteCode.length
|
||||
},
|
||||
route_template: '/invite/{code}/status',
|
||||
route_actual: `/invite/${inviteCode}/status`
|
||||
route_template: '/invite_code/{code}/status',
|
||||
route_actual: `/invite_code/${inviteCode}/status`
|
||||
}
|
||||
)
|
||||
throw error
|
||||
@@ -132,13 +132,13 @@ export async function getInviteCodeStatus(
|
||||
if (!isHttpError(error, 'Failed to get invite code status:')) {
|
||||
captureApiError(
|
||||
error as Error,
|
||||
'/invite/{code}/status',
|
||||
'/invite_code/{code}/status',
|
||||
'network_error',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
route_template: '/invite/{code}/status',
|
||||
route_actual: `/invite/${inviteCode}/status`
|
||||
route_template: '/invite_code/{code}/status',
|
||||
route_actual: `/invite_code/${inviteCode}/status`
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -293,7 +293,9 @@ export async function submitSurvey(
|
||||
}
|
||||
}
|
||||
|
||||
export async function claimInvite(code: string): Promise<void> {
|
||||
export async function claimInvite(
|
||||
code: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'auth',
|
||||
@@ -305,7 +307,7 @@ export async function claimInvite(code: string): Promise<void> {
|
||||
})
|
||||
|
||||
const res = await api.fetchApi(
|
||||
`/invite/${encodeURIComponent(code)}/claim`,
|
||||
`/invite_code/${encodeURIComponent(code)}/claim`,
|
||||
{
|
||||
method: 'POST'
|
||||
}
|
||||
@@ -317,7 +319,7 @@ export async function claimInvite(code: string): Promise<void> {
|
||||
)
|
||||
captureApiError(
|
||||
error,
|
||||
'/invite/{code}/claim',
|
||||
'/invite_code/{code}/claim',
|
||||
'http_error',
|
||||
res.status,
|
||||
'claim_invite',
|
||||
@@ -327,8 +329,8 @@ export async function claimInvite(code: string): Promise<void> {
|
||||
status_code: res.status,
|
||||
status_text: res.statusText
|
||||
},
|
||||
route_template: '/invite/{code}/claim',
|
||||
route_actual: `/invite/${encodeURIComponent(code)}/claim`
|
||||
route_template: '/invite_code/{code}/claim',
|
||||
route_actual: `/invite_code/${encodeURIComponent(code)}/claim`
|
||||
}
|
||||
)
|
||||
throw error
|
||||
@@ -340,18 +342,20 @@ export async function claimInvite(code: string): Promise<void> {
|
||||
message: 'Invite claimed successfully',
|
||||
level: 'info'
|
||||
})
|
||||
|
||||
return res.json()
|
||||
} catch (error) {
|
||||
// Only capture network errors (not HTTP errors we already captured)
|
||||
if (!isHttpError(error, 'Failed to claim invite:')) {
|
||||
captureApiError(
|
||||
error as Error,
|
||||
'/invite/{code}/claim',
|
||||
'/invite_code/{code}/claim',
|
||||
'network_error',
|
||||
undefined,
|
||||
'claim_invite',
|
||||
{
|
||||
route_template: '/invite/{code}/claim',
|
||||
route_actual: `/invite/${encodeURIComponent(code)}/claim`
|
||||
route_template: '/invite_code/{code}/claim',
|
||||
route_actual: `/invite_code/${encodeURIComponent(code)}/claim`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1827,6 +1827,10 @@
|
||||
"cloudStart_learnAboutButton": "Learn about Cloud",
|
||||
"cloudStart_wantToRun": "Want to run comfyUI locally instead?",
|
||||
"cloudStart_download": "Download ComfyUI",
|
||||
"cloudStart_invited": "YOU'RE INVITED",
|
||||
"cloudStart_invited_signin": "Sign in to continue onto Cloud.",
|
||||
"cloudStart_invited_signup_title": "Don’t have an account yet?",
|
||||
"cloudStart_invited_signup_description": "Sign up instead",
|
||||
"cloudWaitlist_titleLine1": "YOU'RE ON THE",
|
||||
"cloudWaitlist_titleLine2": "WAITLIST 🎉",
|
||||
"cloudWaitlist_message": "You have been added to the waitlist. We will notify you when access is available.",
|
||||
@@ -1835,7 +1839,6 @@
|
||||
"cloudClaimInvite_processingTitle": "Processing Invite Code...",
|
||||
"cloudClaimInvite_claimButton": "Claim Invite",
|
||||
"cloudSorryContactSupport_title": "Sorry, contact support",
|
||||
"cloudVerifyEmail_title": "Email Verification",
|
||||
"cloudPrivateBeta_title": "Cloud is currently in private beta",
|
||||
"cloudPrivateBeta_desc": "Sign in to join the waitlist. We'll notify you when it's your turn. Already been notified? Sign in start using Cloud.",
|
||||
"cloudForgotPassword_title": "Forgot Password",
|
||||
@@ -1851,5 +1854,28 @@
|
||||
"cloudSurvey_steps_familiarity": "How familiar are you with ComfyUI?",
|
||||
"cloudSurvey_steps_purpose": "What will you primarily use ComfyUI for?",
|
||||
"cloudSurvey_steps_industry": "What's your primary industry?",
|
||||
"cloudSurvey_steps_making": "What do you plan on making?"
|
||||
"cloudSurvey_steps_making": "What do you plan on making?",
|
||||
"cloudVerifyEmail_toast_message": "We've sent a verification email to {email}. Please check your inbox and click the link to verify your email address.",
|
||||
"cloudVerifyEmail_failed_toast_message": "Failed to send verification email. Please contact support.",
|
||||
"cloudVerifyEmail_title": "Check your email",
|
||||
"cloudVerifyEmail_back": "Back",
|
||||
"cloudVerifyEmail_sent": "We've sent a verification link to:",
|
||||
"cloudVerifyEmail_clickToContinue": "Click the link in that email to automatically continue onto the next steps.",
|
||||
"cloudVerifyEmail_didntReceive": "Didn't receive the email?",
|
||||
"cloudVerifyEmail_resend": "Resend email",
|
||||
"cloudVerifyEmail_toast_success": "Verification email has been sent to {email}.",
|
||||
"cloudVerifyEmail_toast_failed": "Failed to send verification email. Please try again.",
|
||||
"cloudInvite_title": "YOU'RE INVITED",
|
||||
"cloudInvite_subtitle": "This invite can only be used once. Double check you’re signed into the account you want to use.",
|
||||
"cloudInvite_switchAccounts": "Switch accounts",
|
||||
"cloudInvite_signedInAs": "Signed in as:",
|
||||
"cloudInvite_acceptButton": "Accept invite",
|
||||
"cloudInvite_placeholderEmail": "email@email.com",
|
||||
"cloudInvite_processing": "Processing...",
|
||||
"cloudInvite_alreadyClaimed_prefix": "It looks like this invite has already been claimed by",
|
||||
"cloudInvite_expired_prefix": "It looks like this invite is expired.",
|
||||
"cloudInvite_unknownEmail": "this account",
|
||||
"cloudInvite_expired": "This invite has expired.",
|
||||
"cloudInvite_contactLink": "Contact us here",
|
||||
"cloudInvite_contactLink_suffix": "for questions."
|
||||
}
|
||||
|
||||
@@ -1709,23 +1709,23 @@
|
||||
"zoomToFit": "화면에 맞게 확대"
|
||||
},
|
||||
"cloudFooter_needHelp": "도움이 필요하신가요?",
|
||||
"cloudStart_title": "바로 창작, 단 몇 초면 충분",
|
||||
"cloudStart_desc": "설정 불필요. 모든 기기에서 작동.",
|
||||
"cloudStart_explain": "다수의 결과를 동시 생성. 프로젝트를 쉽게 공유.",
|
||||
"cloudStart_title": "창작은 단 몇 초면 충분해요",
|
||||
"cloudStart_desc": "설정없이도, 기기의 제약없이",
|
||||
"cloudStart_explain": "한번에 다수의 결과물을, 워크플로우로 쉽게 공유해요.",
|
||||
"cloudStart_learnAboutButton": "Cloud에 대해 알아보기",
|
||||
"cloudStart_wantToRun": "대신 ComfyUI를 로컬에서 실행하고 싶으신가요?",
|
||||
"cloudStart_wantToRun": "ComfyUI를 로컬에서 실행해보고 싶다면?",
|
||||
"cloudStart_download": "ComfyUI 다운로드",
|
||||
"cloudWaitlist_titleLine1": "당신은",
|
||||
"cloudWaitlist_titleLine2": "대기 목록에 있습니다 🎉",
|
||||
"cloudWaitlist_message": "대기 목록에 등록되었습니다. 베타 액세스가 가능할 때 알려드리겠습니다.",
|
||||
"cloudWaitlist_questionsText": "궁금한 점이 있으신가요? 문의하기",
|
||||
"cloudWaitlist_contactLink": "여기",
|
||||
"cloudClaimInvite_processingTitle": "초대 코드 처리 중...",
|
||||
"cloudClaimInvite_claimButton": "초대 신청",
|
||||
"cloudSorryContactSupport_title": "죄송합니다, 지원팀에 문의하세요",
|
||||
"cloudWaitlist_titleLine1": "방금",
|
||||
"cloudWaitlist_titleLine2": "대기자 명단에 등록되었습니다 🎉",
|
||||
"cloudWaitlist_message": "대기자 명단에 등록되었습니다. 베타 버전이 오픈되면 알려드릴게요.",
|
||||
"cloudWaitlist_questionsText": "궁금한 점이 있으신가요? 문의는",
|
||||
"cloudWaitlist_contactLink": "여기로",
|
||||
"cloudClaimInvite_processingTitle": "초대 코드 확인중...",
|
||||
"cloudClaimInvite_claimButton": "초대 요청하기",
|
||||
"cloudSorryContactSupport_title": "죄송합니다, 지원팀에 문의해주세요",
|
||||
"cloudVerifyEmail_title": "이메일 확인",
|
||||
"cloudPrivateBeta_title": "Cloud는 현재 비공개 베타입니다",
|
||||
"cloudPrivateBeta_desc": "로그인하여 대기 목록에 등록하세요. 베타 액세스가 가능할 때 알려드리겠습니다. 이미 알림을 받으셨나요? 로그인하여 Comfy Cloud를 시작하세요.",
|
||||
"cloudPrivateBeta_title": "Cloud는 현재 비공개 베타 버전입니다",
|
||||
"cloudPrivateBeta_desc": "로그인하여 대기자 명단에 등록하세요. 베타 버전이 오픈될 때 알려드릴게요. 이미 알림을 받으셨다면? 로그인하여 Comfy Cloud를 시작해보세요.",
|
||||
"cloudForgotPassword_title": "비밀번호 찾기",
|
||||
"cloudForgotPassword_instructions": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드리겠습니다.",
|
||||
"cloudForgotPassword_emailLabel": "이메일",
|
||||
@@ -1736,8 +1736,8 @@
|
||||
"cloudForgotPassword_emailRequired": "이메일은 필수 입력 항목입니다",
|
||||
"cloudForgotPassword_passwordResetSent": "비밀번호 재설정이 전송되었습니다",
|
||||
"cloudForgotPassword_passwordResetError": "비밀번호 재설정 이메일 전송에 실패했습니다",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUI 경험 수준은 어떻게 되시나요?",
|
||||
"cloudSurvey_steps_purpose": "ComfyUI의 주요 용도는 무엇인가요?",
|
||||
"cloudSurvey_steps_industry": "주요 업계는 무엇인가요?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUI 경험도는 어떻게 되시나요?",
|
||||
"cloudSurvey_steps_purpose": "ComfyUI의 주된 목적은 어떻게 되나요?",
|
||||
"cloudSurvey_steps_industry": "어떤 업계/업종에 근무하시나요?",
|
||||
"cloudSurvey_steps_making": "어떤 종류의 콘텐츠를 만들 계획이신가요?"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,13 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
import('@/platform/onboarding/cloud/UserCheckView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'code/:code',
|
||||
name: 'cloud-invite-code',
|
||||
component: () =>
|
||||
import('@/platform/onboarding/cloud/CloudInviteEntryView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'invite-check',
|
||||
name: 'cloud-invite-check',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,7 +18,6 @@ const PUBLIC_ROUTE_NAMES = new Set([
|
||||
'cloud-login',
|
||||
'cloud-signup',
|
||||
'cloud-forgot-password',
|
||||
'cloud-verify-email',
|
||||
'cloud-sorry-contact-support'
|
||||
])
|
||||
|
||||
@@ -30,7 +29,6 @@ const isPublicRoute = (to: RouteLocationNormalized) => {
|
||||
path === '/cloud/login' ||
|
||||
path === '/cloud/signup' ||
|
||||
path === '/cloud/forgot-password' ||
|
||||
path === '/cloud/verify-email' ||
|
||||
path === '/cloud/sorry-contact-support'
|
||||
)
|
||||
return true
|
||||
@@ -250,6 +248,12 @@ router.beforeEach(async (to, _from, next) => {
|
||||
// For root path, check actual user status to handle waitlisted users
|
||||
if (!isElectron() && isLoggedIn && to.path === '/') {
|
||||
try {
|
||||
// Check email verification first
|
||||
const authStore = useFirebaseAuthStore()
|
||||
if (!authStore.isEmailVerified) {
|
||||
return next({ name: 'cloud-verify-email' })
|
||||
}
|
||||
|
||||
// Import auth functions dynamically to avoid circular dependency
|
||||
const { getUserCloudStatus, getSurveyCompletedStatus } = await import(
|
||||
'@/api/auth'
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
browserLocalPersistence,
|
||||
createUserWithEmailAndPassword,
|
||||
onAuthStateChanged,
|
||||
sendEmailVerification,
|
||||
sendPasswordResetEmail,
|
||||
setPersistence,
|
||||
signInWithEmailAndPassword,
|
||||
@@ -75,6 +76,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
const isAuthenticated = computed(() => !!currentUser.value)
|
||||
const userEmail = computed(() => currentUser.value?.email)
|
||||
const userId = computed(() => currentUser.value?.uid)
|
||||
const isEmailVerified = computed(
|
||||
() => currentUser.value?.emailVerified ?? false
|
||||
)
|
||||
|
||||
// Get auth from VueFire and listen for auth state changes
|
||||
// From useFirebaseAuth docs:
|
||||
@@ -289,6 +293,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
await updatePassword(currentUser.value, newPassword)
|
||||
}
|
||||
|
||||
/** Send email verification to current user */
|
||||
const verifyEmail = async (): Promise<void> => {
|
||||
if (!currentUser.value) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
await sendEmailVerification(currentUser.value)
|
||||
}
|
||||
|
||||
const addCredits = async (
|
||||
requestBodyContent: CreditPurchasePayload
|
||||
): Promise<CreditPurchaseResponse> => {
|
||||
@@ -364,6 +376,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
// State
|
||||
loading,
|
||||
currentUser,
|
||||
isEmailVerified,
|
||||
isInitialized,
|
||||
balance,
|
||||
lastBalanceUpdateTime,
|
||||
@@ -387,6 +400,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
accessBillingPortal,
|
||||
sendPasswordReset,
|
||||
updatePassword: _updatePassword,
|
||||
getAuthHeader
|
||||
getAuthHeader,
|
||||
verifyEmail
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user