Skip to content

Commit bb6305d

Browse files
committed
feat: add TwoFaModal component for OTP input and verification handling
1 parent 306a6ca commit bb6305d

File tree

2 files changed

+166
-0
lines changed

2 files changed

+166
-0
lines changed

custom/TwoFaModal.vue

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<template>
2+
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 top-0 bottom-0 left-0 right-0"
3+
v-if ="modelShow">
4+
<div class="relative bg-white dark:bg-gray-700 rounded-lg shadow p-6 w-full max-w-md">
5+
<div id="mfaCode-label" class="mb-4 text-gray-700 dark:text-gray-100">
6+
{{ $t('Please enter your authenticator code') }}
7+
</div>
8+
9+
<div class="my-4 w-full flex justify-center" ref="otpRoot">
10+
<v-otp-input
11+
ref="code"
12+
container-class="grid grid-cols-6 gap-3 w-full"
13+
input-classes="bg-gray-50 text-center flex justify-center otp-input border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-10 h-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
14+
:num-inputs="6"
15+
inputType="number"
16+
inputmode="numeric"
17+
:should-auto-focus="true"
18+
:should-focus-order="true"
19+
v-model:value="bindValue"
20+
@on-complete="handleOnComplete"
21+
/>
22+
</div>
23+
24+
<div class="mt-6 flex justify-center gap-3">
25+
<button
26+
class="px-4 py-2 rounded border bg-gray-100 dark:bg-gray-600"
27+
@click="onCancel"
28+
:disabled="inProgress"
29+
>{{ $t('Cancel') }}</button>
30+
</div>
31+
</div>
32+
</div>
33+
</template>
34+
35+
<script setup lang="ts">
36+
import VOtpInput from 'vue3-otp-input';
37+
import { ref, nextTick, onMounted, onBeforeUnmount } from 'vue';
38+
import { useUserStore } from '@/stores/user';
39+
import { useI18n } from 'vue-i18n';
40+
declare global {
41+
interface Window {
42+
adminforthTwoFaModal: {
43+
getCode: () => Promise<any>;
44+
};
45+
}
46+
}
47+
const props = defineProps<{
48+
autoFinishLogin?: boolean
49+
}>();
50+
const emit = defineEmits<{
51+
(e: 'resolved', payload: any): void
52+
(e: 'rejected', err?: any): void
53+
(e: 'closed'): void
54+
}>();
55+
56+
const modelShow = ref(false);
57+
let resolveFn: ((code: string) => void) | null = null;
58+
let verifyingCallback: ((code: string) => boolean) | null = null;
59+
let verifyFn: null | ((code: string) => Promise<boolean> | boolean) = null;
60+
61+
window.adminforthTwoFaModal = {
62+
getCode: (verifyingCallback?: () => Promise<boolean>) => new Promise((resolve) => {
63+
if (modelShow.value) {
64+
throw new Error('Modal is already open');
65+
}
66+
modelShow.value = true;
67+
resolveFn = resolve;
68+
verifyFn = verifyingCallback ?? null;
69+
}),
70+
};
71+
72+
const { t } = useI18n();
73+
const user = useUserStore();
74+
75+
const code = ref<any>(null);
76+
const otpRoot = ref<HTMLElement | null>(null);
77+
const bindValue = ref('');
78+
79+
function tagOtpInputs() {
80+
const root = otpRoot.value;
81+
if (!root) return;
82+
root.querySelectorAll('input.otp-input').forEach((el, idx) => {
83+
el.setAttribute('name', 'mfaCode');
84+
el.setAttribute('id', `mfaCode-${idx + 1}`);
85+
el.setAttribute('autocomplete', 'one-time-code');
86+
el.setAttribute('inputmode', 'numeric');
87+
el.setAttribute('aria-labelledby', 'mfaCode-label');
88+
});
89+
}
90+
91+
function handlePaste(event: ClipboardEvent) {
92+
event.preventDefault();
93+
const pastedText = event.clipboardData?.getData('text') || '';
94+
if (pastedText.length === 6) {
95+
code.value?.fillInput(pastedText);
96+
}
97+
}
98+
99+
async function submit() {
100+
if (bindValue.value.length !== 6) return;
101+
await sendCode(bindValue.value);
102+
}
103+
104+
async function handleOnComplete(value: string) {
105+
await sendCode(value);
106+
}
107+
108+
async function sendCode(value: string) {
109+
if (!resolveFn) {
110+
throw new Error('Modal is not initialized properly');
111+
}
112+
113+
if (verifyFn) {
114+
try {
115+
const ok = await verifyFn(value);
116+
if (!ok) {
117+
return;
118+
}
119+
} catch {
120+
return;
121+
}
122+
}
123+
124+
modelShow.value = false;
125+
resolveFn(value);
126+
}
127+
128+
129+
function onCancel() {
130+
modelShow.value = false;
131+
emit('rejected', new Error('cancelled'));
132+
emit('closed');
133+
}
134+
135+
onMounted(async () => {
136+
await nextTick();
137+
tagOtpInputs();
138+
window.addEventListener('paste', handlePaste as any);
139+
});
140+
onBeforeUnmount(() => {
141+
window.removeEventListener('paste', handlePaste as any);
142+
});
143+
</script>
144+

index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
3333
path:'/setup2fa',
3434
component: { file: this.componentPath('TwoFactorsSetup.vue'), meta: { title: 'Setup 2FA', customLayout: true }}
3535
})
36+
const everyPageBottomInjections = this.adminforth.config.customization.globalInjections.everyPageBottom || []
37+
everyPageBottomInjections.push({ file: this.componentPath('TwoFaModal.vue'), meta: {} })
3638
this.activate( resourceConfig, adminforth )
3739
}
3840

@@ -188,5 +190,25 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
188190
}
189191
},
190192
});
193+
server.endpoint({
194+
method: 'POST',
195+
path: `/plugin/twofa/verify`,
196+
noAuth: false,
197+
handler: async ({ adminUser, body }) => {
198+
if (!body?.code) return { error: 'Code is required' };
199+
200+
const authRes = this.adminforth.config.resources
201+
.find(r => r.resourceId === this.adminforth.config.auth.usersResourceId);
202+
const connector = this.adminforth.connectors[authRes.dataSource];
203+
const pkName = authRes.columns.find(c => c.primaryKey).name;
204+
const user = await connector.getRecordByPrimaryKey(authRes, adminUser.dbUser[pkName]);
205+
206+
const secret = user[this.options.twoFaSecretFieldName];
207+
if (!secret) return { error: '2FA is not set up for this user' };
208+
209+
const verified = twofactor.verifyToken(secret, body.code, this.options.timeStepWindow);
210+
return verified ? { ok: true } : { error: 'Wrong or expired OTP code' };
211+
}
212+
});
191213
}
192214
}

0 commit comments

Comments
 (0)