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,33 +1,100 @@
<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">
<img
:src="$vuetify.theme.current.dark ? IconDark : IconLight"
alt="CloudyLamb"
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 === '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-text-field
v-model="formModel.name" v-model="formModel.name"
label="Username" label="Username"
variant="outlined" variant="outlined"
:rules="nameRules" :rules="nameRules"
class="mb-4" class="mb-2"
@keydown.enter="handleNext"
/> />
<v-text-field <v-text-field
v-model="formModel.nick" v-model="formModel.nick"
label="Nickname" label="Nickname"
variant="outlined" variant="outlined"
:rules="nickRules" :rules="nickRules"
class="mb-4" 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>
<!-- Stage 2: Email -->
<v-window-item :value="1">
<v-text-field <v-text-field
v-model="formModel.email" v-model="formModel.email"
label="Email" label="Email"
placeholder="your@email.com" placeholder="your@email.com"
variant="outlined" variant="outlined"
:rules="emailRules" :rules="emailRules"
class="mb-4" 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>
<!-- Stage 3: Password -->
<v-window-item :value="2">
<v-text-field <v-text-field
v-model="formModel.password" v-model="formModel.password"
label="Password" label="Password"
@@ -35,65 +102,79 @@
placeholder="Enter your password" placeholder="Enter your password"
variant="outlined" variant="outlined"
:rules="passwordRules" :rules="passwordRules"
class="mb-4" 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>
<!-- Stage 4: Captcha -->
<v-window-item :value="3">
<div class="d-flex justify-center mb-4"> <div class="d-flex justify-center mb-4">
<client-only> <client-only>
<captcha-widget <captcha-widget @verified="onCaptchaVerified" />
:provider="captchaProvider"
:api-key="captchaApiKey"
@verified="onCaptchaVerified"
/>
</client-only> </client-only>
</div> </div>
</v-window-item>
</v-window>
<v-btn
type="submit"
color="primary"
block
size="large"
:disabled="isLoading"
>
Create Account
</v-btn>
<div class="mt-3 text-center text-body-2 opacity-75">
<v-btn
variant="text"
block
size="small"
to="/auth/login"
>
Already have an account? Login
</v-btn>
</div>
</v-form>
<v-alert <v-alert
v-if="error" v-if="error"
type="error" type="error"
closable closable
class="mt-4" class="mt-2"
@update:model-value="error = null" @update:model-value="error = null"
> >
{{ error }} {{ error }}
</v-alert> </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,10 +246,11 @@ 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>
<div class="pa-8">
<div class="mb-4"> <div class="mb-4">
<img <img
:src="$vuetify.theme.current.dark ? IconDark : IconLight" :src="$vuetify.theme.current.dark ? IconDark : IconLight"
@@ -251,7 +268,9 @@ function getFactorName(factorType: number) {
</div> </div>
<div v-if="stage === 'select-factor'"> <div v-if="stage === 'select-factor'">
<h2 class="text-2xl font-bold mb-1">Choose how to sign in</h2> <h2 class="text-2xl font-bold mb-1">Choose how to sign in</h2>
<p class="text-lg">Select your preferred authentication method</p> <p class="text-lg">
Select your preferred authentication method
</p>
</div> </div>
<div v-if="stage === 'enter-code' && selectedFactor"> <div v-if="stage === 'enter-code' && selectedFactor">
<h2 class="text-2xl font-bold mb-1"> <h2 class="text-2xl font-bold mb-1">
@@ -279,37 +298,41 @@ function getFactorName(factorType: number) {
</div> </div>
<div v-if="stage === 'token-exchange'"> <div v-if="stage === 'token-exchange'">
<h2 class="text-2xl font-bold mb-1">Finalizing Login</h2> <h2 class="text-2xl font-bold mb-1">Finalizing Login</h2>
<p class="text-lg">Please wait while we complete your sign in.</p> <p class="text-lg">
Please wait while we complete your sign in.
</p>
</div> </div>
</div> </div>
</v-col> </v-col>
<v-col cols="12" lg="6" class="d-flex align-center justify-stretch"> <v-col cols="12" lg="6" class="d-flex align-center justify-stretch">
<div class="w-full d-flex flex-column md:text-right"> <div class="w-full d-flex flex-column md:text-right">
<v-window
v-model="activeStageIndex"
class="align-self-stretch pt-2"
>
<!-- Stage 1: Find Account --> <!-- Stage 1: Find Account -->
<div v-if="stage === 'find-account'"> <v-window-item :value="0">
<v-text-field <v-text-field
v-model="accountIdentifier" v-model="accountIdentifier"
label="Email or username" label="Email or username"
variant="filled" variant="outlined"
class="mb-2" class="mb-2"
@keydown.enter="handleFindAccount" @keydown.enter="handleFindAccount"
/> />
<v-btn variant="text" class="text-capitalize pl-0" color="primary" <v-btn
slim
variant="text"
class="text-capitalize"
color="primary"
>Forgot email?</v-btn >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"
color="primary"
href="https://support.google.com/accounts/answer/181449?hl=en"
target="_blank"
>
Learn more about using Guest mode
</v-btn>
</p> </p>
</div>
<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
@@ -319,14 +342,18 @@ function getFactorName(factorType: number) {
> >
Create account Create account
</v-btn> </v-btn>
<v-btn color="primary" size="large" @click="handleFindAccount"> <v-btn
color="primary"
size="large"
@click="handleFindAccount"
>
Next Next
</v-btn> </v-btn>
</div> </div>
</div> </v-window-item>
<!-- Stage 2: Select Factor --> <!-- Stage 2: Select Factor -->
<div v-if="stage === 'select-factor' && challenge"> <v-window-item :value="1">
<v-radio-group v-model="selectedFactorId"> <v-radio-group v-model="selectedFactorId">
<v-list> <v-list>
<v-list-item v-for="factor in factors" :key="factor.id"> <v-list-item v-for="factor in factors" :key="factor.id">
@@ -364,21 +391,21 @@ function getFactorName(factorType: number) {
Next Next
</v-btn> </v-btn>
</div> </div>
</div> </v-window-item>
<!-- Stage 3: Enter Code --> <!-- Stage 3: Enter Code -->
<div v-if="stage === 'enter-code' && selectedFactor"> <v-window-item :value="2">
<v-text-field <v-text-field
v-model="password" v-model="password"
:type="selectedFactor.type === 0 ? 'password' : 'text'" :type="selectedFactor?.type === 0 ? 'password' : 'text'"
:label="selectedFactor.type === 0 ? 'Password' : 'Code'" :label="selectedFactor?.type === 0 ? 'Password' : 'Code'"
variant="filled" variant="outlined"
class="mb-2" class="mb-2"
@keydown.enter="handleVerifyFactor" @keydown.enter="handleVerifyFactor"
/> />
<div class="d-flex justify-space-between align-center mt-6"> <div class="d-flex justify-space-between align-center mt-6">
<v-btn <v-btn
v-if="selectedFactor.type === 1" v-if="selectedFactor?.type === 1"
variant="text" variant="text"
class="text-capitalize pl-0" class="text-capitalize pl-0"
color="primary" color="primary"
@@ -387,18 +414,27 @@ function getFactorName(factorType: number) {
Resend Code Resend Code
</v-btn> </v-btn>
<v-spacer v-else /> <v-spacer v-else />
<v-btn color="primary" size="large" @click="handleVerifyFactor"> <v-btn
color="primary"
size="large"
@click="handleVerifyFactor"
>
Verify Verify
</v-btn> </v-btn>
</div> </div>
</div> </v-window-item>
<!-- Stage 4: Token Exchange --> <!-- Stage 4: Token Exchange -->
<div v-if="stage === 'token-exchange'"> <v-window-item :value="3">
<div class="d-flex justify-center"> <div class="d-flex justify-center">
<v-progress-circular indeterminate size="64" color="primary" /> <v-progress-circular
</div> indeterminate
size="64"
color="primary"
/>
</div> </div>
</v-window-item>
</v-window>
<v-alert <v-alert
v-if="error" v-if="error"
@@ -412,32 +448,8 @@ function getFactorName(factorType: number) {
</div> </div>
</v-col> </v-col>
</v-row> </v-row>
</div>
</v-card> </v-card>
<div <footer-compact />
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>
</v-container> </v-container>
</template> </template>