feat: 添加二步验证相关
This commit is contained in:
@@ -12,8 +12,7 @@ server:
|
||||
steps:
|
||||
- name: build new image
|
||||
commands:
|
||||
- docker compose -f ./deploy/docker-compose.yml build --no-cache
|
||||
|
||||
- USE_PROXY=1 docker compose -f ./deploy/docker-compose.yml build --no-cache
|
||||
- name: clean old continaer
|
||||
commands:
|
||||
- docker container inspect kami_frontend &> /dev/null && docker container rm -f kami_frontend
|
||||
@@ -55,7 +54,7 @@ clone:
|
||||
steps:
|
||||
- name: build new image
|
||||
commands:
|
||||
- docker compose -f ./deploy/docker-compose.yml build --no-cache
|
||||
- USE_PROXY=0 docker compose -f ./deploy/docker-compose.yml build --no-cache
|
||||
- name: clean old continaer
|
||||
commands:
|
||||
- docker container inspect kami_frontend &> /dev/null && docker container rm -f kami_frontend
|
||||
|
||||
@@ -2,7 +2,14 @@ FROM node:20 as builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
RUN npm i -g nrm && nrm use taobao && npm install -g npm@latest pnpm@9.4.0 && pnpm i && pnpm build
|
||||
# 定义参数
|
||||
ARG USE_PROXY
|
||||
|
||||
# 根据USE_PROXY参数设置环境变量
|
||||
RUN if [ "$USE_PROXY" = "1" ]; then \
|
||||
npm i -g nrm && nrm use taobao; \
|
||||
fi \
|
||||
&& npm install -g npm@latest pnpm@9.4.0 && pnpm i && pnpm build
|
||||
|
||||
FROM nginx:latest
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ services:
|
||||
build:
|
||||
context: ../.
|
||||
dockerfile: ./deploy/Dockerfile
|
||||
args:
|
||||
- USE_PROXY=${USE_PROXY}
|
||||
container_name: kami_frontend
|
||||
image: kami_frontend:0.11
|
||||
restart: always
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" type="image/x-icon"
|
||||
href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico">
|
||||
<!-- <link rel="shortcut icon" type="image/x-icon"
|
||||
href="https://unpkg.byted-static.com/latest/byted/arco-config/assets/favicon.ico"> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>卡销终端解决方案</title>
|
||||
<title>卡销终端解决方案(核销端)</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import qs from 'query-string';
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
import type { UserState } from '@/store/modules/user/types';
|
||||
import { encryptWithBase64 } from '@/utils/encrypt';
|
||||
@@ -7,6 +8,7 @@ import type { PaymentRecord } from './sys-user-payment';
|
||||
export interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
totpCode: string;
|
||||
verifyCode: string;
|
||||
verifyKey: string;
|
||||
}
|
||||
@@ -40,10 +42,12 @@ export function getCaptchaAPI() {
|
||||
export function changePassword(data: {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
totpCode: string;
|
||||
}) {
|
||||
data = {
|
||||
oldPassword: encryptWithBase64(data.oldPassword),
|
||||
newPassword: encryptWithBase64(data.newPassword)
|
||||
newPassword: encryptWithBase64(data.newPassword),
|
||||
totpCode: data.totpCode
|
||||
};
|
||||
return axios.put('/user/changePwd', data);
|
||||
}
|
||||
@@ -56,3 +60,39 @@ export interface statisticsRecord {
|
||||
export function queryStatistics() {
|
||||
return axios.get<statisticsRecord>('/sysUser/statistics');
|
||||
}
|
||||
|
||||
// 获取当前账号是否开启了二步验证
|
||||
export function queryTotpStatus() {
|
||||
return axios.get<{
|
||||
status: boolean;
|
||||
image?: string;
|
||||
otpSecret?: string;
|
||||
otpKey?: string;
|
||||
}>('/user/totp/status');
|
||||
}
|
||||
|
||||
// 设置两步验证
|
||||
export function setTotp(data: {
|
||||
code: string;
|
||||
password: string;
|
||||
otpKey: string;
|
||||
otpSecret: string;
|
||||
}) {
|
||||
data.password = encryptWithBase64(data.password);
|
||||
return axios.post('/user/totp/set', data);
|
||||
}
|
||||
|
||||
// 查看两步验证图像
|
||||
export function getTotpImage(params: { code: string }) {
|
||||
return axios.get<{ image: string }>('/user/totp/image', {
|
||||
params,
|
||||
paramsSerializer: obj => {
|
||||
return qs.stringify(obj);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 重置二步验证
|
||||
export function resetTotp(data: { code: string }) {
|
||||
return axios.post('/user/totp/reset', data);
|
||||
}
|
||||
|
||||
@@ -2,27 +2,19 @@ import CryptoJS from 'crypto-js';
|
||||
|
||||
const CRYPTOJSKEY = 'thisis32bitlongpassphraseimusing';
|
||||
const iv = '1234567890123456';
|
||||
|
||||
const options = {
|
||||
iv: CryptoJS.enc.Utf8.parse(iv),
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
};
|
||||
export function encryptWithBase64(ciphertext: string) {
|
||||
const options = {
|
||||
iv: CryptoJS.enc.Utf8.parse(iv),
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
};
|
||||
const key = CryptoJS.enc.Utf8.parse(CRYPTOJSKEY);
|
||||
const encryptedData = CryptoJS.AES.encrypt(ciphertext, key, options);
|
||||
const encryptedBase64Str = encryptedData.toString();
|
||||
return encryptedBase64Str;
|
||||
return encryptedData.toString();
|
||||
}
|
||||
|
||||
export function decryptWithBase64(ciphertext: string) {
|
||||
const options = {
|
||||
iv: CryptoJS.enc.Utf8.parse(iv),
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
};
|
||||
const key = CryptoJS.enc.Utf8.parse(CRYPTOJSKEY);
|
||||
const decryptData = CryptoJS.AES.decrypt(ciphertext, key, options);
|
||||
const encryptedBase64Str = decryptData.toString();
|
||||
return encryptedBase64Str;
|
||||
const decryptedData = CryptoJS.AES.decrypt(ciphertext, key, options);
|
||||
return decryptedData.toString(CryptoJS.enc.Utf8);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,16 @@
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-form-item field="totpCode" validate-trigger="blur" hide-label>
|
||||
<a-input
|
||||
v-model="form.totpCode"
|
||||
placeholder="请输入两步验证(如果未设置不需要填写)"
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-robot />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item field="capcha" validate-trigger="blur" hide-label>
|
||||
<a-row justify="space-around" :gutter="8">
|
||||
<a-col :span="16">
|
||||
@@ -95,7 +105,7 @@
|
||||
</a-tabs>
|
||||
<a-button
|
||||
type="primary"
|
||||
style="margin: 32px 0 6px"
|
||||
style="margin: 20px 0 6px"
|
||||
long
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
@@ -133,11 +143,13 @@ import { getCaptchaAPI } from '@/api/user';
|
||||
import { pick } from 'lodash';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import { decryptWithBase64, encryptWithBase64 } from '@/utils/encrypt';
|
||||
import { isUndefined } from '@/utils/is';
|
||||
|
||||
const router = useRouter();
|
||||
const codeDisabled = ref(false);
|
||||
// const codeDisabled = ref(false);
|
||||
const userStore = useUserStore();
|
||||
const codeText = ref('获取验证码');
|
||||
// const codeText = ref('获取验证码');
|
||||
const formRef = ref();
|
||||
const tabActiveKey = ref('1');
|
||||
const state = reactive({
|
||||
@@ -147,16 +159,22 @@ const state = reactive({
|
||||
});
|
||||
|
||||
const { loading, setLoading } = useLoading();
|
||||
|
||||
const loginConfig = useStorage('login-config', {
|
||||
rememberPassword: true,
|
||||
username: '', // 演示默认值
|
||||
password: '' // 演示密码
|
||||
userInfo: ''
|
||||
});
|
||||
const form = reactive({
|
||||
username: loginConfig.value.username,
|
||||
password: loginConfig.value.password,
|
||||
// phone: '',
|
||||
username:
|
||||
!isUndefined(loginConfig.value.userInfo) &&
|
||||
loginConfig.value.userInfo !== ''
|
||||
? JSON.parse(decryptWithBase64(loginConfig.value.userInfo)).username
|
||||
: '',
|
||||
password:
|
||||
!isUndefined(loginConfig.value.userInfo) &&
|
||||
loginConfig.value.userInfo !== ''
|
||||
? JSON.parse(decryptWithBase64(loginConfig.value.userInfo)).password
|
||||
: '',
|
||||
totpCode: '',
|
||||
captcha: ''
|
||||
// agreement: false
|
||||
});
|
||||
@@ -172,7 +190,8 @@ const rules = {
|
||||
// /^(?![\d]+$)(?![a-z]+$)(?![A-Z]+$)(?![~!@#$%^&*.]+$)[\da-zA-z~!@#$%^&*.]{6,32}$/,
|
||||
// message: '密码格式不正确'
|
||||
// }
|
||||
]
|
||||
],
|
||||
totpCode: [{ match: /^\d{6}$/, message: '请输入正确的两步验证码(6位数字)' }]
|
||||
// phone: [
|
||||
// { required: true, message: '请输入手机号' },
|
||||
// { length: 11, message: '手机号格式不正确' }
|
||||
@@ -186,7 +205,6 @@ const handleSubmit = () => {
|
||||
.validateField(['username', 'password', 'captcha'])
|
||||
.then(async res => {
|
||||
if (res) {
|
||||
getCaptcha();
|
||||
return;
|
||||
}
|
||||
// if (!form.agreement) {
|
||||
@@ -194,10 +212,16 @@ const handleSubmit = () => {
|
||||
// }
|
||||
setLoading(true);
|
||||
try {
|
||||
const userInfoForm = pick(form, ['username', 'captcha', 'password']);
|
||||
const userInfoForm = pick(form, [
|
||||
'username',
|
||||
'captcha',
|
||||
'password',
|
||||
'totpCode'
|
||||
]);
|
||||
await userStore.login({
|
||||
verifyKey: state.captchaKey,
|
||||
verifyCode: userInfoForm.captcha,
|
||||
totpCode: userInfoForm.totpCode,
|
||||
username: userInfoForm.username,
|
||||
password: userInfoForm.password
|
||||
});
|
||||
@@ -209,17 +233,26 @@ const handleSubmit = () => {
|
||||
}
|
||||
});
|
||||
Message.success('登录成功');
|
||||
const { rememberPassword } = loginConfig.value;
|
||||
const { username, password } = userInfoForm;
|
||||
// 实际生产环境需要进行加密存储。
|
||||
loginConfig.value.username = rememberPassword ? username : '';
|
||||
loginConfig.value.password = rememberPassword ? password : '';
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
username: userInfoForm.username,
|
||||
password: userInfoForm.password
|
||||
})
|
||||
);
|
||||
// const { username, password } = userInfoForm;
|
||||
loginConfig.value.userInfo = encryptWithBase64(
|
||||
JSON.stringify({
|
||||
username: userInfoForm.username,
|
||||
password: userInfoForm.password
|
||||
})
|
||||
);
|
||||
// loginConfig.value.rememberPassword = rememberPassword;
|
||||
} finally {
|
||||
getCaptcha();
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (tabActiveKey.value === '2') {
|
||||
formRef.value.validateField(['username', 'captcha']).then(res => {
|
||||
if (res) return;
|
||||
@@ -229,21 +262,17 @@ const handleSubmit = () => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setRememberPassword = (value: boolean) => {
|
||||
loginConfig.value.rememberPassword = value;
|
||||
};
|
||||
|
||||
const getCaptcha = async () => {
|
||||
const res = await getCaptchaAPI();
|
||||
state.captchaUrl = res.data.img;
|
||||
state.captchaKey = res.data.key;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getCaptcha();
|
||||
});
|
||||
|
||||
// const { start } = useCountDown({
|
||||
// initValue: 59,
|
||||
// onEnd: () => {
|
||||
@@ -318,7 +347,7 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.arco-tabs-content) {
|
||||
height: 192px;
|
||||
}
|
||||
// :deep(.arco-tabs-content) {
|
||||
// height: 192px;
|
||||
// }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.general-card {
|
||||
padding-bottom: 1rem;
|
||||
|
||||
.totp-inner-card {
|
||||
padding: 0 6rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<a-card title="修改密码" class="general-card">
|
||||
<a-form ref="formRef" :model="formData" :rules="rules">
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
style="padding: 0 6rem"
|
||||
>
|
||||
<a-form-item field="oldPassword" label="现密码" required>
|
||||
<a-input-password v-model="formData.oldPassword" />
|
||||
</a-form-item>
|
||||
@@ -10,9 +15,14 @@
|
||||
<a-form-item field="confirmPassword" label="确认密码" required>
|
||||
<a-input-password v-model="formData.confirmPassword" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button html-type="submit" @click="onConform">确认</a-button>
|
||||
<a-form-item
|
||||
field="totpCode"
|
||||
label="两步验证"
|
||||
tooltip="如果设置了就需要填写"
|
||||
>
|
||||
<a-verification-code v-model="formData.totpCode" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" long @click="onConform">确认</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -25,9 +35,9 @@ import { reactive, ref } from 'vue';
|
||||
const formData = reactive({
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
confirmPassword: '',
|
||||
totpCode: ''
|
||||
});
|
||||
|
||||
const rules: Record<string, FieldRule<any> | FieldRule<any>[]> = {
|
||||
oldPassword: [{ required: true, message: '请输入现有密码' }],
|
||||
password: [{ required: true, message: '请输入新密码' }],
|
||||
@@ -42,7 +52,8 @@ const rules: Record<string, FieldRule<any> | FieldRule<any>[]> = {
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
totpCode: [{ match: /^\d{6}$/, message: '请输入正确的两步验证码(6位数字)' }]
|
||||
};
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
@@ -52,11 +63,16 @@ const onConform = async () => {
|
||||
if (errors) return;
|
||||
await changePassword({
|
||||
oldPassword: formData.oldPassword,
|
||||
newPassword: formData.password
|
||||
newPassword: formData.password,
|
||||
totpCode: formData.totpCode
|
||||
});
|
||||
Message.success('密码修改成功');
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
<style lang="less" scoped>
|
||||
.general-card {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
218
src/views/user-center/user-profile/components/totp.tsx
Normal file
218
src/views/user-center/user-profile/components/totp.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { getTotpImage, queryTotpStatus, resetTotp, setTotp } from '@/api/user';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
FormItem,
|
||||
VerificationCode,
|
||||
Image,
|
||||
Button,
|
||||
InputPassword,
|
||||
type FormInstance,
|
||||
Space
|
||||
} from '@arco-design/web-vue';
|
||||
import { defineComponent, onMounted, reactive, ref } from 'vue';
|
||||
import './css/totp.less';
|
||||
|
||||
export const StepTwoForm = defineComponent({
|
||||
emits: { submit: () => true },
|
||||
setup(_, { emit }) {
|
||||
const formData = reactive({
|
||||
code: ''
|
||||
});
|
||||
const formRef = ref<FormInstance>();
|
||||
const rules = {
|
||||
code: [
|
||||
{
|
||||
required: true,
|
||||
message: '验证码不能为空'
|
||||
}
|
||||
]
|
||||
};
|
||||
const state = reactive<{ showImage: boolean; image: string }>({
|
||||
showImage: false,
|
||||
image: ''
|
||||
});
|
||||
return () => (
|
||||
<Space direction='vertical' fill>
|
||||
{state.image ? (
|
||||
<div>
|
||||
<Image src={state.image} />
|
||||
<Button
|
||||
status='warning'
|
||||
onClick={() => {
|
||||
state.showImage = false;
|
||||
state.image = '';
|
||||
formData.code = '';
|
||||
}}
|
||||
long
|
||||
>
|
||||
关闭验证图像
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Form ref={formRef} model={formData} rules={rules}>
|
||||
<FormItem field='code' required label='验证码'>
|
||||
<VerificationCode v-model={formData.code} />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<Button
|
||||
status='success'
|
||||
onClick={() => {
|
||||
formRef.value.validate().then(res => {
|
||||
if (res) return;
|
||||
getTotpImage(formData).then(res => {
|
||||
state.image = res.data.image;
|
||||
state.showImage = true;
|
||||
});
|
||||
});
|
||||
}}
|
||||
long
|
||||
>
|
||||
查看两步验证
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
status='danger'
|
||||
onClick={() => {
|
||||
formRef.value.validate().then(res => {
|
||||
if (res) return;
|
||||
resetTotp(formData).then(() => {
|
||||
emit('submit');
|
||||
});
|
||||
});
|
||||
}}
|
||||
long
|
||||
>
|
||||
删除两步验证
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const StepOneForm = defineComponent({
|
||||
props: {
|
||||
QrImage: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
otpKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
otpSecret: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['submit'],
|
||||
setup(props, { emit }) {
|
||||
const formData = reactive<{ password: string; code: string }>({
|
||||
password: '',
|
||||
code: ''
|
||||
});
|
||||
const formDataRef = ref<FormInstance>();
|
||||
const rules = {
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: '密码不能为空'
|
||||
}
|
||||
],
|
||||
code: [
|
||||
{
|
||||
required: true,
|
||||
message: '验证码不能为空'
|
||||
}
|
||||
]
|
||||
};
|
||||
return () => (
|
||||
<div>
|
||||
<Image src={props.QrImage} />
|
||||
<Form ref={formDataRef} model={formData} rules={rules}>
|
||||
<FormItem field='password' label='密码' required>
|
||||
<InputPassword v-model={formData.password} />
|
||||
</FormItem>
|
||||
<FormItem field='code' required label='二步验证'>
|
||||
<VerificationCode
|
||||
class='totp-verification-code'
|
||||
v-model={formData.code}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Button
|
||||
type='primary'
|
||||
long
|
||||
onClick={() => {
|
||||
formDataRef.value.validate().then(res => {
|
||||
if (res) return;
|
||||
setTotp({
|
||||
otpSecret: props.otpSecret,
|
||||
otpKey: props.otpKey,
|
||||
...formData
|
||||
}).then(() => {
|
||||
emit('submit');
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const state = reactive<{
|
||||
totpStatus: boolean;
|
||||
QrImage: string;
|
||||
otpSecret: string;
|
||||
otpKey: string;
|
||||
}>({
|
||||
totpStatus: false,
|
||||
QrImage: '',
|
||||
otpSecret: '',
|
||||
otpKey: ''
|
||||
});
|
||||
const getTotpStatus = () => {
|
||||
console.log('wobwdnysle');
|
||||
queryTotpStatus().then(res => {
|
||||
state.totpStatus = res.data.status;
|
||||
if (!state.totpStatus) {
|
||||
state.QrImage = res.data.image;
|
||||
state.otpSecret = res.data.otpSecret;
|
||||
state.otpKey = res.data.otpKey;
|
||||
}
|
||||
});
|
||||
};
|
||||
onMounted(() => {
|
||||
getTotpStatus();
|
||||
});
|
||||
return () => (
|
||||
<Card
|
||||
class='general-card'
|
||||
title='二步验证'
|
||||
v-slots={{ extra: () => (state.totpStatus ? '已开启' : '未开启') }}
|
||||
>
|
||||
<div class='totp-inner-card'>
|
||||
{state.totpStatus ? (
|
||||
<StepTwoForm onSubmit={getTotpStatus} />
|
||||
) : (
|
||||
<StepOneForm
|
||||
QrImage={state.QrImage}
|
||||
otpKey={state.otpKey}
|
||||
otpSecret={state.otpSecret}
|
||||
onSubmit={getTotpStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<Breadcrumb :items="['用户相关', '用户设置']" />
|
||||
<a-row>
|
||||
<a-col :span="8">
|
||||
<reset-password />
|
||||
<a-row justify="center">
|
||||
<a-col :span="12">
|
||||
<a-space direction="vertical" fill>
|
||||
<reset-password />
|
||||
<totp />
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
@@ -11,4 +14,5 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import ResetPassword from './components/reset-password.vue';
|
||||
import Totp from './components/totp.tsx';
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user