From e643b6b5adfb9668e5956f2255ef96dc8391549f Mon Sep 17 00:00:00 2001 From: pa Date: Thu, 12 Feb 2026 20:49:55 +0900 Subject: [PATCH] add OtpDialogModal --- package-lock.json | 54 +++ package.json | 1 + src/App.vue | 2 + .../__tests__/OtpDialogModal.test.js | 348 ++++++++++++++++++ src/components/ui/dialog/OtpDialogModal.vue | 128 +++++++ src/components/ui/input-otp/InputOTP.vue | 78 ++++ src/components/ui/input-otp/InputOTPGroup.vue | 19 + .../ui/input-otp/InputOTPSeparator.vue | 18 + src/components/ui/input-otp/InputOTPSlot.vue | 38 ++ src/components/ui/input-otp/index.js | 4 + src/localization/zh-CN.json | 2 +- src/stores/__tests__/modal.test.js | 151 ++++++++ src/stores/auth.js | 23 +- src/stores/modal.js | 116 +++++- 14 files changed, 967 insertions(+), 15 deletions(-) create mode 100644 src/components/__tests__/OtpDialogModal.test.js create mode 100644 src/components/ui/dialog/OtpDialogModal.vue create mode 100644 src/components/ui/input-otp/InputOTP.vue create mode 100644 src/components/ui/input-otp/InputOTPGroup.vue create mode 100644 src/components/ui/input-otp/InputOTPSeparator.vue create mode 100644 src/components/ui/input-otp/InputOTPSlot.vue create mode 100644 src/components/ui/input-otp/index.js 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 }; });