mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-16 21:33:51 +02:00
add OtpDialogModal
This commit is contained in:
54
package-lock.json
generated
54
package-lock.json
generated
@@ -71,6 +71,7 @@
|
||||
"vitest": "^3.2.4",
|
||||
"vue": "^3.5.28",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-input-otp": "^0.3.2",
|
||||
"vue-json-pretty": "^2.6.0",
|
||||
"vue-marquee-text-component": "^2.0.1",
|
||||
"vue-router": "^4.6.4",
|
||||
@@ -12585,6 +12586,59 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue-input-otp": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-input-otp/-/vue-input-otp-0.3.2.tgz",
|
||||
"integrity": "sha512-QMl1842WB6uNAsK4+mZXIskb00TOfahH3AQt8rpRecbtQnOp+oHSUbL/Z3wekfy6pAl+hyN3e1rCUSkCMzbDLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"reka-ui": "^2.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-input-otp/node_modules/@vueuse/core": {
|
||||
"version": "12.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz",
|
||||
"integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "12.8.2",
|
||||
"@vueuse/shared": "12.8.2",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-input-otp/node_modules/@vueuse/metadata": {
|
||||
"version": "12.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz",
|
||||
"integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-input-otp/node_modules/@vueuse/shared": {
|
||||
"version": "12.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz",
|
||||
"integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-json-pretty": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-json-pretty/-/vue-json-pretty-2.6.0.tgz",
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"vitest": "^3.2.4",
|
||||
"vue": "^3.5.28",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-input-otp": "^0.3.2",
|
||||
"vue-json-pretty": "^2.6.0",
|
||||
"vue-marquee-text-component": "^2.0.1",
|
||||
"vue-router": "^4.6.4",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<AlertDialogModal></AlertDialogModal>
|
||||
<PromptDialogModal></PromptDialogModal>
|
||||
<OtpDialogModal></OtpDialogModal>
|
||||
|
||||
<VRCXUpdateDialog></VRCXUpdateDialog>
|
||||
</div>
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
import AlertDialogModal from './components/ui/alert-dialog/AlertDialogModal.vue';
|
||||
import MacOSTitleBar from './components/MacOSTitleBar.vue';
|
||||
import OtpDialogModal from './components/ui/dialog/OtpDialogModal.vue';
|
||||
import PromptDialogModal from './components/ui/dialog/PromptDialogModal.vue';
|
||||
import VRCXUpdateDialog from './components/dialogs/VRCXUpdateDialog.vue';
|
||||
|
||||
|
||||
348
src/components/__tests__/OtpDialogModal.test.js
Normal file
348
src/components/__tests__/OtpDialogModal.test.js
Normal file
@@ -0,0 +1,348 @@
|
||||
/* eslint-disable pretty-import/sort-import-groups */
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import en from '../../localization/en.json';
|
||||
|
||||
vi.mock('../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../plugin/router', () => ({
|
||||
router: {
|
||||
beforeEach: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||
isReady: vi.fn().mockResolvedValue(true)
|
||||
},
|
||||
initRouter: vi.fn()
|
||||
}));
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||
}))
|
||||
};
|
||||
});
|
||||
vi.mock('../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
|
||||
vi.mock('../../service/database', () => ({
|
||||
database: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
if (prop === '__esModule') return false;
|
||||
return vi.fn().mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
)
|
||||
}));
|
||||
vi.mock('../../service/config', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
|
||||
setString: vi.fn(),
|
||||
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
|
||||
setBool: vi.fn(),
|
||||
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||
setInt: vi.fn(),
|
||||
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||
setFloat: vi.fn(),
|
||||
getObject: vi.fn().mockReturnValue(null),
|
||||
setObject: vi.fn(),
|
||||
getArray: vi.fn().mockReturnValue([]),
|
||||
setArray: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('../../service/jsonStorage', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../service/watchState', () => ({
|
||||
watchState: { isLoggedIn: false }
|
||||
}));
|
||||
|
||||
import OtpDialogModal from '../ui/dialog/OtpDialogModal.vue';
|
||||
import { useModalStore } from '../../stores/modal';
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
legacy: false,
|
||||
globalInjection: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en }
|
||||
});
|
||||
|
||||
// Stubs: render children directly so we can inspect DOM without real reka-ui portals
|
||||
const stubs = {
|
||||
Dialog: {
|
||||
template: '<div class="dialog-stub" v-if="open"><slot /></div>',
|
||||
props: ['open']
|
||||
},
|
||||
DialogContent: {
|
||||
template: '<div class="dialog-content-stub"><slot /></div>'
|
||||
},
|
||||
DialogHeader: { template: '<div class="dialog-header"><slot /></div>' },
|
||||
DialogTitle: { template: '<span class="dialog-title"><slot /></span>' },
|
||||
DialogDescription: {
|
||||
template: '<span class="dialog-description"><slot /></span>'
|
||||
},
|
||||
DialogFooter: { template: '<div class="dialog-footer"><slot /></div>' },
|
||||
InputOTP: {
|
||||
name: 'InputOTP',
|
||||
template:
|
||||
'<div class="input-otp-stub"><input :data-maxlength="maxlength" :inputmode="inputmode" /><slot /></div>',
|
||||
props: ['maxlength', 'inputmode', 'modelValue', 'pasteTransformer'],
|
||||
emits: ['update:modelValue', 'complete']
|
||||
},
|
||||
InputOTPGroup: { template: '<div class="otp-group"><slot /></div>' },
|
||||
InputOTPSlot: {
|
||||
template: '<div class="otp-slot" :data-index="index"></div>',
|
||||
props: ['index']
|
||||
},
|
||||
InputOTPSeparator: { template: '<div class="otp-separator">-</div>' }
|
||||
};
|
||||
|
||||
function mountOtpDialog(storeOverrides = {}) {
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
Modal: storeOverrides
|
||||
}
|
||||
});
|
||||
return mount(OtpDialogModal, {
|
||||
global: {
|
||||
plugins: [i18n, pinia],
|
||||
stubs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('OtpDialogModal.vue', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
test('does not render when otpOpen is false', () => {
|
||||
const wrapper = mountOtpDialog({ otpOpen: false });
|
||||
store = useModalStore();
|
||||
expect(wrapper.find('.dialog-stub').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('renders dialog when otpOpen is true', () => {
|
||||
const wrapper = mountOtpDialog({ otpOpen: true });
|
||||
store = useModalStore();
|
||||
expect(wrapper.find('.dialog-stub').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('title and description', () => {
|
||||
test('displays title and description from store', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpTitle: 'Enter TOTP Code',
|
||||
otpDescription: 'Check your authenticator app'
|
||||
});
|
||||
store = useModalStore();
|
||||
expect(wrapper.find('.dialog-title').text()).toBe(
|
||||
'Enter TOTP Code'
|
||||
);
|
||||
expect(wrapper.find('.dialog-description').text()).toBe(
|
||||
'Check your authenticator app'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mode rendering', () => {
|
||||
test('renders 6 slots for totp mode', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.find('.input-otp-stub');
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
|
||||
'6'
|
||||
);
|
||||
expect(otpInput.find('input').attributes('inputmode')).toBe(
|
||||
'numeric'
|
||||
);
|
||||
const slots = wrapper.findAll('.otp-slot');
|
||||
expect(slots).toHaveLength(6);
|
||||
expect(wrapper.find('.otp-separator').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('renders 6 slots for emailOtp mode', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'emailOtp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.find('.input-otp-stub');
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
|
||||
'6'
|
||||
);
|
||||
expect(otpInput.find('input').attributes('inputmode')).toBe(
|
||||
'numeric'
|
||||
);
|
||||
const slots = wrapper.findAll('.otp-slot');
|
||||
expect(slots).toHaveLength(6);
|
||||
});
|
||||
|
||||
test('renders 8 slots with separator for otp (recovery) mode', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'otp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.find('.input-otp-stub');
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
|
||||
'8'
|
||||
);
|
||||
expect(otpInput.find('input').attributes('inputmode')).toBe('text');
|
||||
const slots = wrapper.findAll('.otp-slot');
|
||||
expect(slots).toHaveLength(8);
|
||||
expect(wrapper.find('.otp-separator').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not render otp input when mode is totp', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
// Should not have the 8-slot recovery code input
|
||||
const inputs = wrapper.findAll('.input-otp-stub');
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].find('input').attributes('data-maxlength')).toBe(
|
||||
'6'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('button text', () => {
|
||||
test('displays ok and cancel text from store', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpOkText: 'Verify',
|
||||
otpCancelText: 'Use Recovery'
|
||||
});
|
||||
store = useModalStore();
|
||||
const buttons = wrapper.findAll('button');
|
||||
const cancelBtn = buttons.find((b) => b.text() === 'Use Recovery');
|
||||
const okBtn = buttons.find((b) => b.text() === 'Verify');
|
||||
expect(cancelBtn).toBeTruthy();
|
||||
expect(okBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel button', () => {
|
||||
test('calls handleOtpCancel when cancel button is clicked', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpOkText: 'Verify',
|
||||
otpCancelText: 'Cancel'
|
||||
});
|
||||
store = useModalStore();
|
||||
const spy = vi.spyOn(store, 'handleOtpCancel');
|
||||
|
||||
const cancelBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text() === 'Cancel');
|
||||
await cancelBtn.trigger('click');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
test('does not call handleOtpOk on form submit when value is empty', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true
|
||||
});
|
||||
store = useModalStore();
|
||||
const spy = vi.spyOn(store, 'handleOtpOk');
|
||||
|
||||
await wrapper.find('form').trigger('submit');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete event', () => {
|
||||
test('calls handleOtpOk when InputOTP emits complete', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const spy = vi.spyOn(store, 'handleOtpOk');
|
||||
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
otpInput.vm.$emit('complete', '123456');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('123456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('value reset', () => {
|
||||
test('resets otpValue when dialog opens', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: false,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
|
||||
// Simulate opening: the watcher clears the value
|
||||
store.otpOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
// Wait for the watcher + nextTick inside it
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Dialog should now be visible and InputOTP should have empty modelValue
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.props('modelValue')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('paste transformer', () => {
|
||||
test('recovery code InputOTP has pasteTransformer that strips non-alphanumeric chars', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'otp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
const transformer = otpInput.props('pasteTransformer');
|
||||
expect(transformer).toBeTypeOf('function');
|
||||
expect(transformer('abcd-1234')).toBe('abcd1234');
|
||||
expect(transformer('ab-cd-12-34')).toBe('abcd1234');
|
||||
expect(transformer('abcd1234')).toBe('abcd1234');
|
||||
});
|
||||
|
||||
test('totp InputOTP does not have pasteTransformer', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
expect(otpInput.props('pasteTransformer')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
128
src/components/ui/dialog/OtpDialogModal.vue
Normal file
128
src/components/ui/dialog/OtpDialogModal.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp';
|
||||
import { ref, watch } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useModalStore } from '@/stores';
|
||||
|
||||
const modalStore = useModalStore();
|
||||
|
||||
const { otpOpen, otpTitle, otpDescription, otpOkText, otpCancelText, otpDismissible, otpMode } =
|
||||
storeToRefs(modalStore);
|
||||
|
||||
const otpValue = ref('');
|
||||
|
||||
function onComplete(value) {
|
||||
modalStore.handleOtpOk(value);
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
if (!otpValue.value) return;
|
||||
modalStore.handleOtpOk(otpValue.value);
|
||||
}
|
||||
|
||||
function onEscapeKeyDown(event) {
|
||||
if (!otpDismissible.value) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
modalStore.handleOtpDismiss(otpValue.value);
|
||||
}
|
||||
|
||||
function onPointerDownOutside(event) {
|
||||
if (!otpDismissible.value) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
modalStore.handleOtpDismiss(otpValue.value);
|
||||
}
|
||||
|
||||
function onInteractOutside(event) {
|
||||
if (!otpDismissible.value) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
modalStore.handleOtpDismiss(otpValue.value);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modalStore.handleOtpCancel(otpValue.value);
|
||||
}
|
||||
|
||||
function stripNonAlphanumeric(value) {
|
||||
return value.replace(/[^a-z0-9]/gi, '');
|
||||
}
|
||||
|
||||
watch(otpOpen, (open) => {
|
||||
if (open) {
|
||||
otpValue.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="otpOpen" @update:open="modalStore.setOtpOpen">
|
||||
<DialogContent
|
||||
:show-close-button="false"
|
||||
@escapeKeyDown="onEscapeKeyDown"
|
||||
@pointerDownOutside="onPointerDownOutside"
|
||||
@interactOutside="onInteractOutside">
|
||||
<form @submit.prevent="onSubmit">
|
||||
<DialogHeader class="mb-5">
|
||||
<DialogTitle>{{ otpTitle }}</DialogTitle>
|
||||
<DialogDescription>{{ otpDescription }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<!-- TOTP / EmailOTP: 6 numeric digits -->
|
||||
<InputOTP
|
||||
v-if="otpMode === 'totp' || otpMode === 'emailOtp'"
|
||||
v-model="otpValue"
|
||||
:maxlength="6"
|
||||
autofocus
|
||||
inputmode="numeric"
|
||||
@complete="onComplete">
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot v-for="i in 6" :key="i - 1" :index="i - 1" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
|
||||
<!-- OTP (recovery code): 4+4 alphanumeric with separator -->
|
||||
<InputOTP
|
||||
v-if="otpMode === 'otp'"
|
||||
v-model="otpValue"
|
||||
:maxlength="8"
|
||||
autofocus
|
||||
inputmode="text"
|
||||
:paste-transformer="stripNonAlphanumeric"
|
||||
@complete="onComplete">
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot v-for="i in 4" :key="i - 1" :index="i - 1" />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot v-for="i in 4" :key="i + 3" :index="i + 3" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="mt-5">
|
||||
<Button type="button" variant="outline" @click="handleCancel">
|
||||
{{ otpCancelText }}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{{ otpOkText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
78
src/components/ui/input-otp/InputOTP.vue
Normal file
78
src/components/ui/input-otp/InputOTP.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { OTPInput } from 'vue-input-otp';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { useForwardPropsEmits } from 'reka-ui';
|
||||
|
||||
const props = defineProps({
|
||||
maxlength: { type: Number, required: true },
|
||||
textAlign: { type: String, required: false },
|
||||
inputmode: { type: String, required: false },
|
||||
containerClass: { type: String, required: false },
|
||||
pushPasswordManagerStrategy: { type: String, required: false },
|
||||
noScriptCssFallback: { type: [String, null], required: false },
|
||||
defaultValue: { type: null, required: false },
|
||||
pasteTransformer: { type: Function, required: false },
|
||||
accept: { type: String, required: false },
|
||||
alt: { type: String, required: false },
|
||||
autocomplete: { type: String, required: false },
|
||||
autofocus: { type: Boolean, required: false },
|
||||
capture: { type: [Boolean, String], required: false },
|
||||
checked: { type: [Boolean, Array, Set], required: false },
|
||||
crossorigin: { type: String, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
enterKeyHint: { type: String, required: false },
|
||||
form: { type: String, required: false },
|
||||
formaction: { type: String, required: false },
|
||||
formenctype: { type: String, required: false },
|
||||
formmethod: { type: String, required: false },
|
||||
formnovalidate: { type: Boolean, required: false },
|
||||
formtarget: { type: String, required: false },
|
||||
height: { type: Number, required: false },
|
||||
indeterminate: { type: Boolean, required: false },
|
||||
list: { type: String, required: false },
|
||||
max: { type: Number, required: false },
|
||||
min: { type: Number, required: false },
|
||||
minlength: { type: Number, required: false },
|
||||
multiple: { type: Boolean, required: false },
|
||||
name: { type: String, required: false },
|
||||
pattern: { type: String, required: false },
|
||||
placeholder: { type: String, required: false },
|
||||
readonly: { type: Boolean, required: false },
|
||||
required: { type: Boolean, required: false },
|
||||
size: { type: Number, required: false },
|
||||
src: { type: String, required: false },
|
||||
step: { type: Number, required: false },
|
||||
type: { type: String, required: false },
|
||||
value: { type: null, required: false },
|
||||
width: { type: Number, required: false },
|
||||
class: { type: null, required: false }
|
||||
});
|
||||
|
||||
const emits = defineEmits([
|
||||
'complete',
|
||||
'change',
|
||||
'select',
|
||||
'input',
|
||||
'focus',
|
||||
'blur',
|
||||
'mouseover',
|
||||
'mouseleave',
|
||||
'paste'
|
||||
]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OTPInput
|
||||
v-slot="slotProps"
|
||||
v-bind="forwarded"
|
||||
:container-class="cn('flex items-center gap-2 has-disabled:opacity-50', props.class)"
|
||||
data-slot="input-otp"
|
||||
class="disabled:cursor-not-allowed">
|
||||
<slot v-bind="slotProps" />
|
||||
</OTPInput>
|
||||
</template>
|
||||
19
src/components/ui/input-otp/InputOTPGroup.vue
Normal file
19
src/components/ui/input-otp/InputOTPGroup.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { useForwardProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false }
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="input-otp-group" v-bind="forwarded" :class="cn('flex items-center', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
18
src/components/ui/input-otp/InputOTPSeparator.vue
Normal file
18
src/components/ui/input-otp/InputOTPSeparator.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { MinusIcon } from 'lucide-vue-next';
|
||||
import { useForwardProps } from 'reka-ui';
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false }
|
||||
});
|
||||
|
||||
const forwarded = useForwardProps(props);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="input-otp-separator" role="separator" v-bind="forwarded">
|
||||
<slot>
|
||||
<MinusIcon />
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
38
src/components/ui/input-otp/InputOTPSlot.vue
Normal file
38
src/components/ui/input-otp/InputOTPSlot.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
import { reactiveOmit } from '@vueuse/core';
|
||||
import { useForwardProps } from 'reka-ui';
|
||||
import { useVueOTPContext } from 'vue-input-otp';
|
||||
|
||||
const props = defineProps({
|
||||
index: { type: Number, required: true },
|
||||
class: { type: null, required: false }
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class');
|
||||
|
||||
const forwarded = useForwardProps(delegatedProps);
|
||||
|
||||
const context = useVueOTPContext();
|
||||
|
||||
const slot = computed(() => context?.value.slots[props.index]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-bind="forwarded"
|
||||
data-slot="input-otp-slot"
|
||||
:data-active="slot?.isActive"
|
||||
:class="
|
||||
cn(
|
||||
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
{{ slot?.char }}
|
||||
<div v-if="slot?.hasFakeCaret" class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
4
src/components/ui/input-otp/index.js
Normal file
4
src/components/ui/input-otp/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as InputOTP } from './InputOTP.vue';
|
||||
export { default as InputOTPGroup } from './InputOTPGroup.vue';
|
||||
export { default as InputOTPSeparator } from './InputOTPSeparator.vue';
|
||||
export { default as InputOTPSlot } from './InputOTPSlot.vue';
|
||||
@@ -1968,7 +1968,7 @@
|
||||
"totp": {
|
||||
"header": "双重认证",
|
||||
"description": "输入你的身份验证应用中的验证码",
|
||||
"use_otp": "使用一次性邮件验证码或备用验证码",
|
||||
"use_otp": "使用备用验证码",
|
||||
"verify": "验证",
|
||||
"input_error": "无效的验证码"
|
||||
},
|
||||
|
||||
@@ -319,5 +319,156 @@ describe('useModalStore', () => {
|
||||
store.handlePromptOk('test');
|
||||
expect(store.promptOpen).toBe(false);
|
||||
});
|
||||
|
||||
test('handleOtpOk does nothing without pending otp', () => {
|
||||
store.handleOtpOk('123456');
|
||||
expect(store.otpOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('otpPrompt', () => {
|
||||
test('opens otp dialog with correct mode and text', () => {
|
||||
store.otpPrompt({
|
||||
title: 'TOTP Verification',
|
||||
description: 'Enter your 6-digit code',
|
||||
mode: 'totp',
|
||||
confirmText: 'Verify',
|
||||
cancelText: 'Use recovery code'
|
||||
});
|
||||
|
||||
expect(store.otpOpen).toBe(true);
|
||||
expect(store.otpTitle).toBe('TOTP Verification');
|
||||
expect(store.otpDescription).toBe('Enter your 6-digit code');
|
||||
expect(store.otpMode).toBe('totp');
|
||||
expect(store.otpOkText).toBe('Verify');
|
||||
expect(store.otpCancelText).toBe('Use recovery code');
|
||||
|
||||
store.handleOtpCancel('');
|
||||
});
|
||||
|
||||
test('resolves with value on handleOtpOk', async () => {
|
||||
const promise = store.otpPrompt({
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
mode: 'totp'
|
||||
});
|
||||
|
||||
store.handleOtpOk('123456');
|
||||
|
||||
const result = await promise;
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.reason).toBe('ok');
|
||||
expect(result.value).toBe('123456');
|
||||
expect(store.otpOpen).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves ok:false on handleOtpCancel', async () => {
|
||||
const promise = store.otpPrompt({
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
mode: 'emailOtp'
|
||||
});
|
||||
|
||||
store.handleOtpCancel('123');
|
||||
|
||||
const result = await promise;
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('cancel');
|
||||
expect(result.value).toBe('123');
|
||||
expect(store.otpOpen).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves ok:false on handleOtpDismiss when dismissible', async () => {
|
||||
const promise = store.otpPrompt({
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
mode: 'totp'
|
||||
});
|
||||
|
||||
store.handleOtpDismiss('');
|
||||
|
||||
const result = await promise;
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('dismiss');
|
||||
});
|
||||
|
||||
test('does not dismiss when dismissible is false', async () => {
|
||||
const promise = store.otpPrompt({
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
mode: 'totp',
|
||||
dismissible: false
|
||||
});
|
||||
|
||||
expect(store.otpDismissible).toBe(false);
|
||||
store.handleOtpDismiss('');
|
||||
expect(store.otpOpen).toBe(true);
|
||||
|
||||
store.handleOtpCancel('');
|
||||
await promise;
|
||||
});
|
||||
|
||||
test('sets otp mode correctly', () => {
|
||||
store.otpPrompt({
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
mode: 'otp'
|
||||
});
|
||||
|
||||
expect(store.otpMode).toBe('otp');
|
||||
|
||||
store.handleOtpCancel('');
|
||||
});
|
||||
|
||||
test('defaults mode to totp when not specified', () => {
|
||||
store.otpPrompt({
|
||||
title: 'T',
|
||||
description: 'D'
|
||||
});
|
||||
|
||||
expect(store.otpMode).toBe('totp');
|
||||
|
||||
store.handleOtpCancel('');
|
||||
});
|
||||
|
||||
test('replaces previous otp dialog with reason replaced', async () => {
|
||||
const first = store.otpPrompt({
|
||||
title: 'First',
|
||||
description: 'D',
|
||||
mode: 'totp'
|
||||
});
|
||||
const second = store.otpPrompt({
|
||||
title: 'Second',
|
||||
description: 'D',
|
||||
mode: 'emailOtp'
|
||||
});
|
||||
|
||||
const firstResult = await first;
|
||||
expect(firstResult.ok).toBe(false);
|
||||
expect(firstResult.reason).toBe('replaced');
|
||||
expect(firstResult.value).toBe('');
|
||||
|
||||
expect(store.otpTitle).toBe('Second');
|
||||
expect(store.otpMode).toBe('emailOtp');
|
||||
expect(store.otpOpen).toBe(true);
|
||||
|
||||
store.handleOtpOk('654321');
|
||||
const secondResult = await second;
|
||||
expect(secondResult.ok).toBe(true);
|
||||
expect(secondResult.value).toBe('654321');
|
||||
});
|
||||
|
||||
test('uses default button text from i18n', () => {
|
||||
store.otpPrompt({
|
||||
title: 'T',
|
||||
description: 'D',
|
||||
mode: 'totp'
|
||||
});
|
||||
|
||||
expect(store.otpOkText).toBe(en.dialog.alertdialog.confirm);
|
||||
expect(store.otpCancelText).toBe(en.dialog.alertdialog.cancel);
|
||||
|
||||
store.handleOtpCancel('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -613,13 +613,12 @@ export const useAuthStore = defineStore('Auth', () => {
|
||||
AppApi.FlashWindow();
|
||||
twoFactorAuthDialogVisible.value = true;
|
||||
modalStore
|
||||
.prompt({
|
||||
.otpPrompt({
|
||||
title: t('prompt.totp.header'),
|
||||
description: t('prompt.totp.description'),
|
||||
mode: 'totp',
|
||||
cancelText: t('prompt.totp.use_otp'),
|
||||
confirmText: t('prompt.totp.verify'),
|
||||
pattern: /^[0-9]{6}$/,
|
||||
errorMessage: t('prompt.totp.input_error')
|
||||
confirmText: t('prompt.totp.verify')
|
||||
})
|
||||
.then(({ ok, reason, value }) => {
|
||||
twoFactorAuthDialogVisible.value = false;
|
||||
@@ -653,13 +652,12 @@ export const useAuthStore = defineStore('Auth', () => {
|
||||
}
|
||||
twoFactorAuthDialogVisible.value = true;
|
||||
modalStore
|
||||
.prompt({
|
||||
.otpPrompt({
|
||||
title: t('prompt.otp.header'),
|
||||
description: t('prompt.otp.description'),
|
||||
mode: 'otp',
|
||||
cancelText: t('prompt.otp.use_totp'),
|
||||
confirmText: t('prompt.otp.verify'),
|
||||
pattern: /^[a-z0-9]{4}-[a-z0-9]{4}$/,
|
||||
errorMessage: t('prompt.otp.input_error')
|
||||
confirmText: t('prompt.otp.verify')
|
||||
})
|
||||
.then(({ ok, reason, value }) => {
|
||||
twoFactorAuthDialogVisible.value = false;
|
||||
@@ -672,7 +670,7 @@ export const useAuthStore = defineStore('Auth', () => {
|
||||
|
||||
authRequest
|
||||
.verifyOTP({
|
||||
code: value.trim()
|
||||
code: `${value.slice(0, 4)}-${value.slice(4)}`
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
@@ -694,13 +692,12 @@ export const useAuthStore = defineStore('Auth', () => {
|
||||
AppApi.FlashWindow();
|
||||
twoFactorAuthDialogVisible.value = true;
|
||||
modalStore
|
||||
.prompt({
|
||||
.otpPrompt({
|
||||
title: t('prompt.email_otp.header'),
|
||||
description: t('prompt.email_otp.description'),
|
||||
mode: 'emailOtp',
|
||||
cancelText: t('prompt.email_otp.resend'),
|
||||
confirmText: t('prompt.email_otp.verify'),
|
||||
pattern: /^[0-9]{6}$/,
|
||||
errorMessage: t('prompt.email_otp.input_error')
|
||||
confirmText: t('prompt.email_otp.verify')
|
||||
})
|
||||
.then(({ ok, reason, value }) => {
|
||||
twoFactorAuthDialogVisible.value = false;
|
||||
|
||||
@@ -45,6 +45,16 @@ import { useI18n } from 'vue-i18n';
|
||||
* @property {boolean=} dismissible
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} OtpPromptOptions
|
||||
* @property {string} title
|
||||
* @property {string} description
|
||||
* @property {'totp' | 'emailOtp' | 'otp'} mode
|
||||
* @property {string=} confirmText
|
||||
* @property {string=} cancelText
|
||||
* @property {boolean=} dismissible
|
||||
*/
|
||||
|
||||
export const useModalStore = defineStore('Modal', () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -67,10 +77,20 @@ export const useModalStore = defineStore('Modal', () => {
|
||||
const promptPattern = ref(null);
|
||||
const promptErrorMessage = ref('');
|
||||
|
||||
const otpOpen = ref(false);
|
||||
const otpTitle = ref('');
|
||||
const otpDescription = ref('');
|
||||
const otpOkText = ref('');
|
||||
const otpCancelText = ref('');
|
||||
const otpDismissible = ref(true);
|
||||
const otpMode = ref('totp'); // 'totp' | 'emailOtp' | 'otp'
|
||||
|
||||
/** @type {{ resolve: ((result: ConfirmResult) => void) | null } | null} */
|
||||
let pending = null;
|
||||
/** @type {{ resolve: ((result: PromptResult) => void) | null } | null} */
|
||||
let pendingPrompt = null;
|
||||
/** @type {{ resolve: ((result: PromptResult) => void) | null } | null} */
|
||||
let pendingOtp = null;
|
||||
|
||||
function closeDialog() {
|
||||
alertOpen.value = false;
|
||||
@@ -271,6 +291,88 @@ export const useModalStore = defineStore('Modal', () => {
|
||||
promptOpen.value = !!open;
|
||||
}
|
||||
|
||||
// --- OTP dialog ---
|
||||
|
||||
function closeOtpDialog() {
|
||||
otpOpen.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'ok' | 'cancel' | 'dismiss' | 'replaced'} reason
|
||||
* @param {string} value
|
||||
*/
|
||||
function finishOtp(reason, value) {
|
||||
const resolve = pendingOtp?.resolve;
|
||||
pendingOtp = null;
|
||||
closeOtpDialog();
|
||||
if (resolve) resolve({ ok: reason === 'ok', reason, value });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'ok' | 'cancel' | 'dismiss' | 'replaced'} reason
|
||||
* @param {string} value
|
||||
*/
|
||||
function finishOtpWithoutClosing(reason, value) {
|
||||
const resolve = pendingOtp?.resolve;
|
||||
pendingOtp = null;
|
||||
if (resolve) resolve({ ok: reason === 'ok', reason, value });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OtpPromptOptions} options
|
||||
* @returns {Promise<PromptResult>}
|
||||
*/
|
||||
function openOtp(options) {
|
||||
if (pendingOtp) {
|
||||
finishOtpWithoutClosing('replaced', '');
|
||||
}
|
||||
|
||||
otpTitle.value = options.title;
|
||||
otpDescription.value = options.description;
|
||||
otpDismissible.value = options.dismissible !== false;
|
||||
otpMode.value = options.mode || 'totp';
|
||||
|
||||
otpOkText.value =
|
||||
options.confirmText || t('dialog.alertdialog.confirm');
|
||||
otpCancelText.value =
|
||||
options.cancelText || t('dialog.alertdialog.cancel');
|
||||
|
||||
otpOpen.value = true;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
pendingOtp = { resolve };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* otpPrompt: always resolve({ok, reason, value})
|
||||
* @param {OtpPromptOptions} options
|
||||
* @returns {Promise<PromptResult>}
|
||||
*/
|
||||
function otpPrompt(options) {
|
||||
return openOtp(options);
|
||||
}
|
||||
|
||||
function handleOtpOk(value) {
|
||||
if (!pendingOtp) return;
|
||||
finishOtp('ok', value ?? '');
|
||||
}
|
||||
|
||||
function handleOtpCancel(value) {
|
||||
if (!pendingOtp) return;
|
||||
finishOtp('cancel', value ?? '');
|
||||
}
|
||||
|
||||
function handleOtpDismiss(value) {
|
||||
if (!pendingOtp) return;
|
||||
if (!otpDismissible.value) return;
|
||||
finishOtp('dismiss', value ?? '');
|
||||
}
|
||||
|
||||
function setOtpOpen(open) {
|
||||
otpOpen.value = !!open;
|
||||
}
|
||||
|
||||
return {
|
||||
alertOpen,
|
||||
alertMode,
|
||||
@@ -289,10 +391,18 @@ export const useModalStore = defineStore('Modal', () => {
|
||||
promptInputType,
|
||||
promptPattern,
|
||||
promptErrorMessage,
|
||||
otpOpen,
|
||||
otpTitle,
|
||||
otpDescription,
|
||||
otpOkText,
|
||||
otpCancelText,
|
||||
otpDismissible,
|
||||
otpMode,
|
||||
|
||||
confirm,
|
||||
alert,
|
||||
prompt,
|
||||
otpPrompt,
|
||||
|
||||
handleOk,
|
||||
handleCancel,
|
||||
@@ -301,6 +411,10 @@ export const useModalStore = defineStore('Modal', () => {
|
||||
handlePromptCancel,
|
||||
handlePromptDismiss,
|
||||
setAlertOpen,
|
||||
setPromptOpen
|
||||
setPromptOpen,
|
||||
handleOtpOk,
|
||||
handleOtpCancel,
|
||||
handleOtpDismiss,
|
||||
setOtpOpen
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user