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
+151
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('');
});
});
});
+10 -13
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;
+115 -1
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
};
});