Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 39 additions & 52 deletions custom/TwoFactorsConfirmation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,30 @@
<div class="relative p-4 w-full max-w-md max-h-full">
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
<div class="p-8 w-full max-w-md max-h-full" >
<div class="m-3">{{$t('Please enter your authenticator code')}} </div>
<div class="my-4 flex justify-center items-center">
<div class="p-8 w-full max-w-md max-h-full custom-auth-wrapper" >
<div id="mfaCode-label" class="m-4">{{$t('Please enter your authenticator code')}} </div>
<div class="my-4 w-full flex justify-center" ref="otpRoot">
<v-otp-input
ref="code"
container-class="grid grid-cols-6 gap-3 w-full"
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"
:conditionalClass="['one', 'two', 'three', 'four', 'five', 'six']"
:num-inputs="6"
inputType="number"
inputmode="numeric"
:num-inputs="6"
v-model:value="bindValue"
:should-auto-focus="true"
:should-focus-order="true"
v-model:value="bindValue"
@on-complete="handleOnComplete"
/>
</div>
<!-- <Vue2FACodeInput v-model="code"/> -->
<LinkButton to="/login" class="w-full">{{$t('Back to login')}}</LinkButton>
<div class="mt-6 flex justify-center">
<LinkButton
to="/login"
class="w-[290px] mx-4"
>
{{$t('Back to login')}}
</LinkButton>
</div>
</div>
</div>
</div>
Expand All @@ -39,68 +45,50 @@


</template>


<script setup>
import { onMounted, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';

import { onMounted, nextTick, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';
import { useCoreStore } from '@/stores/core';
import { useUserStore } from '@/stores/user';
import { IconEyeSolid, IconEyeSlashSolid } from '@iconify-prerendered/vue-flowbite';
import { callAdminForthApi, loadFile } from '@/utils';
import { useRouter } from 'vue-router';
import { showErrorTost } from '@/composables/useFrontendApi';
import { LinkButton } from '@/afcl';
import Vue2FACodeInput from '@loltech/vue3-2fa-code-input';
import VOtpInput from "vue3-otp-input";
import { useI18n } from 'vue-i18n';

const { t } = useI18n();
const code = ref(null);
const otpRoot = ref(null);
const bindValue = ref('');

const handleOnComplete = (value) => {
sendCode(value);
};

const fillInput = (value) => {
code.value?.fillInput(value);
};
function tagOtpInputs() {
const root = otpRoot.value;
if (!root) return;
const inputs = root.querySelectorAll('input.otp-input');
inputs.forEach((el, idx) => {
el.setAttribute('name', 'mfaCode');
el.setAttribute('id', `mfaCode-${idx + 1}`);
el.setAttribute('autocomplete', 'one-time-code');
el.setAttribute('inputmode', 'numeric');
el.setAttribute('aria-labelledby', 'mfaCode-label');
});
}

const router = useRouter();
const inProgress = ref(false);

const coreStore = useCoreStore();
const user = useUserStore();


// use this simple function to automatically focus on the next input



const showPw = ref(false);

const error = ref(null);
const totp = ref({});
const totpJWT = ref(null);


function parseJwt(token) {
// Split the token into its parts
const base64Url = token.split('.')[1];

// Base64-decode the payload
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));

// Parse the JSON payload
return JSON.parse(jsonPayload);
}


onMounted(async () => {
coreStore.getPublicConfig();
window.addEventListener('paste', handlePaste);
await nextTick();
tagOtpInputs();
});

