add OtpDialogModal

This commit is contained in:
pa
2026-02-12 20:49:55 +09:00
parent c93b3fbf9f
commit e643b6b5ad
14 changed files with 967 additions and 15 deletions

54
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -8,6 +8,7 @@
<AlertDialogModal></AlertDialogModal>
<PromptDialogModal></PromptDialogModal>
<OtpDialogModal></OtpDialogModal>
<VRCXUpdateDialog></VRCXUpdateDialog>
</div>
@@ -25,6 +26,7 @@
import AlertDialogModal from './components/ui/alert-dialog/AlertDialogModal.vue';
import MacOSTitleBar from './components/MacOSTitleBar.vue';
import OtpDialogModal from './components/ui/dialog/OtpDialogModal.vue';
import PromptDialogModal from './components/ui/dialog/PromptDialogModal.vue';
import VRCXUpdateDialog from './components/dialogs/VRCXUpdateDialog.vue';

View File

@@ -0,0 +1,348 @@
/* eslint-disable pretty-import/sort-import-groups */
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { createI18n } from 'vue-i18n';
import { createTestingPinia } from '@pinia/testing';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import en from '../../localization/en.json';
vi.mock('../../views/Feed/Feed.vue', () => ({
default: { template: '<div />' }
}));
vi.mock('../../views/Feed/columns.jsx', () => ({ columns: [] }));
vi.mock('../../plugin/router', () => ({
router: {
beforeEach: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
currentRoute: ref({ path: '/', name: '', meta: {} }),
isReady: vi.fn().mockResolvedValue(true)
},
initRouter: vi.fn()
}));
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
currentRoute: ref({ path: '/', name: '', meta: {} })
}))
};
});
vi.mock('../../plugin/interopApi', () => ({ initInteropApi: vi.fn() }));
vi.mock('../../service/database', () => ({
database: new Proxy(
{},
{
get: (_target, prop) => {
if (prop === '__esModule') return false;
return vi.fn().mockResolvedValue(null);
}
}
)
}));
vi.mock('../../service/config', () => ({
default: {
init: vi.fn(),
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
setString: vi.fn(),
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
setBool: vi.fn(),
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
setInt: vi.fn(),
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
setFloat: vi.fn(),
getObject: vi.fn().mockReturnValue(null),
setObject: vi.fn(),
getArray: vi.fn().mockReturnValue([]),
setArray: vi.fn(),
remove: vi.fn()
}
}));
vi.mock('../../service/jsonStorage', () => ({ default: vi.fn() }));
vi.mock('../../service/watchState', () => ({
watchState: { isLoggedIn: false }
}));
import OtpDialogModal from '../ui/dialog/OtpDialogModal.vue';
import { useModalStore } from '../../stores/modal';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
legacy: false,
globalInjection: false,
missingWarn: false,
fallbackWarn: false,
messages: { en }
});
// Stubs: render children directly so we can inspect DOM without real reka-ui portals
const stubs = {
Dialog: {
template: '<div class="dialog-stub" v-if="open"><slot /></div>',
props: ['open']
},
DialogContent: {
template: '<div class="dialog-content-stub"><slot /></div>'
},
DialogHeader: { template: '<div class="dialog-header"><slot /></div>' },
DialogTitle: { template: '<span class="dialog-title"><slot /></span>' },
DialogDescription: {
template: '<span class="dialog-description"><slot /></span>'
},
DialogFooter: { template: '<div class="dialog-footer"><slot /></div>' },
InputOTP: {
name: 'InputOTP',
template:
'<div class="input-otp-stub"><input :data-maxlength="maxlength" :inputmode="inputmode" /><slot /></div>',
props: ['maxlength', 'inputmode', 'modelValue', 'pasteTransformer'],
emits: ['update:modelValue', 'complete']
},
InputOTPGroup: { template: '<div class="otp-group"><slot /></div>' },
InputOTPSlot: {
template: '<div class="otp-slot" :data-index="index"></div>',
props: ['index']
},
InputOTPSeparator: { template: '<div class="otp-separator">-</div>' }
};
function mountOtpDialog(storeOverrides = {}) {
const pinia = createTestingPinia({
stubActions: false,
initialState: {
Modal: storeOverrides
}
});
return mount(OtpDialogModal, {
global: {
plugins: [i18n, pinia],
stubs
}
});
}
describe('OtpDialogModal.vue', () => {
let store;
beforeEach(() => {
vi.clearAllMocks();
});
describe('visibility', () => {
test('does not render when otpOpen is false', () => {
const wrapper = mountOtpDialog({ otpOpen: false });
store = useModalStore();
expect(wrapper.find('.dialog-stub').exists()).toBe(false);
});
test('renders dialog when otpOpen is true', () => {
const wrapper = mountOtpDialog({ otpOpen: true });
store = useModalStore();
expect(wrapper.find('.dialog-stub').exists()).toBe(true);
});
});
describe('title and description', () => {
test('displays title and description from store', () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpTitle: 'Enter TOTP Code',
otpDescription: 'Check your authenticator app'
});
store = useModalStore();
expect(wrapper.find('.dialog-title').text()).toBe(
'Enter TOTP Code'
);
expect(wrapper.find('.dialog-description').text()).toBe(
'Check your authenticator app'
);
});
});
describe('mode rendering', () => {
test('renders 6 slots for totp mode', () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpMode: 'totp'
});
store = useModalStore();
const otpInput = wrapper.find('.input-otp-stub');
expect(otpInput.exists()).toBe(true);
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
'6'
);
expect(otpInput.find('input').attributes('inputmode')).toBe(
'numeric'
);
const slots = wrapper.findAll('.otp-slot');
expect(slots).toHaveLength(6);
expect(wrapper.find('.otp-separator').exists()).toBe(false);
});
test('renders 6 slots for emailOtp mode', () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpMode: 'emailOtp'
});
store = useModalStore();
const otpInput = wrapper.find('.input-otp-stub');
expect(otpInput.exists()).toBe(true);
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
'6'
);
expect(otpInput.find('input').attributes('inputmode')).toBe(
'numeric'
);
const slots = wrapper.findAll('.otp-slot');
expect(slots).toHaveLength(6);
});
test('renders 8 slots with separator for otp (recovery) mode', () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpMode: 'otp'
});
store = useModalStore();
const otpInput = wrapper.find('.input-otp-stub');
expect(otpInput.exists()).toBe(true);
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
'8'
);
expect(otpInput.find('input').attributes('inputmode')).toBe('text');
const slots = wrapper.findAll('.otp-slot');
expect(slots).toHaveLength(8);
expect(wrapper.find('.otp-separator').exists()).toBe(true);
});
test('does not render otp input when mode is totp', () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpMode: 'totp'
});
store = useModalStore();
// Should not have the 8-slot recovery code input
const inputs = wrapper.findAll('.input-otp-stub');
expect(inputs).toHaveLength(1);
expect(inputs[0].find('input').attributes('data-maxlength')).toBe(
'6'
);
});
});
describe('button text', () => {
test('displays ok and cancel text from store', () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpOkText: 'Verify',
otpCancelText: 'Use Recovery'
});
store = useModalStore();
const buttons = wrapper.findAll('button');
const cancelBtn = buttons.find((b) => b.text() === 'Use Recovery');
const okBtn = buttons.find((b) => b.text() === 'Verify');
expect(cancelBtn).toBeTruthy();
expect(okBtn).toBeTruthy();
});
});
describe('cancel button', () => {
test('calls handleOtpCancel when cancel button is clicked', async () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpOkText: 'Verify',
otpCancelText: 'Cancel'
});
store = useModalStore();
const spy = vi.spyOn(store, 'handleOtpCancel');
const cancelBtn = wrapper
.findAll('button')
.find((b) => b.text() === 'Cancel');
await cancelBtn.trigger('click');
expect(spy).toHaveBeenCalledWith('');
});
});
describe('submit', () => {
test('does not call handleOtpOk on form submit when value is empty', async () => {
const wrapper = mountOtpDialog({
otpOpen: true
});
store = useModalStore();
const spy = vi.spyOn(store, 'handleOtpOk');
await wrapper.find('form').trigger('submit');
expect(spy).not.toHaveBeenCalled();
});
});
describe('complete event', () => {
test('calls handleOtpOk when InputOTP emits complete', async () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpMode: 'totp'
});
store = useModalStore();
const spy = vi.spyOn(store, 'handleOtpOk');
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
otpInput.vm.$emit('complete', '123456');
await wrapper.vm.$nextTick();
expect(spy).toHaveBeenCalledWith('123456');
});
});
describe('value reset', () => {
test('resets otpValue when dialog opens', async () => {
const wrapper = mountOtpDialog({
otpOpen: false,
otpMode: 'totp'
});
store = useModalStore();
// Simulate opening: the watcher clears the value
store.otpOpen = true;
await wrapper.vm.$nextTick();
// Wait for the watcher + nextTick inside it
await wrapper.vm.$nextTick();
// Dialog should now be visible and InputOTP should have empty modelValue
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
expect(otpInput.exists()).toBe(true);
expect(otpInput.props('modelValue')).toBe('');
});
});
describe('paste transformer', () => {
test('recovery code InputOTP has pasteTransformer that strips non-alphanumeric chars', () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpMode: 'otp'
});
store = useModalStore();
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
const transformer = otpInput.props('pasteTransformer');
expect(transformer).toBeTypeOf('function');
expect(transformer('abcd-1234')).toBe('abcd1234');
expect(transformer('ab-cd-12-34')).toBe('abcd1234');
expect(transformer('abcd1234')).toBe('abcd1234');
});
test('totp InputOTP does not have pasteTransformer', () => {
const wrapper = mountOtpDialog({
otpOpen: true,
otpMode: 'totp'
});
store = useModalStore();
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
expect(otpInput.props('pasteTransformer')).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,128 @@
<script setup>
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '@/components/ui/input-otp';
import { ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { storeToRefs } from 'pinia';
import { useModalStore } from '@/stores';
const modalStore = useModalStore();
const { otpOpen, otpTitle, otpDescription, otpOkText, otpCancelText, otpDismissible, otpMode } =
storeToRefs(modalStore);
const otpValue = ref('');
function onComplete(value) {
modalStore.handleOtpOk(value);
}
function onSubmit() {
if (!otpValue.value) return;
modalStore.handleOtpOk(otpValue.value);
}
function onEscapeKeyDown(event) {
if (!otpDismissible.value) {
event.preventDefault();
return;
}
modalStore.handleOtpDismiss(otpValue.value);
}
function onPointerDownOutside(event) {
if (!otpDismissible.value) {
event.preventDefault();
return;
}
modalStore.handleOtpDismiss(otpValue.value);
}
function onInteractOutside(event) {
if (!otpDismissible.value) {
event.preventDefault();
return;
}
modalStore.handleOtpDismiss(otpValue.value);
}
function handleCancel() {
modalStore.handleOtpCancel(otpValue.value);
}
function stripNonAlphanumeric(value) {
return value.replace(/[^a-z0-9]/gi, '');
}
watch(otpOpen, (open) => {
if (open) {
otpValue.value = '';
}
});
</script>
<template>
<Dialog :open="otpOpen" @update:open="modalStore.setOtpOpen">
<DialogContent
:show-close-button="false"
@escapeKeyDown="onEscapeKeyDown"
@pointerDownOutside="onPointerDownOutside"
@interactOutside="onInteractOutside">
<form @submit.prevent="onSubmit">
<DialogHeader class="mb-5">
<DialogTitle>{{ otpTitle }}</DialogTitle>
<DialogDescription>{{ otpDescription }}</DialogDescription>
</DialogHeader>
<div class="flex justify-center">
<!-- TOTP / EmailOTP: 6 numeric digits -->
<InputOTP
v-if="otpMode === 'totp' || otpMode === 'emailOtp'"
v-model="otpValue"
:maxlength="6"
autofocus
inputmode="numeric"
@complete="onComplete">
<InputOTPGroup>
<InputOTPSlot v-for="i in 6" :key="i - 1" :index="i - 1" />
</InputOTPGroup>
</InputOTP>
<!-- OTP (recovery code): 4+4 alphanumeric with separator -->
<InputOTP
v-if="otpMode === 'otp'"
v-model="otpValue"
:maxlength="8"
autofocus
inputmode="text"
:paste-transformer="stripNonAlphanumeric"
@complete="onComplete">
<InputOTPGroup>
<InputOTPSlot v-for="i in 4" :key="i - 1" :index="i - 1" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot v-for="i in 4" :key="i + 3" :index="i + 3" />
</InputOTPGroup>
</InputOTP>
</div>
<DialogFooter class="mt-5">
<Button type="button" variant="outline" @click="handleCancel">
{{ otpCancelText }}
</Button>
<Button type="submit">
{{ otpOkText }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,78 @@
<script setup>
import { OTPInput } from 'vue-input-otp';
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { useForwardPropsEmits } from 'reka-ui';
const props = defineProps({
maxlength: { type: Number, required: true },
textAlign: { type: String, required: false },
inputmode: { type: String, required: false },
containerClass: { type: String, required: false },
pushPasswordManagerStrategy: { type: String, required: false },
noScriptCssFallback: { type: [String, null], required: false },
defaultValue: { type: null, required: false },
pasteTransformer: { type: Function, required: false },
accept: { type: String, required: false },
alt: { type: String, required: false },
autocomplete: { type: String, required: false },
autofocus: { type: Boolean, required: false },
capture: { type: [Boolean, String], required: false },
checked: { type: [Boolean, Array, Set], required: false },
crossorigin: { type: String, required: false },
disabled: { type: Boolean, required: false },
enterKeyHint: { type: String, required: false },
form: { type: String, required: false },
formaction: { type: String, required: false },
formenctype: { type: String, required: false },
formmethod: { type: String, required: false },
formnovalidate: { type: Boolean, required: false },
formtarget: { type: String, required: false },
height: { type: Number, required: false },
indeterminate: { type: Boolean, required: false },
list: { type: String, required: false },
max: { type: Number, required: false },
min: { type: Number, required: false },
minlength: { type: Number, required: false },
multiple: { type: Boolean, required: false },
name: { type: String, required: false },
pattern: { type: String, required: false },
placeholder: { type: String, required: false },
readonly: { type: Boolean, required: false },
required: { type: Boolean, required: false },
size: { type: Number, required: false },
src: { type: String, required: false },
step: { type: Number, required: false },
type: { type: String, required: false },
value: { type: null, required: false },
width: { type: Number, required: false },
class: { type: null, required: false }
});
const emits = defineEmits([
'complete',
'change',
'select',
'input',
'focus',
'blur',
'mouseover',
'mouseleave',
'paste'
]);
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<OTPInput
v-slot="slotProps"
v-bind="forwarded"
:container-class="cn('flex items-center gap-2 has-disabled:opacity-50', props.class)"
data-slot="input-otp"
class="disabled:cursor-not-allowed">
<slot v-bind="slotProps" />
</OTPInput>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { cn } from '@/lib/utils';
import { reactiveOmit } from '@vueuse/core';
import { useForwardProps } from 'reka-ui';
const props = defineProps({
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
</script>
<template>
<div data-slot="input-otp-group" v-bind="forwarded" :class="cn('flex items-center', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
import { MinusIcon } from 'lucide-vue-next';
import { useForwardProps } from 'reka-ui';
const props = defineProps({
class: { type: null, required: false }
});
const forwarded = useForwardProps(props);
</script>
<template>
<div data-slot="input-otp-separator" role="separator" v-bind="forwarded">
<slot>
<MinusIcon />
</slot>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
import { cn } from '@/lib/utils';
import { computed } from 'vue';
import { reactiveOmit } from '@vueuse/core';
import { useForwardProps } from 'reka-ui';
import { useVueOTPContext } from 'vue-input-otp';
const props = defineProps({
index: { type: Number, required: true },
class: { type: null, required: false }
});
const delegatedProps = reactiveOmit(props, 'class');
const forwarded = useForwardProps(delegatedProps);
const context = useVueOTPContext();
const slot = computed(() => context?.value.slots[props.index]);
</script>
<template>
<div
v-bind="forwarded"
data-slot="input-otp-slot"
:data-active="slot?.isActive"
:class="
cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
props.class
)
">
{{ slot?.char }}
<div v-if="slot?.hasFakeCaret" class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
</div>
</template>

View File

@@ -0,0 +1,4 @@
export { default as InputOTP } from './InputOTP.vue';
export { default as InputOTPGroup } from './InputOTPGroup.vue';
export { default as InputOTPSeparator } from './InputOTPSeparator.vue';
export { default as InputOTPSlot } from './InputOTPSlot.vue';

View File

@@ -1968,7 +1968,7 @@
"totp": {
"header": "双重认证",
"description": "输入你的身份验证应用中的验证码",
"use_otp": "使用一次性邮件验证码或备用验证码",
"use_otp": "使用备用验证码",
"verify": "验证",
"input_error": "无效的验证码"
},

View File

@@ -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('');
});
});
});

