diff --git a/package-lock.json b/package-lock.json
index 3179a1e7..4002698b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 04c1db07..0ed9ecdd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.vue b/src/App.vue
index 4cbc7505..e4f50f80 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -8,6 +8,7 @@
+
@@ -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';
diff --git a/src/components/__tests__/OtpDialogModal.test.js b/src/components/__tests__/OtpDialogModal.test.js
new file mode 100644
index 00000000..1767b135
--- /dev/null
+++ b/src/components/__tests__/OtpDialogModal.test.js
@@ -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: '
' }
+}));
+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: '
',
+ props: ['open']
+ },
+ DialogContent: {
+ template: '
'
+ },
+ DialogHeader: { template: '' },
+ DialogTitle: { template: '' },
+ DialogDescription: {
+ template: ''
+ },
+ DialogFooter: { template: '' },
+ InputOTP: {
+ name: 'InputOTP',
+ template:
+ '
',
+ props: ['maxlength', 'inputmode', 'modelValue', 'pasteTransformer'],
+ emits: ['update:modelValue', 'complete']
+ },
+ InputOTPGroup: { template: '
' },
+ InputOTPSlot: {
+ template: '',
+ props: ['index']
+ },
+ InputOTPSeparator: { template: '-
' }
+};
+
+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();
+ });
+ });
+});
diff --git a/src/components/ui/dialog/OtpDialogModal.vue b/src/components/ui/dialog/OtpDialogModal.vue
new file mode 100644
index 00000000..de47d99a
--- /dev/null
+++ b/src/components/ui/dialog/OtpDialogModal.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
diff --git a/src/components/ui/input-otp/InputOTP.vue b/src/components/ui/input-otp/InputOTP.vue
new file mode 100644
index 00000000..d6012580
--- /dev/null
+++ b/src/components/ui/input-otp/InputOTP.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/input-otp/InputOTPGroup.vue b/src/components/ui/input-otp/InputOTPGroup.vue
new file mode 100644
index 00000000..6ffb016e
--- /dev/null
+++ b/src/components/ui/input-otp/InputOTPGroup.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
diff --git a/src/components/ui/input-otp/InputOTPSeparator.vue b/src/components/ui/input-otp/InputOTPSeparator.vue
new file mode 100644
index 00000000..b44ae079
--- /dev/null
+++ b/src/components/ui/input-otp/InputOTPSeparator.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/input-otp/InputOTPSlot.vue b/src/components/ui/input-otp/InputOTPSlot.vue
new file mode 100644
index 00000000..01853a7e
--- /dev/null
+++ b/src/components/ui/input-otp/InputOTPSlot.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
diff --git a/src/components/ui/input-otp/index.js b/src/components/ui/input-otp/index.js
new file mode 100644
index 00000000..efba8d73
--- /dev/null
+++ b/src/components/ui/input-otp/index.js
@@ -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';
diff --git a/src/localization/zh-CN.json b/src/localization/zh-CN.json
index 06f10175..34a12983 100644
--- a/src/localization/zh-CN.json
+++ b/src/localization/zh-CN.json
@@ -1968,7 +1968,7 @@
"totp": {
"header": "双重认证",
"description": "输入你的身份验证应用中的验证码",
- "use_otp": "使用一次性邮件验证码或备用验证码",
+ "use_otp": "使用备用验证码",
"verify": "验证",
"input_error": "无效的验证码"
},
diff --git a/src/stores/__tests__/modal.test.js b/src/stores/__tests__/modal.test.js
index 847aa395..3f77ed2a 100644
--- a/src/stores/__tests__/modal.test.js
+++ b/src/stores/__tests__/modal.test.js
@@ -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('');
+ });
});
});
diff --git a/src/stores/auth.js b/src/stores/auth.js
index b0b95435..7bb70bbb 100644
--- a/src/stores/auth.js
+++ b/src/stores/auth.js
@@ -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;
diff --git a/src/stores/modal.js b/src/stores/modal.js
index 2fde57bf..d0a54893 100644
--- a/src/stores/modal.js
+++ b/src/stores/modal.js
@@ -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}
+ */
+ 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}
+ */
+ 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
};
});