onBeforeUnmount(() => {
Copy link

Copilot AI Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onBeforeUnmount function references handlePaste in the removeEventListener call, but handlePaste function is not defined in this component and the addEventListener call was removed from onMounted.

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -161,9 +149,9 @@
}

/**
* This particular piece of code makes the last input have a gap in the middle.
*/
.spaced-code-input {
* This particular piece of code makes the last input have a gap in the middle.
*/
.spaced-code-input {
& .vue3-2fa-code-input-box {
&:nth-child(3) {
@apply mr-4;
Expand All @@ -175,4 +163,3 @@
}
}
</style>

37 changes: 27 additions & 10 deletions custom/TwoFactorsSetup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@
<div class="relative p-4 w-full max-w-md max-h-full">
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
<div class="p-10 w-full max-w-md max-h-full" >
<div class="p-10 w-full max-w-md max-h-full custom-auth-wrapper" >
<div class="m-3" >{{$t('Scan this QR code with your authenticator app or open by')}} <a class="text-blue-600" :href="totpUri">{{$t('click')}}</a></div>
<div class="flex justify-center m-3" >
<img :src="totpQrCode" class="min-w-[200px], min-h-[200px]" alt="QR code" />
</div>
<div class="m-3 ">
<div class="m-1">{{$t('Or copy this code to app manually:')}}</div>
<div class="w-full max-w-[46rem]">
<div class="w-full">
<div class="relative">
<label for="npm-install-copy-text" class="sr-only">{{$t('Label')}}</label>
<input id="npm-install-copy-text" type="text" class="col-span-10 bg-gray-50 border border-gray-300 text-gray-500 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full px-2.5 py-4 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 pr-12" :value="totp.newSecret" readonly>
Expand All @@ -33,21 +33,22 @@
</div>
</div>
</div>
<div class="my-4 flex justify-center items-center">
<div class="my-4 w-full flex justify-center p-2" ref="otpRoot">
<v-otp-input
ref="code"
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-11 h-11 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"
:conditionalClass="['one', 'two', 'three', 'four', 'five', 'six']"
inputType="number"
inputmode="numeric"
container-class="grid grid-cols-6 gap-3 w-full"
input-classes="otp-input bg-gray-50 text-center border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block h-[43.33px] w-full 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"
:num-inputs="6"
inputType="number"
inputmode="numeric"
:should-auto-focus="true"
:should-focus-order="true"
v-model:value="bindValue"
@on-complete="handleOnComplete"
/>
</div>
<!-- <Vue2FACodeInput v-model="code" autofocus /> -->
<div class="flex flex-row gap-2.5 pl-3 pr-3 h-12">
<div class="flex flex-row gap-2.5 px-3 h-12">
<LinkButton to="/login" class="w-full">
{{$t('Back to login')}}
</LinkButton>
Expand All @@ -67,7 +68,7 @@

<script setup lang="ts">

import { onMounted, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';
import { onMounted, onBeforeUnmount, nextTick, ref, watchEffect,computed,watch } from 'vue';
import { useCoreStore } from '@/stores/core';
import { useUserStore } from '@/stores/user';
import { IconEyeSolid, IconEyeSlashSolid } from '@iconify-prerendered/vue-flowbite';
Expand All @@ -89,6 +90,7 @@ const handleOnComplete = (value) => {

const router = useRouter();
const inProgress = ref(false);
const otpRoot = ref(null);

const coreStore = useCoreStore();
const user = useUserStore();
Expand Down Expand Up @@ -151,12 +153,27 @@ onMounted(async () => {
}

window.addEventListener('paste', handlePaste);
await nextTick();
tagOtpInputs();
});

onBeforeUnmount(() => {
window.removeEventListener('paste', handlePaste);
});

function tagOtpInputs() {
const root = otpRoot.value;
if (!root) return;
const inputs = root.querySelectorAll('input.otp-input');
inputs.forEach((el, idx) => {
el.setAttribute('name', 'mfaCode');
el.setAttribute('id', `mfaCode-${idx + 1}`);
el.setAttribute('autocomplete', 'one-time-code');
el.setAttribute('inputmode', 'numeric');
el.setAttribute('aria-labelledby', 'mfaCode-label');
});
}

async function sendCode (value) {
inProgress.value = true;

Expand Down Expand Up @@ -206,5 +223,5 @@ const handleSkip = async () => {
<style>
.otp-input {
margin: 0 5px;
}
}
</style>
13 changes: 7 additions & 6 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,16 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
const beforeLoginConfirmation = this.adminforth.config.auth.beforeLoginConfirmation;
const beforeLoginConfirmationArray = Array.isArray(beforeLoginConfirmation) ? beforeLoginConfirmation : [beforeLoginConfirmation];
beforeLoginConfirmationArray.push(
async({ adminUser, response }: { adminUser: AdminUser, response: IAdminForthHttpResponse} )=> {
async({ adminUser, response, extra }: { adminUser: AdminUser, response: IAdminForthHttpResponse, extra?: any} )=> {
const secret = adminUser.dbUser[this.options.twoFaSecretFieldName]
const userName = adminUser.dbUser[adminforth.config.auth.usernameField]
const brandName = adminforth.config.customization.brandName;
const brandNameSlug = adminforth.config.customization.brandNameSlug;
const authResource = adminforth.config.resources.find((res)=>res.resourceId === adminforth.config.auth.usersResourceId )
const authPk = authResource.columns.find((col)=>col.primaryKey).name
const userPk = adminUser.dbUser[authPk]
const rememberMe = extra?.body?.rememberMe || false;
const rememberMeDays = rememberMe ? adminforth.config.auth.rememberMeDays || 30 : 1;
let newSecret = null;

const userNeeds2FA = this.options.usersFilterToApply ? this.options.usersFilterToApply(adminUser) : true;
Expand All @@ -79,7 +81,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
const tempSecret = twofactor.generateSecret({name: brandName,account: userName})
newSecret = tempSecret.secret
} else {
const value = this.adminforth.auth.issueJWT({userName, issuer:brandName, pk:userPk, userCanSkipSetup }, 'tempTotp', '2h');
const value = this.adminforth.auth.issueJWT({userName, issuer:brandName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'tempTotp', '2h');
response.setHeader('Set-Cookie', `adminforth_${brandNameSlug}_totpTemporaryJWT=${value}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; max-age=3600; `);

return {
Expand All @@ -90,7 +92,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
ok: true
}
}
const totpTemporaryJWT = this.adminforth.auth.issueJWT({userName, newSecret, issuer:brandName, pk:userPk, userCanSkipSetup }, 'tempTotp', '2h');
const totpTemporaryJWT = this.adminforth.auth.issueJWT({userName, newSecret, issuer:brandName, pk:userPk, userCanSkipSetup, rememberMeDays }, 'tempTotp', '2h');
response.setHeader('Set-Cookie', `adminforth_${brandNameSlug}_totpTemporaryJWT=${totpTemporaryJWT}; Path=${this.adminforth.config.baseUrl || '/'}; HttpOnly; SameSite=Strict; Expires=${new Date(Date.now() + '1h').toUTCString() } `);

return {
Expand Down Expand Up @@ -127,7 +129,6 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
const brandNameSlug = this.adminforth.config.customization.brandNameSlug;
const totpTemporaryJWT = cookies.find((cookie)=>cookie.key === `adminforth_${brandNameSlug}_totpTemporaryJWT`)?.value;
const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'tempTotp');

if (!decoded)
return {status:'error',message:'Invalid token'}

Expand All @@ -140,7 +141,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
await connector.updateRecord({resource:this.authResource, recordId:decoded.pk, newValues:{[this.options.twoFaSecretFieldName]: decoded.newSecret}})
}
this.adminforth.auth.removeCustomCookie({response, name:'totpTemporaryJWT'})
this.adminforth.auth.setAuthCookie({response, username:decoded.userName, pk:decoded.pk})
this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
return { status: 'ok', allowedLogin: true }
} else {
return {error: 'Wrong or expired OTP code'}
Expand All @@ -153,7 +154,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
const verified = twofactor.verifyToken(user[this.options.twoFaSecretFieldName], body.code, this.options.timeStepWindow);
if (verified) {
this.adminforth.auth.removeCustomCookie({response, name:'totpTemporaryJWT'})
this.adminforth.auth.setAuthCookie({response, username:decoded.userName, pk:decoded.pk})
this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
return { status: 'ok', allowedLogin: true }
} else {
return {error: 'Wrong or expired OTP code'}
Expand Down