View File

@@ -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;

View File

@@ -45,6 +45,16 @@ import { useI18n } from 'vue-i18n';
* @property {boolean=} dismissible
*/
/**
* @typedef {Object} OtpPromptOptions
* @property {string} title
* @property {string} description
* @property {'totp' | 'emailOtp' | 'otp'} mode
* @property {string=} confirmText
* @property {string=} cancelText
* @property {boolean=} dismissible
*/
export const useModalStore = defineStore('Modal', () => {
const { t } = useI18n();
@@ -67,10 +77,20 @@ export const useModalStore = defineStore('Modal', () => {
const promptPattern = ref(null);
const promptErrorMessage = ref('');
const otpOpen = ref(false);
const otpTitle = ref('');
const otpDescription = ref('');
const otpOkText = ref('');
const otpCancelText = ref('');
const otpDismissible = ref(true);
const otpMode = ref('totp'); // 'totp' | 'emailOtp' | 'otp'
/** @type {{ resolve: ((result: ConfirmResult) => void) | null } | null} */
let pending = null;
/** @type {{ resolve: ((result: PromptResult) => void) | null } | null} */
let pendingPrompt = null;
/** @type {{ resolve: ((result: PromptResult) => void) | null } | null} */
let pendingOtp = null;
function closeDialog() {
alertOpen.value = false;
@@ -271,6 +291,88 @@ export const useModalStore = defineStore('Modal', () => {
promptOpen.value = !!open;
}
// --- OTP dialog ---
function closeOtpDialog() {
otpOpen.value = false;
}
/**
* @param {'ok' | 'cancel' | 'dismiss' | 'replaced'} reason
* @param {string} value
*/
function finishOtp(reason, value) {
const resolve = pendingOtp?.resolve;
pendingOtp = null;
closeOtpDialog();
if (resolve) resolve({ ok: reason === 'ok', reason, value });
}
/**
* @param {'ok' | 'cancel' | 'dismiss' | 'replaced'} reason
* @param {string} value
*/
function finishOtpWithoutClosing(reason, value) {
const resolve = pendingOtp?.resolve;
pendingOtp = null;
if (resolve) resolve({ ok: reason === 'ok', reason, value });
}
/**
* @param {OtpPromptOptions} options
* @returns {Promise<PromptResult>}
*/
function openOtp(options) {
if (pendingOtp) {
finishOtpWithoutClosing('replaced', '');
}
otpTitle.value = options.title;
otpDescription.value = options.description;
otpDismissible.value = options.dismissible !== false;
otpMode.value = options.mode || 'totp';
otpOkText.value =
options.confirmText || t('dialog.alertdialog.confirm');
otpCancelText.value =
options.cancelText || t('dialog.alertdialog.cancel');
otpOpen.value = true;
return new Promise((resolve) => {
pendingOtp = { resolve };
});
}
/**
* otpPrompt: always resolve({ok, reason, value})
* @param {OtpPromptOptions} options
* @returns {Promise<PromptResult>}
*/
function otpPrompt(options) {
return openOtp(options);
}
function handleOtpOk(value) {
if (!pendingOtp) return;
finishOtp('ok', value ?? '');
}
function handleOtpCancel(value) {
if (!pendingOtp) return;
finishOtp('cancel', value ?? '');
}
function handleOtpDismiss(value) {
if (!pendingOtp) return;
if (!otpDismissible.value) return;
finishOtp('dismiss', value ?? '');
}
function setOtpOpen(open) {
otpOpen.value = !!open;
}
return {
alertOpen,
alertMode,
@@ -289,10 +391,18 @@ export const useModalStore = defineStore('Modal', () => {
promptInputType,
promptPattern,
promptErrorMessage,
otpOpen,
otpTitle,
otpDescription,
otpOkText,
otpCancelText,
otpDismissible,
otpMode,
confirm,
alert,
prompt,
otpPrompt,
handleOk,
handleCancel,
@@ -301,6 +411,10 @@ export const useModalStore = defineStore('Modal', () => {
handlePromptCancel,
handlePromptDismiss,
setAlertOpen,
setPromptOpen
setPromptOpen,
handleOtpOk,
handleOtpCancel,
handleOtpDismiss,
setOtpOpen
};
});