jcloud/dashboard/src/pages/LoginSignup.vue

952 lines
25 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="来自您的身份验证应用的2FA代码"
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,
})
"
>
验证
</Button>
<ErrorMessage
class="mt-2"
:message="$resources.verify2FA.error"
/>
</template>
<!-- 忘记密码部分 -->
<template v-else-if="hasForgotPassword">
<FormControl
label="邮箱"
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 },
}"
>
我记得我的密码
</router-link>
<Button
class="mt-4"
:loading="$resources.resetPassword.loading"
variant="solid"
>
重置密码
</Button>
</template>
<!-- 登录部分 -->
<template v-else-if="isLogin">
<FormControl
label="用户名或邮箱"
placeholder="email@example.com"
autocomplete="email"
v-model="email"
:disabled="otpSent && useEmail"
required
/>
<!-- 密码验证 -->
<template v-if="!isOauthLogin && !useEmail">
<FormControl
class="mt-4"
label="密码"
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 },
}"
>
忘记密码?
</router-link>
</div>
<Button
class="mt-4"
variant="solid"
:loading="$session.login.loading"
type="submit"
>
登录
</Button>
</template>
<!-- OTP 验证 -->
<template v-else-if="useEmail">
<!-- OTP 验证输入(当 OTP 已发送时) -->
<template v-if="otpSent">
<FormControl
class="mt-4"
label="验证码"
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"
>
登陆
</Button>
<Button
class="w-full"
:loading="$resources.sendOTP.loading"
variant="outline"
:disabled="otpResendCountdown > 0"
@click="$resources.sendOTP.submit()"
>
重新发送验证码
{{
otpResendCountdown > 0
? `在 ${otpResendCountdown} 秒后`
: ''
}}
</Button>
</div>
</template>
<!-- 初始 OTP 请求按钮 -->
<template v-else>
<Button
class="mt-4"
:loading="$resources.sendOTP.loading"
variant="solid"
@click="$resources.sendOTP.submit()"
>
发送验证码
</Button>
</template>
</template>
<!-- OAuth 验证 -->
<template v-else>
<Button class="mt-4" variant="solid">
使用 {{ 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="用户名"
type="text"
placeholder="设置用户名"
v-model="username"
autocomplete="username"
required
/>
<FormControl
label="邮箱 (可选)"
type="email"
placeholder="email@example.com"
autocomplete="email"
class="mt-4"
v-model="email"
/>
<FormControl
label="手机号"
type="tel"
placeholder="请输入您的手机号码"
class="mt-4"
v-model="phoneNumber"
autocomplete="tel"
required
@blur="validatePhoneNumber"
/>
<div v-if="phoneNumberFormatError" class="mt-2 text-sm text-red-600">
请输入正确的手机号码格式
</div>
<FormControl
label="密码"
type="password"
placeholder="设置登录密码"
class="mt-4"
v-model="signupPassword"
autocomplete="new-password"
required
/>
<div class="mt-1 text-sm text-gray-600">
密码必须至少8个字符并包含大小写字母和数字
</div>
<div class="mt-2" v-if="signupPassword">
<div class="flex items-center">
<div class="text-sm w-20">密码强度:</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="确认密码"
type="password"
placeholder="再次输入密码"
class="mt-4"
v-model="confirmPassword"
autocomplete="new-password"
required
/>
<div v-if="passwordMismatch" class="mt-2 text-sm text-red-600">
两次输入的密码不一致
</div>
<Button
class="mt-4"
:loading="$resources.signupWithUsername.loading"
variant="solid"
type="submit"
:disabled="(passwordMismatch && confirmPassword) || phoneNumberFormatError"
>
注册
</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"
>
</span>
</div>
</div>
<div class="flex flex-col gap-2">
<Button
v-if="isLogin && !useEmail"
@click="switchToEmailLogin"
icon-left="mail"
>
使用邮箱验证码登陆
</Button>
<Button
v-else-if="isLogin && useEmail"
@click="switchToPasswordLogin"
icon-left="key"
>
使用密码登陆
</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'
? '没有账户?创建一个账户。'
: '已有账户?登录。'
}}
</router-link>
</div>
</div>
</div>
<div v-else-if="otpRequested">
<form class="flex flex-col">
<FormControl
label="邮箱"
type="email"
placeholder="email@example.com"
autocomplete="email"
v-model="email"
required
/>
<FormControl
label="验证码"
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()"
>
验证
</Button>
<Button
class="mt-2"
variant="outline"
:loading="$resources.resendOTP.loading"
@click="$resources.resendOTP.submit()"
:disabled="otpResendCountdown > 0"
>
重新发送验证码
{{
otpResendCountdown > 0
? `${otpResendCountdown} 秒后`
: ''
}}
</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'
? '新用户?创建一个新账户。'
: '已有账户?登录。'
}}
</router-link>
</div>
</div>
<div
class="text-p-base text-gray-700"
v-else-if="resetPasswordEmailSent"
>
<p>
我们已向
<span class="font-semibold">{{ email }}</span
>发送了一封邮件请点击收到的链接重置您的密码
</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"
>
今果 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('验证码已发送至您的邮箱');
},
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('验证码已发送至您的邮箱');
},
onError(err) {
toast.error(
getToastErrorMessage(err, '验证码重发失败'),
);
},
};
},
sendOTP() {
return {
url: 'jcloud.api.account.send_otp',
params: {
email: this.email,
},
onSuccess() {
this.otpSent = true;
this.otpResendCountdown = 30;
toast.success('验证码已发送至您的邮箱');
},
onError(err) {
toast.error(
getToastErrorMessage(err, '验证码发送失败'),
);
},
};
},
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 || '注册失败,请检查您的信息');
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 || '注册失败,请检查您的信息');
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('用户名不能为空');
return;
}
if (!this.phoneNumber) {
toast.error('手机号不能为空');
return;
}
if (!this.isPhoneNumberValid) {
toast.error('请输入正确的手机号码格式');
return;
}
if (!this.signupPassword) {
toast.error('密码不能为空');
return;
}
if (this.signupPassword !== this.confirmPassword) {
toast.error('两次输入的密码不一致');
return;
}
if (!this.isPasswordValid) {
toast.error('密码必须至少8个字符并包含大小写字母和数字');
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 '重置密码';
} else if (this.isLogin) {
if (this.saasProduct) {
return `登录您的账户以开始使用 ${this.saasProduct.title}`;
}
return '登录您的账户';
} else {
if (this.saasProduct) {
return `注册以创建您的 ${this.saasProduct.title} 站点`;
}
return '创建新账户';
}
},
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 '弱';
if (this.passwordStrength < 60) return '一般';
if (this.passwordStrength < 80) return '强';
return '非常强';
},
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>