jcloud/dashboard/src/pages/LoginSignup.vue
2025-12-29 21:19:47 +08:00

952 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="flex h-screen overflow-hidden sm:bg-gray-50">
<div class="w-full overflow-auto">
<LoginBox
:title="title"
:class="{ 'pointer-events-none': $resources.signup.loading }"
>
<template v-slot:default>
<div v-if="!(resetPasswordEmailSent || otpRequested)">
<form class="flex flex-col" @submit.prevent="submitForm">
<!-- 2FA 部分 -->
<template v-if="is2FA">
<FormControl
:label="$t('2FA code from your authenticator app')"
placeholder="123456"
v-model="twoFactorCode"
required
/>
<Button
class="mt-4"
:loading="
$resources.verify2FA.loading ||
$session.login.loading ||
$resources.resetPassword.loading
"
variant="solid"
@click="
$resources.verify2FA.submit({
user: email,
totp_code: twoFactorCode,
})
"
>
{{ $t('Verify') }}
</Button>
<ErrorMessage
class="mt-2"
:message="$resources.verify2FA.error"
/>
</template>
<!-- 忘记密码部分 -->
<template v-else-if="hasForgotPassword">
<FormControl
:label="$t('Email')"
type="email"
placeholder="email@example.com"
autocomplete="email"
v-model="email"
required
/>
<router-link
class="mt-2 text-sm"
v-if="hasForgotPassword"
:to="{
name: 'Login',
query: { ...$route.query, forgot: undefined },
}"
>
{{ $t('I remember my password') }}
</router-link>
<Button
class="mt-4"
:loading="$resources.resetPassword.loading"
variant="solid"
>
{{ $t('Reset Password') }}
</Button>
</template>
<!-- 登录部分 -->
<template v-else-if="isLogin">
<FormControl
:label="$t('Username or Email')"
placeholder="email@example.com"
autocomplete="email"
v-model="email"
:disabled="otpSent && useEmail"
required
/>
<!-- 密码验证 -->
<template v-if="!isOauthLogin && !useEmail">
<FormControl
class="mt-4"
:label="$t('Password')"
type="password"
placeholder="•••••"
v-model="password"
name="password"
autocomplete="current-password"
required
/>
<div class="mt-2 flex flex-col gap-2">
<router-link
class="text-sm"
:to="{
name: 'Login',
query: { ...$route.query, forgot: 1 },
}"
>
{{ $t('Forgot Password?') }}
</router-link>
</div>
<Button
class="mt-4"
variant="solid"
:loading="$session.login.loading"
type="submit"
>
{{ $t('Log In') }}
</Button>
</template>
<!-- OTP 验证 -->
<template v-else-if="useEmail">
<!-- OTP 验证输入(当 OTP 已发送时) -->
<template v-if="otpSent">
<FormControl
class="mt-4"
:label="$t('Verification Code')"
placeholder="123456"
v-model="otp"
required
/>
<div class="mt-4 space-y-2">
<Button
class="w-full"
:loading="$resources.verifyOTPAndLogin.loading"
variant="solid"
@click="verifyOTPAndLogin"
>
{{ $t('Log In') }}
</Button>
<Button
class="w-full"
:loading="$resources.sendOTP.loading"
variant="outline"
:disabled="otpResendCountdown > 0"
@click="$resources.sendOTP.submit()"
>
{{ $t('Resend Verification Code') }}
{{
otpResendCountdown > 0
? ` ${$t('in')} ${otpResendCountdown} ${$t('seconds')}`
: ''
}}
</Button>
</div>
</template>
<!-- 初始 OTP 请求按钮 -->
<template v-else>
<Button
class="mt-4"
:loading="$resources.sendOTP.loading"
variant="solid"
@click="$resources.sendOTP.submit()"
>
{{ $t('Send Verification Code') }}
</Button>
</template>
</template>
<!-- OAuth 验证 -->
<template v-else>
<Button class="mt-4" variant="solid">
{{ $t('Log in with') }} {{ oauthProviderName }}
</Button>
</template>
<!-- 错误信息 -->
<ErrorMessage
class="mt-2"
:message="
$session.login.error ||
$resources.is2FAEnabled.error ||
$resources.sendOTP.error ||
$resources.verifyOTPAndLogin.error
"
/>
</template>
<!-- 注册部分 -->
<template v-else>
<FormControl
:label="$t('Username')"
type="text"
:placeholder="$t('Set username')"
v-model="username"
autocomplete="username"
required
/>
<FormControl
:label="$t('Email (optional)')"
type="email"
placeholder="email@example.com"
autocomplete="email"
class="mt-4"
v-model="email"
/>
<FormControl
:label="$t('Phone Number')"
type="tel"
:placeholder="$t('Please enter your phone number')"
class="mt-4"
v-model="phoneNumber"
autocomplete="tel"
required
@blur="validatePhoneNumber"
/>
<div v-if="phoneNumberFormatError" class="mt-2 text-sm text-red-600">
{{ $t('Please enter a valid phone number format') }}
</div>
<FormControl
:label="$t('Password')"
type="password"
:placeholder="$t('Set login password')"
class="mt-4"
v-model="signupPassword"
autocomplete="new-password"
required
/>
<div class="mt-1 text-sm text-gray-600">
{{ $t('Password must be at least 8 characters and contain uppercase and lowercase letters and numbers') }}
</div>
<div class="mt-2" v-if="signupPassword">
<div class="flex items-center">
<div class="text-sm w-20">{{ $t('Password Strength') }}:</div>
<div class="flex-1 bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all"
:class="passwordStrengthClass"
:style="{width: passwordStrength + '%'}"></div>
</div>
<div class="ml-2 text-sm" :class="passwordStrengthTextClass">{{passwordStrengthText}}</div>
</div>
</div>
<FormControl
:label="$t('Confirm Password')"
type="password"
:placeholder="$t('Enter password again')"
class="mt-4"
v-model="confirmPassword"
autocomplete="new-password"
required
/>
<div v-if="passwordMismatch" class="mt-2 text-sm text-red-600">
{{ $t('Passwords do not match') }}
</div>
<Button
class="mt-4"
:loading="$resources.signupWithUsername.loading"
variant="solid"
type="submit"
:disabled="(passwordMismatch && confirmPassword) || phoneNumberFormatError"
>
{{ $t('Sign Up') }}
</Button>
</template>
<ErrorMessage class="mt-2" :message="error" />
</form>
<div
class="flex flex-col"
v-if="!hasForgotPassword && !isOauthLogin && !is2FA"
>
<div class="-mb-2 mt-6 border-t text-center">
<div class="-translate-y-1/2 transform">
<span
class="relative bg-white px-2 text-sm font-medium leading-8 text-gray-800"
>
{{ $t('Or') }}
</span>
</div>
</div>
<div class="flex flex-col gap-2">
<Button
v-if="isLogin && !useEmail"
@click="switchToEmailLogin"
icon-left="mail"
>
{{ $t('Log in with email verification code') }}
</Button>
<Button
v-else-if="isLogin && useEmail"
@click="switchToPasswordLogin"
icon-left="key"
>
{{ $t('Log in with password') }}
</Button>
</div>
<div
class="mt-6 text-center"
v-if="!(otpRequested || resetPasswordEmailSent)"
>
<router-link
class="text-center text-base font-medium text-gray-900 hover:text-gray-700"
:to="{
name: $route.name == 'Login' ? 'Signup' : 'Login',
query: { ...$route.query, forgot: undefined },
}"
>
{{
$route.name == 'Login'
? $t("Don't have an account? Create one.")
: $t('Already have an account? Log in.')
}}
</router-link>
</div>
</div>
</div>
<div v-else-if="otpRequested">
<form class="flex flex-col">
<FormControl
:label="$t('Email')"
type="email"
placeholder="email@example.com"
autocomplete="email"
v-model="email"
required
/>
<FormControl
:label="$t('Verification Code')"
type="text"
class="mt-4"
placeholder="123456"
maxlength="6"
v-model="otp"
required
/>
<ErrorMessage
class="mt-2"
:message="$resources.verifyOTP.error"
/>
<Button
class="mt-4"
variant="solid"
:loading="$resources.verifyOTP.loading"
@click="$resources.verifyOTP.submit()"
>
{{ $t('Verify') }}
</Button>
<Button
class="mt-2"
variant="outline"
:loading="$resources.resendOTP.loading"
@click="$resources.resendOTP.submit()"
:disabled="otpResendCountdown > 0"
>
{{ $t('Resend Verification Code') }}
{{
otpResendCountdown > 0
? ` ${$t('in')} ${otpResendCountdown} ${$t('seconds')}`
: ''
}}
</Button>
</form>
<div class="mt-6 text-center">
<router-link
class="text-center text-base font-medium text-gray-900 hover:text-gray-700"
:to="{
name: $route.name == 'Login' ? 'Signup' : 'Login',
query: { ...$route.query, forgot: undefined },
}"
>
{{
$route.name == 'Login'
? $t('New user? Create a new account.')
: $t('Already have an account? Log in.')
}}
</router-link>
</div>
</div>
<div
class="text-p-base text-gray-700"
v-else-if="resetPasswordEmailSent"
>
<p>
{{ $t('We have sent an email to') }}
<span class="font-semibold">{{ email }}</span
>{{ $t('. Please click on the link received to reset your password.') }}
</p>
</div>
</template>
<template v-slot:logo v-if="saasProduct">
<div class="mx-auto flex items-center space-x-2">
<img
class="inline-block h-7 w-7 rounded-sm"
:src="saasProduct?.logo"
/>
<span
class="select-none text-xl font-semibold tracking-tight text-gray-900"
>
{{ saasProduct?.title }}
</span>
</div>
</template>
<template v-slot:footer v-if="saasProduct">
<div
class="mt-2 flex w-full items-center justify-center text-sm text-gray-600"
>
{{ $t('Powered by Jingrow') }}
</div>
</template>
</LoginBox>
</div>
</div>
</template>
<script>
import LoginBox from '../components/auth/LoginBox.vue';
import { toast } from 'vue-sonner';
import { getToastErrorMessage } from '../utils/toast';
export default {
name: 'Signup',
components: {
LoginBox,
},
data() {
return {
email: '',
username: '',
phoneNumber: '',
signupPassword: '',
account_request: '',
otpRequested: false,
otp: '',
otpSent: false,
twoFactorCode: '',
password: null,
otpResendCountdown: 0,
resetPasswordEmailSent: false,
confirmPassword: '',
phoneNumberFormatError: false,
};
},
mounted() {
this.email = localStorage.getItem('login_email');
setInterval(() => {
if (this.otpResendCountdown > 0) {
this.otpResendCountdown -= 1;
}
}, 1000);
},
watch: {
email() {
this.resetSignupState();
},
},
resources: {
signup() {
return {
url: 'jcloud.api.account.signup',
params: {
email: this.email,
referrer: this.getReferrerIfAny(),
product: this.$route.query.product,
},
onSuccess(account_request) {
this.account_request = account_request;
this.otpRequested = true;
this.otpResendCountdown = 30;
toast.success(this.$t('Verification code has been sent to your email'));
},
onError: (error) => {
if (error?.exc_type !== 'ValidationError') {
return;
}
let errorMessage = '';
if ((error?.messages ?? []).length) {
errorMessage = error?.messages?.[0];
}
// check if error message has `is already registered` substring
if (errorMessage.includes('is already registered')) {
localStorage.setItem('login_email', this.email);
if (this.$route.query?.product) {
this.$router.push({
name: 'Login',
query: {
redirect: `/dashboard/create-site/${this.$route.query.product}/setup`,
},
});
} else {
this.$router.push({
name: 'Login',
});
}
}
},
};
},
verifyOTP() {
return {
url: 'jcloud.api.account.verify_otp',
params: {
account_request: this.account_request,
otp: this.otp,
},
onSuccess(key) {
window.open(`/dashboard/setup-account/${key}`, '_self');
},
};
},
resendOTP() {
return {
url: 'jcloud.api.account.resend_otp',
params: {
account_request: this.account_request,
},
onSuccess() {
this.otp = '';
this.otpResendCountdown = 30;
toast.success(this.$t('Verification code has been sent to your email'));
},
onError(err) {
toast.error(
getToastErrorMessage(err, this.$t('Failed to resend verification code')),
);
},
};
},
sendOTP() {
return {
url: 'jcloud.api.account.send_otp',
params: {
email: this.email,
},
onSuccess() {
this.otpSent = true;
this.otpResendCountdown = 30;
toast.success(this.$t('Verification code has been sent to your email'));
},
onError(err) {
toast.error(
getToastErrorMessage(err, this.$t('Failed to send verification code')),
);
},
};
},
verifyOTPAndLogin() {
return {
url: 'jcloud.api.account.verify_otp_and_login',
params: {
email: this.email,
otp: this.otp,
},
onSuccess(res) {
this.afterLogin(res);
},
};
},
oauthLogin() {
return {
url: 'jcloud.api.oauth.oauth_authorize_url',
onSuccess(url) {
localStorage.setItem('login_email', this.email);
window.location.href = url;
},
};
},
resetPassword() {
return {
url: 'jcloud.api.account.send_reset_password_email',
onSuccess() {
this.resetPasswordEmailSent = true;
},
};
},
signupSettings() {
return {
url: 'jcloud.api.account.signup_settings',
params: {
product: this.$route.query.product,
},
auto: true,
};
},
is2FAEnabled() {
return {
url: 'jcloud.api.account.is_2fa_enabled',
};
},
verify2FA() {
return {
url: 'jcloud.api.account.verify_2fa',
onSuccess: async () => {
if (this.isLogin) {
if (!this.useEmail) {
await this.$resources.verifyOTPAndLogin.submit();
} else {
await this.login();
}
} else if (this.hasForgotPassword) {
await this.$resources.resetPassword.submit({
email: this.email,
});
}
},
};
},
signupWithUsername() {
return {
url: 'jcloud.api.account.signup_with_username',
params: {
username: this.username,
email: this.email || null,
phone_number: this.phoneNumber || null,
password: this.signupPassword,
referrer: this.getReferrerIfAny(),
product: this.$route.query.product,
},
onSuccess(res) {
if (res && res.success === false) {
toast.error(res.message || this.$t('Sign up failed, please check your information'));
return;
}
localStorage.setItem('login_email', this.username || this.email);
if (res && res.dashboard_route) {
window.location.href = res.dashboard_route;
} else {
window.location.href = '/dashboard/welcome';
}
},
onError(err) {
const errorMessage = err?.messages?.length
? err.messages.join('\n')
: (err?.message || this.$t('Sign up failed, please check your information'));
toast.error(errorMessage);
},
};
},
},
methods: {
resetSignupState() {
if (!this.isLogin && !this.hasForgotPassword && this.otpRequested) {
this.otpRequested = false;
this.account_request = '';
this.otp = '';
}
},
async submitForm() {
if (this.isLogin) {
if (this.isOauthLogin) {
this.$resources.oauthLogin.submit({
provider: this.socialLoginKey,
});
} else if (this.useEmail && this.otpSent) {
return;
} else if (!this.useEmail && this.email && this.password) {
this.checkTwoFactorAndLogin();
} else if (this.useEmail && !this.otpSent) {
this.$resources.sendOTP.submit();
}
} else if (this.hasForgotPassword) {
this.checkTwoFactorAndResetPassword();
} else {
if (!this.username) {
toast.error(this.$t('Username cannot be empty'));
return;
}
if (!this.phoneNumber) {
toast.error(this.$t('Phone number cannot be empty'));
return;
}
if (!this.isPhoneNumberValid) {
toast.error(this.$t('Please enter a valid phone number format'));
return;
}
if (!this.signupPassword) {
toast.error(this.$t('Password cannot be empty'));
return;
}
if (this.signupPassword !== this.confirmPassword) {
toast.error(this.$t('Passwords do not match'));
return;
}
if (!this.isPasswordValid) {
toast.error(this.$t('Password must be at least 8 characters and contain uppercase and lowercase letters and numbers'));
return;
}
this.$resources.signupWithUsername.submit();
}
},
async checkTwoFactorAndLogin() {
await this.$resources.is2FAEnabled.submit(
{ user: this.email },
{
onSuccess: async (two_factor_enabled) => {
if (two_factor_enabled) {
this.$router.push({
name: 'Login',
query: {
...this.$route.query,
two_factor: 1,
},
});
} else {
await this.login();
}
},
},
);
},
async checkTwoFactorAndResetPassword() {
await this.$resources.is2FAEnabled.submit(
{ user: this.email },
{
onSuccess: async (two_factor_enabled) => {
if (two_factor_enabled) {
this.$router.push({
name: 'Login',
query: {
two_factor: 1,
forgot: 1,
},
});
} else {
await this.$resources.resetPassword.submit({
email: this.email,
});
}
},
},
);
},
verifyOTPAndLogin() {
this.$resources.is2FAEnabled.submit(
{ user: this.email },
{
onSuccess: async (two_factor_enabled) => {
if (two_factor_enabled) {
this.$router.push({
name: 'Login',
query: {
...this.$route.query,
two_factor: 1,
},
});
} else {
await this.$resources.verifyOTPAndLogin.submit();
}
},
},
);
},
getReferrerIfAny() {
const params = location.search;
const searchParams = new URLSearchParams(params);
return searchParams.get('referrer');
},
async login() {
await this.$session.login.submit(
{
email: this.email,
password: this.password,
},
{
onSuccess: (res) => {
// 直接在此处处理重定向不调用afterLogin
localStorage.setItem('login_email', this.email || this.username);
// 老用户直接进入主仪表板
window.location.href = '/dashboard';
},
onError: (err) => {
if (this.$route.name === 'Login' && this.$route.query.two_factor) {
this.$router.push({
name: 'Login',
query: {
two_factor: undefined,
},
});
this.twoFactorCode = '';
}
},
},
);
},
afterLogin(res) {
let loginRoute = `/dashboard${res.dashboard_route || '/'}`;
// if query param redirect is present, redirect to that route
if (this.$route.query.redirect) {
loginRoute = this.$route.query.redirect;
}
localStorage.setItem('login_email', this.email);
window.location.href = loginRoute;
},
switchToEmailLogin() {
this.$router.push({
name: 'Login',
query: {
...this.$route.query,
use_email: 1
}
});
},
switchToPasswordLogin() {
this.$router.push({
name: 'Login',
query: {
...this.$route.query,
use_email: undefined
}
});
},
validatePhoneNumber() {
// 失焦时验证手机号格式
if (this.phoneNumber && this.phoneNumber.length > 0) {
this.phoneNumberFormatError = !this.isPhoneNumberValid;
} else {
this.phoneNumberFormatError = false;
}
},
},
computed: {
error() {
if (this.$resources.signup.error) {
return this.$resources.signup.error;
}
if (this.$resources.resetPassword.error) {
return this.$resources.resetPassword.error;
}
},
saasProduct() {
return this.$resources.signupSettings.data?.product_trial;
},
isLogin() {
return this.$route.name == 'Login' && !this.$route.query.forgot;
},
hasForgotPassword() {
return this.$route.name == 'Login' && this.$route.query.forgot;
},
is2FA() {
return this.$route.name == 'Login' && this.$route.query.two_factor;
},
emailDomain() {
return this.email?.includes('@') ? this.email?.split('@').pop() : '';
},
isOauthLogin() {
return (
this.oauthEmailDomains.has(this.emailDomain) &&
this.emailDomain.length > 0
);
},
oauthProviders() {
const domains = this.$resources.signupSettings.data?.oauth_domains;
let providers = {};
if (domains) {
domains.map(
(d) =>
(providers[d.email_domain] = {
social_login_key: d.social_login_key,
provider_name: d.provider_name,
}),
);
}
return providers;
},
oauthEmailDomains() {
return new Set(Object.keys(this.oauthProviders));
},
socialLoginKey() {
return this.oauthProviders[this.emailDomain].social_login_key;
},
oauthProviderName() {
return this.oauthProviders[this.emailDomain].provider_name;
},
title() {
if (this.hasForgotPassword) {
return this.$t('Reset Password');
} else if (this.isLogin) {
if (this.saasProduct) {
return this.$t('Log in to your account to start using {product}', { product: this.saasProduct.title });
}
return this.$t('Log in to your account');
} else {
if (this.saasProduct) {
return this.$t('Sign up to create your {product} site', { product: this.saasProduct.title });
}
return this.$t('Create New Account');
}
},
useEmail() {
// 默认使用密码登录只有当明确指定use_email时才使用邮箱验证码
return Boolean(this.$route.query.use_email);
},
passwordMismatch() {
return this.signupPassword !== this.confirmPassword && this.confirmPassword;
},
passwordStrength() {
if (!this.signupPassword) return 0;
let strength = 0;
// 密码长度
if (this.signupPassword.length >= 8) strength += 25;
// 包含小写字母
if (/[a-z]/.test(this.signupPassword)) strength += 25;
// 包含大写字母
if (/[A-Z]/.test(this.signupPassword)) strength += 25;
// 包含数字
if (/[0-9]/.test(this.signupPassword)) strength += 25;
return strength;
},
passwordStrengthClass() {
if (this.passwordStrength < 40) return 'bg-red-500';
if (this.passwordStrength < 60) return 'bg-yellow-500';
if (this.passwordStrength < 80) return 'bg-blue-500';
return 'bg-green-500';
},
passwordStrengthText() {
if (this.passwordStrength < 40) return this.$t('Weak');
if (this.passwordStrength < 60) return this.$t('Fair');
if (this.passwordStrength < 80) return this.$t('Strong');
return this.$t('Very Strong');
},
passwordStrengthTextClass() {
if (this.passwordStrength < 40) return 'text-red-500';
if (this.passwordStrength < 60) return 'text-yellow-500';
if (this.passwordStrength < 80) return 'text-blue-500';
return 'text-green-500';
},
isPasswordValid() {
return this.signupPassword &&
this.signupPassword.length >= 8 &&
/[a-z]/.test(this.signupPassword) &&
/[A-Z]/.test(this.signupPassword) &&
/[0-9]/.test(this.signupPassword);
},
isPhoneNumberValid() {
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(this.phoneNumber);
},
},
};
</script>