Proper create account

This commit is contained in:
2025-09-20 13:43:53 +08:00
parent 5d283165d8
commit 534e0f6080
3 changed files with 485 additions and 291 deletions

View File

@@ -0,0 +1,30 @@
<template>
<v-container class="footer">
<div class="d-flex justify-space-between align-center">
<v-select
:items="['English (United States)']"
model-value="English (United States)"
variant="plain"
density="compact"
hide-details
class="flex-grow-0"
/>
<div class="d-flex">
<v-btn variant="text" size="small" class="text-capitalize">Help</v-btn>
<v-btn variant="text" size="small" class="text-capitalize"
>Privacy</v-btn
>
<v-btn variant="text" size="small" class="text-capitalize">Terms</v-btn>
</div>
</div>
</v-container>
</template>
<style scoped>
.footer {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
}
</style>

View File

@@ -1,99 +1,180 @@
<template> <template>
<v-container class="d-flex align-center justify-center fill-height"> <v-container class="d-flex align-center justify-center fill-height">
<v-card class="pa-4" max-width="500"> <v-card max-width="1000" rounded="lg" width="100%">
<v-card-title>Create a new Solar Network ID</v-card-title> <div v-if="isLoading" class="d-flex justify-center mb-4">
<v-overlay :model-value="isLoading" class="align-center justify-center"> <v-progress-linear indeterminate color="primary" height="4" />
<v-progress-circular indeterminate size="64" /> </div>
</v-overlay> <div class="pa-8">
<v-form @submit.prevent="handleCreateAccount"> <div class="mb-4">
<v-text-field <img
v-model="formModel.name" :src="$vuetify.theme.current.dark ? IconDark : IconLight"
label="Username" alt="CloudyLamb"
variant="outlined" height="60"
:rules="nameRules" width="60"
class="mb-4" />
/>
<v-text-field
v-model="formModel.nick"
label="Nickname"
variant="outlined"
:rules="nickRules"
class="mb-4"
/>
<v-text-field
v-model="formModel.email"
label="Email"
placeholder="your@email.com"
variant="outlined"
:rules="emailRules"
class="mb-4"
/>
<v-text-field
v-model="formModel.password"
label="Password"
type="password"
placeholder="Enter your password"
variant="outlined"
:rules="passwordRules"
class="mb-4"
/>
<div class="d-flex justify-center mb-4">
<client-only>
<captcha-widget
:provider="captchaProvider"
:api-key="captchaApiKey"
@verified="onCaptchaVerified"
/>
</client-only>
</div> </div>
<v-row>
<v-col cols="12" lg="6" class="d-flex align-start justify-start">
<div class="md:text-left h-auto">
<div v-if="stage === 'username-nick'">
<h2 class="text-2xl font-bold mb-1">Create your account</h2>
<p class="text-lg">Start with your username and nickname</p>
</div>
<div v-if="stage === 'email'">
<h2 class="text-2xl font-bold mb-1">Add your email</h2>
<p class="text-lg">We'll use this for account verification</p>
</div>
<div v-if="stage === 'password'">
<h2 class="text-2xl font-bold mb-1">Set your password</h2>
<p class="text-lg">Choose a strong password for your account</p>
</div>
<div v-if="stage === 'captcha'">
<h2 class="text-2xl font-bold mb-1">Verify you're human</h2>
<p class="text-lg">
Complete the captcha to create your account
</p>
</div>
</div>
</v-col>
<v-col cols="12" lg="6" class="d-flex align-center justify-stretch">
<div class="w-full d-flex flex-column md:text-right">
<v-window
v-model="activeStageIndex"
class="align-self-stretch pt-2"
>
<!-- Stage 1: Username and Nickname -->
<v-window-item :value="0">
<v-text-field
v-model="formModel.name"
label="Username"
variant="outlined"
:rules="nameRules"
class="mb-2"
@keydown.enter="handleNext"
/>
<v-text-field
v-model="formModel.nick"
label="Nickname"
variant="outlined"
:rules="nickRules"
class="mb-2"
@keydown.enter="handleNext"
/>
<div class="d-flex justify-space-between align-center mt-6">
<v-btn
variant="text"
class="text-capitalize"
to="/auth/login"
>
Login
</v-btn>
<v-btn color="primary" size="large" @click="handleNext">
Next
</v-btn>
</div>
</v-window-item>
<v-btn <!-- Stage 2: Email -->
type="submit" <v-window-item :value="1">
color="primary" <v-text-field
block v-model="formModel.email"
size="large" label="Email"
:disabled="isLoading" placeholder="your@email.com"
> variant="outlined"
Create Account :rules="emailRules"
</v-btn> class="mb-2"
@keydown.enter="handleNext"
/>
<div class="d-flex justify-space-between align-center mt-6">
<v-spacer />
<v-btn color="primary" size="large" @click="handleNext">
Next
</v-btn>
</div>
</v-window-item>
<div class="mt-3 text-center text-body-2 opacity-75"> <!-- Stage 3: Password -->
<v-btn <v-window-item :value="2">
variant="text" <v-text-field
block v-model="formModel.password"
size="small" label="Password"
to="/auth/login" type="password"
> placeholder="Enter your password"
Already have an account? Login variant="outlined"
</v-btn> :rules="passwordRules"
</div> class="mb-2"
</v-form> @keydown.enter="handleNext"
<v-alert />
v-if="error" <div class="d-flex justify-space-between align-center mt-6">
type="error" <v-spacer />
closable <v-btn color="primary" size="large" @click="handleNext">
class="mt-4" Next
@update:model-value="error = null" </v-btn>
> </div>
{{ error }} </v-window-item>
</v-alert>
<!-- Stage 4: Captcha -->
<v-window-item :value="3">
<div class="d-flex justify-center mb-4">
<client-only>
<captcha-widget @verified="onCaptchaVerified" />
</client-only>
</div>
</v-window-item>
</v-window>
<v-alert
v-if="error"
type="error"
closable
class="mt-2"
@update:model-value="error = null"
>
{{ error }}
</v-alert>
</div>
</v-col>
</v-row>
</div>
</v-card> </v-card>
<footer-compact />
</v-container> </v-container>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from "vue" import { ref, reactive, computed } from "vue"
import { useRouter } from "vue-router" import { useRouter } from "vue-router"
import { useSolarNetwork } from "~/composables/useSolarNetwork" import { useSolarNetwork } from "~/composables/useSolarNetwork"
import CaptchaWidget from "~/components/CaptchaWidget.vue" import CaptchaWidget from "~/components/CaptchaWidget.vue"
import IconLight from "~/assets/images/cloudy-lamb.png"
import IconDark from "~/assets/images/cloudy-lamb@dark.png"
const router = useRouter() const router = useRouter()
const api = useSolarNetwork() const api = useSolarNetwork()
const stage = ref<"username-nick" | "email" | "password" | "captcha">(
"username-nick"
)
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
// Computed for v-window active index
const activeStageIndex = computed(() => {
switch (stage.value) {
case "username-nick":
return 0
case "email":
return 1
case "password":
return 2
case "captcha":
return 3
default:
return 0
}
})
const formModel = reactive({ const formModel = reactive({
name: "", name: "",
nick: "", nick: "",
@@ -103,33 +184,104 @@ const formModel = reactive({
captchaToken: "" captchaToken: ""
}) })
// Get captcha provider and API key from global data onMounted(() => {
const captchaProvider = ref((window as { DyPrefetch?: { provider?: string } }).DyPrefetch?.provider || "") formModel.language = navigator.language
const captchaApiKey = ref((window as { DyPrefetch?: { api_key?: string } }).DyPrefetch?.api_key || "") })
const nameRules = [ const nameRules = [
(v: string) => !!v || "Please enter a username", (v: string) => !!v || "Name is required",
(v: string) => v.length >= 2 || "Name must be at least 2 characters long",
(v: string) => v.length <= 256 || "Name must be at most 256 characters long",
(v: string) => (v: string) =>
/^[A-Za-z0-9_-]+$/.test(v) || /^[A-Za-z0-9_-]+$/.test(v) ||
"Username can only contain letters, numbers, underscores, and hyphens." "Name can only contain letters, numbers, underscores, and hyphens."
] ]
const nickRules = [(v: string) => !!v || "Please enter a nickname"] const nickRules = [
(v: string) => !!v || "Nick is required",
(v: string) => v.length <= 256 || "Nick must be at most 256 characters long"
]
const emailRules = [ const emailRules = [
(v: string) => !!v || "Please enter your email", (v: string) => !!v || "Email is required",
(v: string) =>
v.length <= 1024 || "Email must be at most 1024 characters long",
(v: string) =>
/^[^+]+@[^@]+\.[^@]+$/.test(v) ||
"Email address cannot contain '+' symbol.",
(v: string) => /.+@.+\..+/.test(v) || "Please enter a valid email address" (v: string) => /.+@.+\..+/.test(v) || "Please enter a valid email address"
] ]
const passwordRules = [ const passwordRules = [
(v: string) => !!v || "Please enter a password", (v: string) => !!v || "Password is required",
(v: string) => v.length >= 4 || "Password must be at least 4 characters long" (v: string) => v.length >= 4 || "Password must be at least 4 characters long",
(v: string) =>
v.length <= 128 || "Password must be at most 128 characters long"
] ]
const onCaptchaVerified = (token: string) => { const onCaptchaVerified = (token: string) => {
formModel.captchaToken = token formModel.captchaToken = token
handleCreateAccount()
}
function handleNext() {
error.value = null
if (stage.value === "username-nick") {
if (!formModel.name || !formModel.nick) {
error.value = "Please fill in username and nickname"
return
}
if (
!nameRules.every(
(rule) =>
typeof rule(formModel.name) === "boolean" && rule(formModel.name)
)
) {
error.value = "Invalid username"
return
}
if (
!nickRules.every(
(rule) =>
typeof rule(formModel.nick) === "boolean" && rule(formModel.nick)
)
) {
error.value = "Invalid nickname"
return
}
stage.value = "email"
} else if (stage.value === "email") {
if (!formModel.email) {
error.value = "Please enter your email"
return
}
if (
!emailRules.every(
(rule) =>
typeof rule(formModel.email) === "boolean" && rule(formModel.email)
)
) {
error.value = "Invalid email"
return
}
stage.value = "password"
} else if (stage.value === "password") {
if (!formModel.password) {
error.value = "Please enter your password"
return
}
if (
!passwordRules.every(
(rule) =>
typeof rule(formModel.password) === "boolean" &&
rule(formModel.password)
)
) {
error.value = "Invalid password"
return
}
stage.value = "captcha"
}
} }
async function handleCreateAccount() { async function handleCreateAccount() {
@@ -153,9 +305,9 @@ async function handleCreateAccount() {
alert( alert(
"Welcome to Solar Network! Your account has been created successfully. Don't forget to check your email for activation instructions." "Welcome to Solar Network! Your account has been created successfully. Don't forget to check your email for activation instructions."
) )
router.push("/login") router.push("/auth/login")
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'An error occurred' error.value = e instanceof Error ? e.message : "An error occurred"
} finally { } finally {
isLoading.value = false isLoading.value = false
} }

View File

@@ -17,6 +17,22 @@ const stage = ref<
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
// Computed for v-window active index
const activeStageIndex = computed(() => {
switch (stage.value) {
case "find-account":
return 0
case "select-factor":
return 1
case "enter-code":
return 2
case "token-exchange":
return 3
default:
return 0
}
})
// Stage 1: Find Account // Stage 1: Find Account
const accountIdentifier = ref("") const accountIdentifier = ref("")
const deviceId = ref("") const deviceId = ref("")
@@ -230,214 +246,210 @@ function getFactorName(factorType: number) {
<template> <template>
<v-container class="d-flex align-center justify-center fill-height"> <v-container class="d-flex align-center justify-center fill-height">
<v-card class="pa-8" max-width="1000" rounded="lg" width="100%"> <v-card max-width="1000" rounded="lg" width="100%">
<v-overlay :model-value="isLoading" class="align-center justify-center"> <div v-if="isLoading" class="d-flex justify-center mb-4">
<v-progress-circular indeterminate size="64" /> <v-progress-linear indeterminate color="primary" height="4" />
</v-overlay>
<div class="mb-4">
<img
:src="$vuetify.theme.current.dark ? IconDark : IconLight"
alt="CloudyLamb"
height="60"
width="60"
/>
</div> </div>
<v-row> <div class="pa-8">
<v-col cols="12" lg="6" class="d-flex align-start justify-start"> <div class="mb-4">
<div class="md:text-left h-auto"> <img
<div v-if="stage === 'find-account'"> :src="$vuetify.theme.current.dark ? IconDark : IconLight"
<h2 class="text-2xl font-bold mb-1">Sign in</h2> alt="CloudyLamb"
<p class="text-lg">Use your Solarpass</p> height="60"
width="60"
/>
</div>
<v-row>
<v-col cols="12" lg="6" class="d-flex align-start justify-start">
<div class="md:text-left h-auto">
<div v-if="stage === 'find-account'">
<h2 class="text-2xl font-bold mb-1">Sign in</h2>
<p class="text-lg">Use your Solarpass</p>
</div>
<div v-if="stage === 'select-factor'">
<h2 class="text-2xl font-bold mb-1">Choose how to sign in</h2>
<p class="text-lg">
Select your preferred authentication method
</p>
</div>
<div v-if="stage === 'enter-code' && selectedFactor">
<h2 class="text-2xl font-bold mb-1">
Enter your
{{
selectedFactor.type === 0 ? "password" : "verification code"
}}
</h2>
<p v-if="selectedFactor.type === 1" class="text-lg">
A code has been sent to
{{ selectedFactor.contact || "your email" }}.
</p>
<p v-if="selectedFactor.type === 2" class="text-lg">
Enter the code from your in-app authenticator.
</p>
<p v-if="selectedFactor.type === 3" class="text-lg">
Enter the timed verification code.
</p>
<p v-if="selectedFactor.type === 4" class="text-lg">
Enter your PIN code.
</p>
<p v-if="selectedFactor.type === 0" class="text-lg">
Enter your password to continue.
</p>
</div>
<div v-if="stage === 'token-exchange'">
<h2 class="text-2xl font-bold mb-1">Finalizing Login</h2>
<p class="text-lg">
Please wait while we complete your sign in.
</p>
</div>
</div> </div>
<div v-if="stage === 'select-factor'"> </v-col>
<h2 class="text-2xl font-bold mb-1">Choose how to sign in</h2> <v-col cols="12" lg="6" class="d-flex align-center justify-stretch">
<p class="text-lg">Select your preferred authentication method</p> <div class="w-full d-flex flex-column md:text-right">
</div> <v-window
<div v-if="stage === 'enter-code' && selectedFactor"> v-model="activeStageIndex"
<h2 class="text-2xl font-bold mb-1"> class="align-self-stretch pt-2"
Enter your
{{
selectedFactor.type === 0 ? "password" : "verification code"
}}
</h2>
<p v-if="selectedFactor.type === 1" class="text-lg">
A code has been sent to
{{ selectedFactor.contact || "your email" }}.
</p>
<p v-if="selectedFactor.type === 2" class="text-lg">
Enter the code from your in-app authenticator.
</p>
<p v-if="selectedFactor.type === 3" class="text-lg">
Enter the timed verification code.
</p>
<p v-if="selectedFactor.type === 4" class="text-lg">
Enter your PIN code.
</p>
<p v-if="selectedFactor.type === 0" class="text-lg">
Enter your password to continue.
</p>
</div>
<div v-if="stage === 'token-exchange'">
<h2 class="text-2xl font-bold mb-1">Finalizing Login</h2>
<p class="text-lg">Please wait while we complete your sign in.</p>
</div>
</div>
</v-col>
<v-col cols="12" lg="6" class="d-flex align-center justify-stretch">
<div class="w-full d-flex flex-column md:text-right">
<!-- Stage 1: Find Account -->
<div v-if="stage === 'find-account'">
<v-text-field
v-model="accountIdentifier"
label="Email or username"
variant="filled"
class="mb-2"
@keydown.enter="handleFindAccount"
/>
<v-btn variant="text" class="text-capitalize pl-0" color="primary"
>Forgot email?</v-btn
> >
<!-- Stage 1: Find Account -->
<v-window-item :value="0">
<v-text-field
v-model="accountIdentifier"
label="Email or username"
variant="outlined"
class="mb-2"
@keydown.enter="handleFindAccount"
/>
<v-btn
slim
variant="text"
class="text-capitalize"
color="primary"
>Forgot email?</v-btn
>
<p class="text-body-2 mt-8 mb-4"> <div class="d-flex justify-end">
Not your computer? Use Private Browsing windows to sign in. <p class="mt-4 mb-6 text-sm max-w-96">
<v-btn Not your computer? Remember to use Private Browsing
variant="text" windows to sign in.
class="text-capitalize pl-0" </p>
color="primary" </div>
href="https://support.google.com/accounts/answer/181449?hl=en"
target="_blank"
>
Learn more about using Guest mode
</v-btn>
</p>
<div class="d-flex justify-space-between align-center mt-8"> <div class="d-flex justify-space-between align-center mt-8">
<v-btn <v-btn
variant="text" variant="text"
class="text-capitalize" class="text-capitalize"
to="/auth/create-account" to="/auth/create-account"
> >
Create account Create account
</v-btn> </v-btn>
<v-btn color="primary" size="large" @click="handleFindAccount"> <v-btn
Next color="primary"
</v-btn> size="large"
</div> @click="handleFindAccount"
>
Next
</v-btn>
</div>
</v-window-item>
<!-- Stage 2: Select Factor -->
<v-window-item :value="1">
<v-radio-group v-model="selectedFactorId">
<v-list>
<v-list-item v-for="factor in factors" :key="factor.id">
<v-list-item-action>
<v-radio
:value="factor.id"
:label="getFactorName(factor.type)"
/>
</v-list-item-action>
<template #append>
<v-icon>{{
factor.type === 0
? "mdi-lock"
: factor.type === 1
? "mdi-email"
: factor.type === 2
? "mdi-cellphone"
: factor.type === 3
? "mdi-clock"
: factor.type === 4
? "mdi-numeric"
: "mdi-shield-key"
}}</v-icon>
</template>
</v-list-item>
</v-list>
</v-radio-group>
<div class="d-flex justify-end mt-6">
<v-btn
color="primary"
size="large"
:disabled="!selectedFactorId"
@click="handleFactorSelected"
>
Next
</v-btn>
</div>
</v-window-item>
<!-- Stage 3: Enter Code -->
<v-window-item :value="2">
<v-text-field
v-model="password"
:type="selectedFactor?.type === 0 ? 'password' : 'text'"
:label="selectedFactor?.type === 0 ? 'Password' : 'Code'"
variant="outlined"
class="mb-2"
@keydown.enter="handleVerifyFactor"
/>
<div class="d-flex justify-space-between align-center mt-6">
<v-btn
v-if="selectedFactor?.type === 1"
variant="text"
class="text-capitalize pl-0"
color="primary"
@click="requestVerificationCode"
>
Resend Code
</v-btn>
<v-spacer v-else />
<v-btn
color="primary"
size="large"
@click="handleVerifyFactor"
>
Verify
</v-btn>
</div>
</v-window-item>
<!-- Stage 4: Token Exchange -->
<v-window-item :value="3">
<div class="d-flex justify-center">
<v-progress-circular
indeterminate
size="64"
color="primary"
/>
</div>
</v-window-item>
</v-window>
<v-alert
v-if="error"
type="error"
closable
class="mt-2"
@update:model-value="error = null"
>
{{ error }}
</v-alert>
</div> </div>
</v-col>
<!-- Stage 2: Select Factor --> </v-row>
<div v-if="stage === 'select-factor' && challenge">
<v-radio-group v-model="selectedFactorId">
<v-list>
<v-list-item v-for="factor in factors" :key="factor.id">
<v-list-item-action>
<v-radio
:value="factor.id"
:label="getFactorName(factor.type)"
/>
</v-list-item-action>
<template #append>
<v-icon>{{
factor.type === 0
? "mdi-lock"
: factor.type === 1
? "mdi-email"
: factor.type === 2
? "mdi-cellphone"
: factor.type === 3
? "mdi-clock"
: factor.type === 4
? "mdi-numeric"
: "mdi-shield-key"
}}</v-icon>
</template>
</v-list-item>
</v-list>
</v-radio-group>
<div class="d-flex justify-end mt-6">
<v-btn
color="primary"
size="large"
:disabled="!selectedFactorId"
@click="handleFactorSelected"
>
Next
</v-btn>
</div>
</div>
<!-- Stage 3: Enter Code -->
<div v-if="stage === 'enter-code' && selectedFactor">
<v-text-field
v-model="password"
:type="selectedFactor.type === 0 ? 'password' : 'text'"
:label="selectedFactor.type === 0 ? 'Password' : 'Code'"
variant="filled"
class="mb-2"
@keydown.enter="handleVerifyFactor"
/>
<div class="d-flex justify-space-between align-center mt-6">
<v-btn
v-if="selectedFactor.type === 1"
variant="text"
class="text-capitalize pl-0"
color="primary"
@click="requestVerificationCode"
>
Resend Code
</v-btn>
<v-spacer v-else />
<v-btn color="primary" size="large" @click="handleVerifyFactor">
Verify
</v-btn>
</div>
</div>
<!-- Stage 4: Token Exchange -->
<div v-if="stage === 'token-exchange'">
<div class="d-flex justify-center">
<v-progress-circular indeterminate size="64" color="primary" />
</div>
</div>
<v-alert
v-if="error"
type="error"
closable
class="mt-2"
@update:model-value="error = null"
>
{{ error }}
</v-alert>
</div>
</v-col>
</v-row>
</v-card>
<div
class="d-flex justify-space-between align-center w-100"
style="
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
max-width: 1200px;
"
>
<v-select
:items="['English (United States)']"
model-value="English (United States)"
variant="plain"
density="compact"
hide-details
class="flex-grow-0"
/>
<div class="d-flex">
<v-btn variant="text" size="small" class="text-capitalize">Help</v-btn>
<v-btn variant="text" size="small" class="text-capitalize"
>Privacy</v-btn
>
<v-btn variant="text" size="small" class="text-capitalize">Terms</v-btn>
</div> </div>
</div> </v-card>
<footer-compact />
</v-container> </v-container>
</template> </template>