feature: cloud onboarding scaffolding

This commit is contained in:
Jin Yi
2025-09-11 17:04:06 +09:00
parent f1fbab6e1f
commit 49e530295e
11 changed files with 1050 additions and 18 deletions

28
src/api/me.ts Normal file
View 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
View 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.'
}
}

View File

@@ -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"
}
}
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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

View 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 }
}
]