mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-03 05:26:05 +02:00
add OtpDialogModal
This commit is contained in:
Generated
+54
@@ -71,6 +71,7 @@
|
|||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
"vue": "^3.5.28",
|
"vue": "^3.5.28",
|
||||||
"vue-i18n": "^11.2.8",
|
"vue-i18n": "^11.2.8",
|
||||||
|
"vue-input-otp": "^0.3.2",
|
||||||
"vue-json-pretty": "^2.6.0",
|
"vue-json-pretty": "^2.6.0",
|
||||||
"vue-marquee-text-component": "^2.0.1",
|
"vue-marquee-text-component": "^2.0.1",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
@@ -12585,6 +12586,59 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vue-json-pretty": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-json-pretty/-/vue-json-pretty-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-json-pretty/-/vue-json-pretty-2.6.0.tgz",
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
"vue": "^3.5.28",
|
"vue": "^3.5.28",
|
||||||
"vue-i18n": "^11.2.8",
|
"vue-i18n": "^11.2.8",
|
||||||
|
"vue-input-otp": "^0.3.2",
|
||||||
"vue-json-pretty": "^2.6.0",
|
"vue-json-pretty": "^2.6.0",
|
||||||
"vue-marquee-text-component": "^2.0.1",
|
"vue-marquee-text-component": "^2.0.1",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
<AlertDialogModal></AlertDialogModal>
|
<AlertDialogModal></AlertDialogModal>
|
||||||
<PromptDialogModal></PromptDialogModal>
|
<PromptDialogModal></PromptDialogModal>
|
||||||
|
<OtpDialogModal></OtpDialogModal>
|
||||||
|
|
||||||
<VRCXUpdateDialog></VRCXUpdateDialog>
|
<VRCXUpdateDialog></VRCXUpdateDialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
|
|
||||||
import AlertDialogModal from './components/ui/alert-dialog/AlertDialogModal.vue';
|
import AlertDialogModal from './components/ui/alert-dialog/AlertDialogModal.vue';
|
||||||
import MacOSTitleBar from './components/MacOSTitleBar.vue';
|
import MacOSTitleBar from './components/MacOSTitleBar.vue';
|
||||||
|
import OtpDialogModal from './components/ui/dialog/OtpDialogModal.vue';
|
||||||
import PromptDialogModal from './components/ui/dialog/PromptDialogModal.vue';
|
import PromptDialogModal from './components/ui/dialog/PromptDialogModal.vue';
|
||||||
import VRCXUpdateDialog from './components/dialogs/VRCXUpdateDialog.vue';
|
import VRCXUpdateDialog from './components/dialogs/VRCXUpdateDialog.vue';
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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": {
|
"totp": {
|
||||||
"header": "双重认证",
|
"header": "双重认证",
|
||||||
"description": "输入你的身份验证应用中的验证码",
|
"description": "输入你的身份验证应用中的验证码",
|
||||||
"use_otp": "使用一次性邮件验证码或备用验证码",
|
"use_otp": "使用备用验证码",
|
||||||
"verify": "验证",
|
"verify": "验证",
|
||||||
"input_error": "无效的验证码"
|
"input_error": "无效的验证码"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -319,5 +319,156 @@ describe('useModalStore', () => {
|
|||||||
store.handlePromptOk('test');
|
store.handlePromptOk('test');
|
||||||
expect(store.promptOpen).toBe(false);
|
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('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-13
@@ -613,13 +613,12 @@ export const useAuthStore = defineStore('Auth', () => {
|
|||||||
AppApi.FlashWindow();
|
AppApi.FlashWindow();
|
||||||
twoFactorAuthDialogVisible.value = true;
|
twoFactorAuthDialogVisible.value = true;
|
||||||
modalStore
|
modalStore
|
||||||
.prompt({
|
.otpPrompt({
|
||||||
title: t('prompt.totp.header'),
|
title: t('prompt.totp.header'),
|
||||||
description: t('prompt.totp.description'),
|
description: t('prompt.totp.description'),
|
||||||
|
mode: 'totp',
|
||||||
cancelText: t('prompt.totp.use_otp'),
|
cancelText: t('prompt.totp.use_otp'),
|
||||||
confirmText: t('prompt.totp.verify'),
|
confirmText: t('prompt.totp.verify')
|
||||||
pattern: /^[0-9]{6}$/,
|
|
||||||
errorMessage: t('prompt.totp.input_error')
|
|
||||||
})
|
})
|
||||||
.then(({ ok, reason, value }) => {
|
.then(({ ok, reason, value }) => {
|
||||||
twoFactorAuthDialogVisible.value = false;
|
twoFactorAuthDialogVisible.value = false;
|
||||||
@@ -653,13 +652,12 @@ export const useAuthStore = defineStore('Auth', () => {
|
|||||||
}
|
}
|
||||||
twoFactorAuthDialogVisible.value = true;
|
twoFactorAuthDialogVisible.value = true;
|
||||||
modalStore
|
modalStore
|
||||||
.prompt({
|
.otpPrompt({
|
||||||
title: t('prompt.otp.header'),
|
title: t('prompt.otp.header'),
|
||||||
description: t('prompt.otp.description'),
|
description: t('prompt.otp.description'),
|
||||||
|
mode: 'otp',
|
||||||
cancelText: t('prompt.otp.use_totp'),
|
cancelText: t('prompt.otp.use_totp'),
|
||||||
confirmText: t('prompt.otp.verify'),
|
confirmText: t('prompt.otp.verify')
|
||||||
pattern: /^[a-z0-9]{4}-[a-z0-9]{4}$/,
|
|
||||||
errorMessage: t('prompt.otp.input_error')
|
|
||||||
})
|
})
|
||||||
.then(({ ok, reason, value }) => {
|
.then(({ ok, reason, value }) => {
|
||||||
twoFactorAuthDialogVisible.value = false;
|
twoFactorAuthDialogVisible.value = false;
|
||||||
@@ -672,7 +670,7 @@ export const useAuthStore = defineStore('Auth', () => {
|
|||||||
|
|
||||||
authRequest
|
authRequest
|
||||||
.verifyOTP({
|
.verifyOTP({
|
||||||
code: value.trim()
|
code: `${value.slice(0, 4)}-${value.slice(4)}`
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -694,13 +692,12 @@ export const useAuthStore = defineStore('Auth', () => {
|
|||||||
AppApi.FlashWindow();
|
AppApi.FlashWindow();
|
||||||
twoFactorAuthDialogVisible.value = true;
|
twoFactorAuthDialogVisible.value = true;
|
||||||
modalStore
|
modalStore
|
||||||
.prompt({
|
.otpPrompt({
|
||||||
title: t('prompt.email_otp.header'),
|
title: t('prompt.email_otp.header'),
|
||||||
description: t('prompt.email_otp.description'),
|
description: t('prompt.email_otp.description'),
|
||||||
|
mode: 'emailOtp',
|
||||||
cancelText: t('prompt.email_otp.resend'),
|
cancelText: t('prompt.email_otp.resend'),
|
||||||
confirmText: t('prompt.email_otp.verify'),
|
confirmText: t('prompt.email_otp.verify')
|
||||||
pattern: /^[0-9]{6}$/,
|
|
||||||
errorMessage: t('prompt.email_otp.input_error')
|
|
||||||
})
|
})
|
||||||
.then(({ ok, reason, value }) => {
|
.then(({ ok, reason, value }) => {
|
||||||
twoFactorAuthDialogVisible.value = false;
|
twoFactorAuthDialogVisible.value = false;
|
||||||
|
|||||||
+115
-1
@@ -45,6 +45,16 @@ import { useI18n } from 'vue-i18n';
|
|||||||
* @property {boolean=} dismissible
|
* @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', () => {
|
export const useModalStore = defineStore('Modal', () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -67,10 +77,20 @@ export const useModalStore = defineStore('Modal', () => {
|
|||||||
const promptPattern = ref(null);
|
const promptPattern = ref(null);
|
||||||
const promptErrorMessage = ref('');
|
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} */
|
/** @type {{ resolve: ((result: ConfirmResult) => void) | null } | null} */
|
||||||
let pending = null;
|
let pending = null;
|
||||||
/** @type {{ resolve: ((result: PromptResult) => void) | null } | null} */
|
/** @type {{ resolve: ((result: PromptResult) => void) | null } | null} */
|
||||||
let pendingPrompt = null;
|
let pendingPrompt = null;
|
||||||
|
/** @type {{ resolve: ((result: PromptResult) => void) | null } | null} */
|
||||||
|
let pendingOtp = null;
|
||||||
|
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
alertOpen.value = false;
|
alertOpen.value = false;
|
||||||
@@ -271,6 +291,88 @@ export const useModalStore = defineStore('Modal', () => {
|
|||||||
promptOpen.value = !!open;
|
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 {
|
return {
|
||||||
alertOpen,
|
alertOpen,
|
||||||
alertMode,
|
alertMode,
|
||||||
@@ -289,10 +391,18 @@ export const useModalStore = defineStore('Modal', () => {
|
|||||||
promptInputType,
|
promptInputType,
|
||||||
promptPattern,
|
promptPattern,
|
||||||
promptErrorMessage,
|
promptErrorMessage,
|
||||||
|
otpOpen,
|
||||||
|
otpTitle,
|
||||||
|
otpDescription,
|
||||||
|
otpOkText,
|
||||||
|
otpCancelText,
|
||||||
|
otpDismissible,
|
||||||
|
otpMode,
|
||||||
|
|
||||||
confirm,
|
confirm,
|
||||||
alert,
|
alert,
|
||||||
prompt,
|
prompt,
|
||||||
|
otpPrompt,
|
||||||
|
|
||||||
handleOk,
|
handleOk,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
@@ -301,6 +411,10 @@ export const useModalStore = defineStore('Modal', () => {
|
|||||||
handlePromptCancel,
|
handlePromptCancel,
|
||||||
handlePromptDismiss,
|
handlePromptDismiss,
|
||||||
setAlertOpen,
|
setAlertOpen,
|
||||||
setPromptOpen
|
setPromptOpen,
|
||||||
|
handleOtpOk,
|
||||||
|
handleOtpCancel,
|
||||||
|
handleOtpDismiss,
|
||||||
|
setOtpOpen
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user