mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-01 19:20:10 +00:00
feature: cloud onboarding scaffolding
This commit is contained in:
28
src/api/me.ts
Normal file
28
src/api/me.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Mock API for user onboarding status
|
||||
// TODO: Replace with actual API calls when backend is ready
|
||||
|
||||
export interface UserOnboardingStatus {
|
||||
surveyTaken: boolean
|
||||
whitelisted: boolean
|
||||
email?: string
|
||||
userId?: string
|
||||
}
|
||||
|
||||
// Mock data storage (in production, this would come from backend)
|
||||
let mockUserData: UserOnboardingStatus = {
|
||||
surveyTaken: false,
|
||||
whitelisted: false
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<UserOnboardingStatus> {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
// Return mock data
|
||||
return { ...mockUserData }
|
||||
}
|
||||
|
||||
// Helper function to update mock data (for testing)
|
||||
export function setMockUserData(data: Partial<UserOnboardingStatus>) {
|
||||
mockUserData = { ...mockUserData, ...data }
|
||||
}
|
||||
38
src/api/survey.ts
Normal file
38
src/api/survey.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Mock API for survey submission
|
||||
// TODO: Replace with actual API calls when backend is ready
|
||||
|
||||
import { setMockUserData } from './me'
|
||||
|
||||
export interface SurveyPayload {
|
||||
useCase?: string
|
||||
experience?: string
|
||||
teamSize?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface SurveyResponse {
|
||||
whitelisted: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function submitSurvey(_payload: SurveyPayload): Promise<SurveyResponse> {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Mock logic: whitelist some users based on payload
|
||||
// In production, this would be determined by backend
|
||||
const isWhitelisted = Math.random() > 0.5 // 50% chance for demo
|
||||
|
||||
// Update mock user data
|
||||
setMockUserData({
|
||||
surveyTaken: true,
|
||||
whitelisted: isWhitelisted
|
||||
})
|
||||
|
||||
return {
|
||||
whitelisted: isWhitelisted,
|
||||
message: isWhitelisted
|
||||
? 'Welcome! You have been granted access.'
|
||||
: 'Thank you! You have been added to the waitlist.'
|
||||
}
|
||||
}
|
||||
@@ -1565,6 +1565,11 @@
|
||||
"confirmPasswordLabel": "Confirm Password",
|
||||
"confirmPasswordPlaceholder": "Enter the same password again",
|
||||
"forgotPassword": "Forgot password?",
|
||||
"passwordResetInstructions": "Enter your email address and we'll send you a link to reset your password.",
|
||||
"sendResetLink": "Send reset link",
|
||||
"backToLogin": "Back to login",
|
||||
"didntReceiveEmail": "Didn't receive an email? Contact us at",
|
||||
"passwordResetError": "Failed to send password reset email. Please try again.",
|
||||
"loginButton": "Log in",
|
||||
"orContinueWith": "Or continue with",
|
||||
"loginWithGoogle": "Log in with Google",
|
||||
@@ -1721,5 +1726,27 @@
|
||||
"showGroups": "Show Frames/Groups",
|
||||
"renderBypassState": "Render Bypass State",
|
||||
"renderErrorState": "Render Error State"
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"survey": {
|
||||
"title": "Cloud Survey",
|
||||
"placeholder": "Survey questions placeholder"
|
||||
},
|
||||
"waitlist": {
|
||||
"title": "Cloud Waitlist",
|
||||
"message": "You have been added to the waitlist. We will notify you when access is available."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Forgot Password",
|
||||
"instructions": "Enter your email address and we'll send you a link to reset your password.",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"sendResetLink": "Send reset link",
|
||||
"backToLogin": "Back to login",
|
||||
"didntReceiveEmail": "Didn't receive an email? Contact us at",
|
||||
"passwordResetSent": "Password reset email sent",
|
||||
"passwordResetError": "Failed to send password reset email. Please try again.",
|
||||
"emailRequired": "Email is required"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
src/platform/onboarding/cloud/CloudForgotPasswordView.vue
Normal file
128
src/platform/onboarding/cloud/CloudForgotPasswordView.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div class="flex items-center justify-center min-h-screen p-8">
|
||||
<div class="w-96 p-2">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<h1 class="text-2xl font-medium leading-normal my-0">
|
||||
{{ t('cloudOnboarding.forgotPassword.title') }}
|
||||
</h1>
|
||||
<p class="text-base my-0 text-muted">
|
||||
{{ t('cloudOnboarding.forgotPassword.instructions') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form class="flex flex-col gap-6" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
class="opacity-80 text-base font-medium mb-2"
|
||||
for="reset-email"
|
||||
>
|
||||
{{ t('cloudOnboarding.forgotPassword.emailLabel') }}
|
||||
</label>
|
||||
<InputText
|
||||
id="reset-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
:placeholder="
|
||||
t('cloudOnboarding.forgotPassword.emailPlaceholder')
|
||||
"
|
||||
class="h-10"
|
||||
:invalid="!!errorMessage && !email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
<small v-if="errorMessage" class="text-red-500">
|
||||
{{ errorMessage }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<Message v-if="successMessage" severity="success">
|
||||
{{ successMessage }}
|
||||
</Message>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
:label="t('cloudOnboarding.forgotPassword.sendResetLink')"
|
||||
:loading="loading"
|
||||
:disabled="!email || loading"
|
||||
class="h-10 font-medium"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
:label="t('cloudOnboarding.forgotPassword.backToLogin')"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-10"
|
||||
@click="navigateToLogin"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Help text -->
|
||||
<p class="text-xs text-muted mt-8 text-center">
|
||||
{{ t('cloudOnboarding.forgotPassword.didntReceiveEmail') }}
|
||||
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
|
||||
hello@comfy.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseViewTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const navigateToLogin = () => {
|
||||
void router.push({ name: 'cloud-login' })
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!email.value) {
|
||||
errorMessage.value = t('cloudOnboarding.forgotPassword.emailRequired')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
// sendPasswordReset is already wrapped and returns a promise
|
||||
await authActions.sendPasswordReset(email.value)
|
||||
|
||||
successMessage.value = t('cloudOnboarding.forgotPassword.passwordResetSent')
|
||||
|
||||
// Optionally redirect to login after a delay
|
||||
setTimeout(() => {
|
||||
navigateToLogin()
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('Password reset error:', error)
|
||||
errorMessage.value = t('cloudOnboarding.forgotPassword.passwordResetError')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
170
src/platform/onboarding/cloud/CloudLoginView.vue
Normal file
170
src/platform/onboarding/cloud/CloudLoginView.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div class="flex items-center justify-center min-h-screen p-8">
|
||||
<div class="w-96 p-2">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<h1 class="text-2xl 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>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
|
||||
<!-- Form -->
|
||||
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
|
||||
|
||||
<!-- Divider -->
|
||||
<Divider align="center" layout="horizontal" class="my-8">
|
||||
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
|
||||
</Divider>
|
||||
|
||||
<!-- Social Login Buttons -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.login.loginWithGoogle') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
{{ t('auth.login.loginWithGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
<p class="text-xs text-muted mt-8">
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
{{ t('auth.login.andText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
{{ t('auth.login.questionsContactPrefix') }}
|
||||
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
|
||||
hello@comfy.org</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseViewTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getMe } from '@/api/me'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import CloudSignInForm from '@/platform/onboarding/cloud/components/CloudSignInForm.vue'
|
||||
import { type SignInData } from '@/schemas/signInSchema'
|
||||
import { translateAuthError } from '@/utils/authErrorTranslation'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const isSecureContext = window.isSecureContext
|
||||
const authError = ref('')
|
||||
|
||||
const navigateToSignup = () => {
|
||||
void router.push({ name: 'cloud-signup', query: route.query })
|
||||
}
|
||||
|
||||
const onSuccess = async () => {
|
||||
try {
|
||||
// Get user onboarding status
|
||||
const me = await getMe()
|
||||
|
||||
// Check if there's a redirect URL
|
||||
const redirectPath = route.query.redirect as string
|
||||
|
||||
// Navigate based on user status
|
||||
if (!me.surveyTaken) {
|
||||
await router.push({ name: 'cloud-survey' })
|
||||
} else if (!me.whitelisted) {
|
||||
await router.push({ name: 'cloud-waitlist' })
|
||||
} else if (redirectPath) {
|
||||
// User is fully onboarded, go to redirect URL
|
||||
await router.push(redirectPath)
|
||||
} else {
|
||||
// User is fully onboarded, go to main app
|
||||
await router.push({ path: '/' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking user status:', error)
|
||||
// On error, go to main app
|
||||
void router.push({ path: '/' })
|
||||
}
|
||||
}
|
||||
|
||||
// Custom error handler for inline display
|
||||
const inlineErrorHandler = (error: unknown) => {
|
||||
authError.value = translateAuthError(error)
|
||||
authActions.reportError(error)
|
||||
}
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
authError.value = ''
|
||||
if (await authActions.signInWithGoogle(inlineErrorHandler)()) {
|
||||
await onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithGithub = async () => {
|
||||
authError.value = ''
|
||||
if (await authActions.signInWithGithub(inlineErrorHandler)()) {
|
||||
await onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithEmail = async (values: SignInData) => {
|
||||
authError.value = ''
|
||||
if (
|
||||
await authActions.signInWithEmail(
|
||||
values.email,
|
||||
values.password,
|
||||
inlineErrorHandler
|
||||
)()
|
||||
) {
|
||||
await onSuccess()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
175
src/platform/onboarding/cloud/CloudSignupView.vue
Normal file
175
src/platform/onboarding/cloud/CloudSignupView.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div class="flex items-center justify-center min-h-screen p-8">
|
||||
<div class="w-96 p-2">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<h1 class="text-2xl font-medium leading-normal my-0">
|
||||
{{ t('auth.signup.title') }}
|
||||
</h1>
|
||||
<p class="text-base my-0">
|
||||
<span class="text-muted">{{
|
||||
t('auth.signup.alreadyHaveAccount')
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-1 cursor-pointer text-blue-500"
|
||||
@click="navigateToLogin"
|
||||
>{{ t('auth.signup.signIn') }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
|
||||
<!-- Form -->
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
|
||||
|
||||
<!-- Divider -->
|
||||
<Divider align="center" layout="horizontal" class="my-8">
|
||||
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
|
||||
</Divider>
|
||||
|
||||
<!-- Social Login Buttons -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.signup.signUpWithGoogle') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
{{ t('auth.signup.signUpWithGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
<p class="text-xs text-muted mt-8">
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
{{ t('auth.login.andText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
{{ t('auth.login.questionsContactPrefix') }}
|
||||
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
|
||||
hello@comfy.org</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseViewTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Message from 'primevue/message'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getMe } from '@/api/me'
|
||||
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { SignUpData } from '@/schemas/signInSchema'
|
||||
import { translateAuthError } from '@/utils/authErrorTranslation'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authActions = useFirebaseAuthActions()
|
||||
const isSecureContext = window.isSecureContext
|
||||
const authError = ref('')
|
||||
const userIsInChina = ref(false)
|
||||
|
||||
const navigateToLogin = () => {
|
||||
void router.push({ name: 'cloud-login', query: route.query })
|
||||
}
|
||||
|
||||
const onSuccess = async () => {
|
||||
try {
|
||||
// Get user onboarding status (new users won't have survey/whitelist yet)
|
||||
const me = await getMe()
|
||||
|
||||
// Navigate based on user status
|
||||
if (!me.surveyTaken) {
|
||||
void router.push({ name: 'cloud-survey' })
|
||||
} else if (!me.whitelisted) {
|
||||
void router.push({ name: 'cloud-waitlist' })
|
||||
} else {
|
||||
// User is fully onboarded (rare for signup, but possible)
|
||||
void router.push({ path: '/' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking user status:', error)
|
||||
// For new signups, default to survey
|
||||
void router.push({ name: 'cloud-survey' })
|
||||
}
|
||||
}
|
||||
|
||||
// Custom error handler for inline display
|
||||
const inlineErrorHandler = (error: unknown) => {
|
||||
authError.value = translateAuthError(error)
|
||||
authActions.reportError(error)
|
||||
}
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
authError.value = ''
|
||||
if (await authActions.signInWithGoogle(inlineErrorHandler)()) {
|
||||
await onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithGithub = async () => {
|
||||
authError.value = ''
|
||||
if (await authActions.signInWithGithub(inlineErrorHandler)()) {
|
||||
await onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData) => {
|
||||
authError.value = ''
|
||||
if (
|
||||
await authActions.signUpWithEmail(
|
||||
values.email,
|
||||
values.password,
|
||||
inlineErrorHandler
|
||||
)()
|
||||
) {
|
||||
await onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
</script>
|
||||
146
src/platform/onboarding/cloud/CloudSurveyView.vue
Normal file
146
src/platform/onboarding/cloud/CloudSurveyView.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div class="flex flex-col items-center justify-center min-h-screen p-8">
|
||||
<div class="w-full max-w-md">
|
||||
<h1 class="text-3xl font-bold mb-8">
|
||||
{{ t('cloudOnboarding.survey.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Survey Form -->
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="useCase" class="font-medium">
|
||||
What will you use ComfyUI for?
|
||||
</label>
|
||||
<Select
|
||||
v-model="surveyData.useCase"
|
||||
:options="useCaseOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Select a use case"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="experience" class="font-medium">
|
||||
What's your experience level?
|
||||
</label>
|
||||
<Select
|
||||
v-model="surveyData.experience"
|
||||
:options="experienceOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Select your experience"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="teamSize" class="font-medium">
|
||||
Team size
|
||||
</label>
|
||||
<Select
|
||||
v-model="surveyData.teamSize"
|
||||
:options="teamSizeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Select team size"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Message v-if="error" severity="error">
|
||||
{{ error }}
|
||||
</Message>
|
||||
|
||||
<Button
|
||||
label="Submit Survey"
|
||||
@click="submitSurvey"
|
||||
:loading="loading"
|
||||
:disabled="!isFormValid"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseViewTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import Select from 'primevue/select'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { submitSurvey as submitSurveyAPI } from '@/api/survey'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const surveyData = ref({
|
||||
useCase: '',
|
||||
experience: '',
|
||||
teamSize: ''
|
||||
})
|
||||
|
||||
const useCaseOptions = [
|
||||
{ label: 'Personal Projects', value: 'personal' },
|
||||
{ label: 'Professional Work', value: 'professional' },
|
||||
{ label: 'Research', value: 'research' },
|
||||
{ label: 'Education', value: 'education' },
|
||||
{ label: 'Other', value: 'other' }
|
||||
]
|
||||
|
||||
const experienceOptions = [
|
||||
{ label: 'Beginner', value: 'beginner' },
|
||||
{ label: 'Intermediate', value: 'intermediate' },
|
||||
{ label: 'Advanced', value: 'advanced' },
|
||||
{ label: 'Expert', value: 'expert' }
|
||||
]
|
||||
|
||||
const teamSizeOptions = [
|
||||
{ label: 'Just me', value: '1' },
|
||||
{ label: '2-5 people', value: '2-5' },
|
||||
{ label: '6-20 people', value: '6-20' },
|
||||
{ label: '20+ people', value: '20+' }
|
||||
]
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return !!(
|
||||
surveyData.value.useCase &&
|
||||
surveyData.value.experience &&
|
||||
surveyData.value.teamSize
|
||||
)
|
||||
})
|
||||
|
||||
const submitSurvey = async () => {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await submitSurveyAPI(surveyData.value)
|
||||
|
||||
if (response.whitelisted) {
|
||||
// User is whitelisted, go to main app
|
||||
void router.push({ path: '/' })
|
||||
} else {
|
||||
// User needs to wait
|
||||
void router.push({ name: 'cloud-waitlist' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Survey submission error:', err)
|
||||
error.value = 'Failed to submit survey. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
96
src/platform/onboarding/cloud/CloudWaitlistView.vue
Normal file
96
src/platform/onboarding/cloud/CloudWaitlistView.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div class="flex flex-col items-center justify-center min-h-screen p-8">
|
||||
<div class="w-full max-w-md text-center">
|
||||
<h1 class="text-3xl font-bold mb-8">
|
||||
{{ t('cloudOnboarding.waitlist.title') }}
|
||||
</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<p class="text-gray-400">
|
||||
{{ t('cloudOnboarding.waitlist.message') }}
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-800 rounded-lg p-6">
|
||||
<i class="pi pi-clock text-4xl text-gray-500 mb-4"></i>
|
||||
<p class="text-sm text-gray-400">
|
||||
Your request is being reviewed. We'll notify you via email once you're approved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="checkMessage" :severity="checkMessageType">
|
||||
{{ checkMessage }}
|
||||
</Message>
|
||||
|
||||
<div class="flex gap-4 justify-center">
|
||||
<Button
|
||||
label="Check Status"
|
||||
icon="pi pi-refresh"
|
||||
@click="checkStatus"
|
||||
:loading="loading"
|
||||
severity="secondary"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Return to Home"
|
||||
@click="handleReturn"
|
||||
outlined
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseViewTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Message from 'primevue/message'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getMe } from '@/api/me'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const checkMessage = ref('')
|
||||
const checkMessageType = ref<'success' | 'info' | 'warn' | 'error'>('info')
|
||||
|
||||
const checkStatus = async () => {
|
||||
loading.value = true
|
||||
checkMessage.value = ''
|
||||
|
||||
try {
|
||||
const me = await getMe()
|
||||
|
||||
if (me.whitelisted) {
|
||||
// User has been approved!
|
||||
checkMessage.value = 'Great news! You have been approved.'
|
||||
checkMessageType.value = 'success'
|
||||
|
||||
// Redirect to main app after a short delay
|
||||
setTimeout(() => {
|
||||
void router.push({ path: '/' })
|
||||
}, 2000)
|
||||
} else {
|
||||
// Still waiting
|
||||
checkMessage.value = 'Your application is still under review. Please check back later.'
|
||||
checkMessageType.value = 'info'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking status:', error)
|
||||
checkMessage.value = 'Failed to check status. Please try again.'
|
||||
checkMessageType.value = 'error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReturn = () => {
|
||||
void router.push({ path: '/' })
|
||||
}
|
||||
</script>
|
||||
109
src/platform/onboarding/cloud/components/CloudSignInForm.vue
Normal file
109
src/platform/onboarding/cloud/components/CloudSignInForm.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<Form
|
||||
v-slot="$form"
|
||||
class="flex flex-col gap-6"
|
||||
:resolver="zodResolver(signInSchema)"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<!-- Email Field -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
|
||||
{{ t('auth.login.emailLabel') }}
|
||||
</label>
|
||||
<InputText
|
||||
:id="emailInputId"
|
||||
autocomplete="email"
|
||||
class="h-10"
|
||||
name="email"
|
||||
type="text"
|
||||
:placeholder="t('auth.login.emailPlaceholder')"
|
||||
:invalid="$form.email?.invalid"
|
||||
/>
|
||||
<small v-if="$form.email?.invalid" class="text-red-500">{{
|
||||
$form.email.error.message
|
||||
}}</small>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label
|
||||
class="opacity-80 text-base font-medium"
|
||||
for="cloud-sign-in-password"
|
||||
>
|
||||
{{ t('auth.login.passwordLabel') }}
|
||||
</label>
|
||||
<router-link
|
||||
:to="{ name: 'cloud-forgot-password' }"
|
||||
class="text-muted text-base font-medium hover:text-blue-500 transition-colors"
|
||||
>
|
||||
{{ t('auth.login.forgotPassword') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<Password
|
||||
input-id="cloud-sign-in-password"
|
||||
pt:pc-input-text:root:autocomplete="current-password"
|
||||
name="password"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
:placeholder="t('auth.login.passwordPlaceholder')"
|
||||
:class="{ 'p-invalid': $form.password?.invalid }"
|
||||
fluid
|
||||
class="h-10"
|
||||
/>
|
||||
<small v-if="$form.password?.invalid" class="text-red-500">{{
|
||||
$form.password.error.message
|
||||
}}</small>
|
||||
</div>
|
||||
|
||||
<!-- Auth Error Message -->
|
||||
<Message v-if="authError" severity="error">
|
||||
{{ authError }}
|
||||
</Message>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<ProgressSpinner v-if="loading" class="w-8 h-8" />
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
:label="t('auth.login.loginButton')"
|
||||
class="h-10 font-medium mt-4"
|
||||
/>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormSubmitEvent } from '@primevue/forms'
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import Button from 'primevue/button'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Password from 'primevue/password'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { type SignInData, signInSchema } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
authError?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignInData]
|
||||
}>()
|
||||
|
||||
const emailInputId = 'cloud-sign-in-email'
|
||||
|
||||
const onSubmit = (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
emit('submit', event.values as SignInData)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
116
src/router.ts
116
src/router.ts
@@ -6,14 +6,65 @@ import {
|
||||
createWebHistory
|
||||
} from 'vue-router'
|
||||
|
||||
import { getMe } from '@/api/me'
|
||||
import { cloudOnboardingRoutes } from '@/router/onboarding.cloud'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
|
||||
|
||||
const PUBLIC_ROUTE_NAMES = new Set([
|
||||
'cloud-login',
|
||||
'cloud-signup',
|
||||
'cloud-forgot-password',
|
||||
'verify-email'
|
||||
])
|
||||
|
||||
const isPublicRoute = (to: RouteLocationNormalized) => {
|
||||
const name = String(to.name)
|
||||
if (PUBLIC_ROUTE_NAMES.has(name)) return true
|
||||
const path = to.path
|
||||
// 로그인 전에도 접근 가능해야 자연스러운 경로들
|
||||
if (path === '/login' || path === '/signup' || path === '/forgot-password')
|
||||
return true
|
||||
if (path.startsWith('/code')) return true // /code/:inviteCode
|
||||
if (path.startsWith('/verify-email')) return true // 이메일 인증 콜백
|
||||
return false
|
||||
}
|
||||
|
||||
const isFileProtocol = window.location.protocol === 'file:'
|
||||
const basePath = isElectron() ? '/' : window.location.pathname
|
||||
|
||||
// Determine base path for the router
|
||||
// - Electron always uses root
|
||||
// - Web uses root unless serving from a real subdirectory (e.g., /ComfyBackendDirect)
|
||||
function getBasePath(): string {
|
||||
if (isElectron()) {
|
||||
return '/'
|
||||
}
|
||||
|
||||
const pathname = window.location.pathname
|
||||
|
||||
// These are app routes, not deployment subdirectories
|
||||
const appRoutes = [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/forgot-password',
|
||||
'/survey',
|
||||
'/waitlist'
|
||||
]
|
||||
const isAppRoute = appRoutes.some((route) => pathname.startsWith(route))
|
||||
|
||||
// Use root if we're on an app route or at root
|
||||
if (pathname === '/' || isAppRoute) {
|
||||
return '/'
|
||||
}
|
||||
|
||||
// Otherwise, this might be a subdirectory deployment (e.g., /ComfyBackendDirect)
|
||||
return pathname
|
||||
}
|
||||
|
||||
const basePath = getBasePath()
|
||||
|
||||
const guardElectronAccess = (
|
||||
_to: RouteLocationNormalized,
|
||||
@@ -35,6 +86,8 @@ const router = createRouter({
|
||||
// we need this base path or assets will incorrectly resolve from 'http://localhost:7801/'
|
||||
createWebHistory(basePath),
|
||||
routes: [
|
||||
// Cloud onboarding routes
|
||||
...cloudOnboardingRoutes,
|
||||
{
|
||||
path: '/',
|
||||
component: LayoutDefault,
|
||||
@@ -132,7 +185,7 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
// Global authentication guard
|
||||
router.beforeEach(async (_to, _from, next) => {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
// Wait for Firebase auth to initialize
|
||||
@@ -147,25 +200,54 @@ router.beforeEach(async (_to, _from, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user is authenticated (Firebase or API key)
|
||||
// Check if user is authenticated
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
const isLoggedIn = !!authHeader
|
||||
|
||||
if (!authHeader) {
|
||||
// User is not authenticated, show sign-in dialog
|
||||
const dialogService = useDialogService()
|
||||
const loginSuccess = await dialogService.showSignInDialog()
|
||||
|
||||
if (loginSuccess) {
|
||||
// After successful login, proceed to the intended route
|
||||
next()
|
||||
} else {
|
||||
// User cancelled login, stay on current page or redirect to home
|
||||
next(false)
|
||||
// Allow public routes without authentication
|
||||
if (isPublicRoute(to)) {
|
||||
// If logged in and trying to access login/signup, redirect based on status
|
||||
if (
|
||||
isLoggedIn &&
|
||||
(to.name === 'cloud-login' || to.name === 'cloud-signup')
|
||||
) {
|
||||
try {
|
||||
const me = await getMe()
|
||||
if (!me.surveyTaken) {
|
||||
return next({ name: 'cloud-survey' })
|
||||
}
|
||||
if (!me.whitelisted) {
|
||||
return next({ name: 'cloud-waitlist' })
|
||||
}
|
||||
return next({ path: '/' })
|
||||
} catch (error) {
|
||||
console.error('Error fetching user status:', error)
|
||||
return next({ path: '/' })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User is authenticated, proceed
|
||||
next()
|
||||
// Allow access to public routes
|
||||
return next()
|
||||
}
|
||||
|
||||
// Handle protected routes
|
||||
if (!isLoggedIn) {
|
||||
// For Electron, use dialog
|
||||
if (isElectron()) {
|
||||
const dialogService = useDialogService()
|
||||
const loginSuccess = await dialogService.showSignInDialog()
|
||||
return loginSuccess ? next() : next(false)
|
||||
}
|
||||
|
||||
// For web, redirect to login
|
||||
const redirectTarget = to.fullPath === '/' ? undefined : to.fullPath
|
||||
return next({
|
||||
name: 'cloud-login',
|
||||
query: redirectTarget ? { redirect: redirectTarget } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
// User is logged in and accessing protected route
|
||||
return next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
33
src/router/onboarding.cloud.ts
Normal file
33
src/router/onboarding.cloud.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'cloud-login',
|
||||
component: () => import('@/platform/onboarding/cloud/CloudLoginView.vue')
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
name: 'cloud-signup',
|
||||
component: () => import('@/platform/onboarding/cloud/CloudSignupView.vue')
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'cloud-forgot-password',
|
||||
component: () =>
|
||||
import('@/platform/onboarding/cloud/CloudForgotPasswordView.vue')
|
||||
},
|
||||
{
|
||||
path: '/survey',
|
||||
name: 'cloud-survey',
|
||||
component: () => import('@/platform/onboarding/cloud/CloudSurveyView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/waitlist',
|
||||
name: 'cloud-waitlist',
|
||||
component: () =>
|
||||
import('@/platform/onboarding/cloud/CloudWaitlistView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user