Skip to content

Commit 306a6ca

Browse files
authored
Merge pull request #5 from devforth/add-remember-me
feat: add remember me functionality and improve OTP input accessibili…
2 parents d7df218 + 3712313 commit 306a6ca

File tree

3 files changed

+73
-68
lines changed

3 files changed

+73
-68
lines changed

custom/TwoFactorsConfirmation.vue

Lines changed: 39 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,30 @@
1212
<div class="relative p-4 w-full max-w-md max-h-full">
1313
<!-- Modal content -->
1414
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
15-
<div class="p-8 w-full max-w-md max-h-full" >
16-
<div class="m-3">{{$t('Please enter your authenticator code')}} </div>
17-
<div class="my-4 flex justify-center items-center">
15+
<div class="p-8 w-full max-w-md max-h-full custom-auth-wrapper" >
16+
<div id="mfaCode-label" class="m-4">{{$t('Please enter your authenticator code')}} </div>
17+
<div class="my-4 w-full flex justify-center" ref="otpRoot">
1818
<v-otp-input
1919
ref="code"
20+
container-class="grid grid-cols-6 gap-3 w-full"
2021
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"
21-
:conditionalClass="['one', 'two', 'three', 'four', 'five', 'six']"
22+
:num-inputs="6"
2223
inputType="number"
2324
inputmode="numeric"
24-
:num-inputs="6"
25-
v-model:value="bindValue"
2625
:should-auto-focus="true"
2726
:should-focus-order="true"
27+
v-model:value="bindValue"
2828
@on-complete="handleOnComplete"
2929
/>
3030
</div>
31-
<!-- <Vue2FACodeInput v-model="code"/> -->
32-
<LinkButton to="/login" class="w-full">{{$t('Back to login')}}</LinkButton>
31+
<div class="mt-6 flex justify-center">
32+
<LinkButton
33+
to="/login"
34+
class="w-[290px] mx-4"
35+
>
36+
{{$t('Back to login')}}
37+
</LinkButton>
38+
</div>
3339
</div>
3440
</div>
3541
</div>
@@ -39,68 +45,50 @@
3945

4046

4147
</template>
42-
43-
48+
49+
4450
<script setup>
45-
46-
import { onMounted, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';
51+
52+
import { onMounted, nextTick, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';
4753
import { useCoreStore } from '@/stores/core';
4854
import { useUserStore } from '@/stores/user';
49-
import { IconEyeSolid, IconEyeSlashSolid } from '@iconify-prerendered/vue-flowbite';
5055
import { callAdminForthApi, loadFile } from '@/utils';
51-
import { useRouter } from 'vue-router';
5256
import { showErrorTost } from '@/composables/useFrontendApi';
5357
import { LinkButton } from '@/afcl';
54-
import Vue2FACodeInput from '@loltech/vue3-2fa-code-input';
5558
import VOtpInput from "vue3-otp-input";
5659
import { useI18n } from 'vue-i18n';
5760
5861
const { t } = useI18n();
5962
const code = ref(null);
63+
const otpRoot = ref(null);
64+
const bindValue = ref('');
6065
6166
const handleOnComplete = (value) => {
6267
sendCode(value);
6368
};
6469
65-
const fillInput = (value) => {
66-
code.value?.fillInput(value);
67-
};
70+
function tagOtpInputs() {
71+
const root = otpRoot.value;
72+
if (!root) return;
73+
const inputs = root.querySelectorAll('input.otp-input');
74+
inputs.forEach((el, idx) => {
75+
el.setAttribute('name', 'mfaCode');
76+
el.setAttribute('id', `mfaCode-${idx + 1}`);
77+
el.setAttribute('autocomplete', 'one-time-code');
78+
el.setAttribute('inputmode', 'numeric');
79+
el.setAttribute('aria-labelledby', 'mfaCode-label');
80+
});
81+
}
6882
69-
const router = useRouter();
7083
const inProgress = ref(false);
71-
84+
7285
const coreStore = useCoreStore();
7386
const user = useUserStore();
7487
75-
76-
// use this simple function to automatically focus on the next input
77-
78-
79-
80-
const showPw = ref(false);
81-
82-
const error = ref(null);
83-
const totp = ref({});
84-
const totpJWT = ref(null);
85-
86-
87-
function parseJwt(token) {
88-
// Split the token into its parts
89-
const base64Url = token.split('.')[1];
90-
91-
// Base64-decode the payload
92-
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
93-
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
94-
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
95-
}).join(''));
96-
97-
// Parse the JSON payload
98-
return JSON.parse(jsonPayload);
99-
}
100-
88+
10189
onMounted(async () => {
102-
coreStore.getPublicConfig();
103-
window.addEventListener('paste', handlePaste);
90+
await nextTick();
91+
tagOtpInputs();
10492
});
10593
10694
onBeforeUnmount(() => {
@@ -161,9 +149,9 @@
161149
}
162150
163151
/**
164-
* This particular piece of code makes the last input have a gap in the middle.
165-
*/
166-
.spaced-code-input {
152+
* This particular piece of code makes the last input have a gap in the middle.
153+
*/
154+
.spaced-code-input {
167155
& .vue3-2fa-code-input-box {
168156
&:nth-child(3) {
169157
@apply mr-4;
@@ -175,4 +163,3 @@
175163
}
176164
}
177165
</style>
178-

custom/TwoFactorsSetup.vue

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
<div class="relative p-4 w-full max-w-md max-h-full">
1313
<!-- Modal content -->
1414
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700 dark:shadow-black text-gray-500" >
15-
<div class="p-10 w-full max-w-md max-h-full" >
15+
<div class="p-10 w-full max-w-md max-h-full custom-auth-wrapper" >
1616
<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>
1717
<div class="flex justify-center m-3" >
1818
<img :src="totpQrCode" class="min-w-[200px], min-h-[200px]" alt="QR code" />
1919
</div>
2020
<div class="m-3 ">
2121
<div class="m-1">{{$t('Or copy this code to app manually:')}}</div>
22-
<div class="w-full max-w-[46rem]">
22+
<div class="w-full">
2323
<div class="relative">
2424
<label for="npm-install-copy-text" class="sr-only">{{$t('Label')}}</label>
2525
<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>
@@ -33,21 +33,22 @@
3333
</div>
3434
</div>
3535
</div>
36-
<div class="my-4 flex justify-center items-center">
36+
<div class="my-4 w-full flex justify-center p-2" ref="otpRoot">
3737
<v-otp-input
3838
ref="code"
39-
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"
40-
:conditionalClass="['one', 'two', 'three', 'four', 'five', 'six']"
41-
inputType="number"
42-
inputmode="numeric"
39+
container-class="grid grid-cols-6 gap-3 w-full"
40+
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"
4341
:num-inputs="6"
42+
inputType="number"
43+
inputmode="numeric"
4444
:should-auto-focus="true"
4545
:should-focus-order="true"
46+
v-model:value="bindValue"
4647
@on-complete="handleOnComplete"
4748
/>
4849
</div>
4950
<!-- <Vue2FACodeInput v-model="code" autofocus /> -->
50-
<div class="flex flex-row gap-2.5 pl-3 pr-3 h-12">
51+
<div class="flex flex-row gap-2.5 px-3 h-12">
5152
<LinkButton to="/login" class="w-full">
5253
{{$t('Back to login')}}
5354
</LinkButton>
@@ -67,7 +68,7 @@
6768

6869
<script setup lang="ts">
6970
70-
import { onMounted, onBeforeUnmount, ref, watchEffect,computed,watch } from 'vue';
71+
import { onMounted, onBeforeUnmount, nextTick, ref, watchEffect,computed,watch } from 'vue';
7172
import { useCoreStore } from '@/stores/core';
7273
import { useUserStore } from '@/stores/user';
7374
import { IconEyeSolid, IconEyeSlashSolid } from '@iconify-prerendered/vue-flowbite';
@@ -89,6 +90,7 @@ const handleOnComplete = (value) => {
8990
9091
const router = useRouter();
9192
const inProgress = ref(false);
93+
const otpRoot = ref(null);
9294
9395
const coreStore = useCoreStore();
9496
const user = useUserStore();
@@ -151,12 +153,27 @@ onMounted(async () => {
151153
}
152154
153155
window.addEventListener('paste', handlePaste);
156+
await nextTick();
157+
tagOtpInputs();
154158
});
155159
156160
onBeforeUnmount(() => {
157161
window.removeEventListener('paste', handlePaste);
158162
});
159163
164+
function tagOtpInputs() {
165+
const root = otpRoot.value;
166+
if (!root) return;
167+
const inputs = root.querySelectorAll('input.otp-input');
168+
inputs.forEach((el, idx) => {
169+
el.setAttribute('name', 'mfaCode');
170+
el.setAttribute('id', `mfaCode-${idx + 1}`);
171+
el.setAttribute('autocomplete', 'one-time-code');
172+
el.setAttribute('inputmode', 'numeric');
173+
el.setAttribute('aria-labelledby', 'mfaCode-label');
174+
});
175+
}
176+
160177
async function sendCode (value) {
161178
inProgress.value = true;
162179
@@ -206,5 +223,5 @@ const handleSkip = async () => {
206223
<style>
207224
.otp-input {
208225
margin: 0 5px;
209-
}
226+
}
210227
</style>

index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,16 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
5858
const beforeLoginConfirmation = this.adminforth.config.auth.beforeLoginConfirmation;
5959
const beforeLoginConfirmationArray = Array.isArray(beforeLoginConfirmation) ? beforeLoginConfirmation : [beforeLoginConfirmation];
6060
beforeLoginConfirmationArray.push(
61-
async({ adminUser, response }: { adminUser: AdminUser, response: IAdminForthHttpResponse} )=> {
61+
async({ adminUser, response, extra }: { adminUser: AdminUser, response: IAdminForthHttpResponse, extra?: any} )=> {
6262
const secret = adminUser.dbUser[this.options.twoFaSecretFieldName]
6363
const userName = adminUser.dbUser[adminforth.config.auth.usernameField]
6464
const brandName = adminforth.config.customization.brandName;
6565
const brandNameSlug = adminforth.config.customization.brandNameSlug;
6666
const authResource = adminforth.config.resources.find((res)=>res.resourceId === adminforth.config.auth.usersResourceId )
6767
const authPk = authResource.columns.find((col)=>col.primaryKey).name
6868
const userPk = adminUser.dbUser[authPk]
69+
const rememberMe = extra?.body?.rememberMe || false;
70+
const rememberMeDays = rememberMe ? adminforth.config.auth.rememberMeDays || 30 : 1;
6971
let newSecret = null;
7072

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

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

9698
return {
@@ -127,7 +129,6 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
127129
const brandNameSlug = this.adminforth.config.customization.brandNameSlug;
128130
const totpTemporaryJWT = cookies.find((cookie)=>cookie.key === `adminforth_${brandNameSlug}_totpTemporaryJWT`)?.value;
129131
const decoded = await this.adminforth.auth.verify(totpTemporaryJWT, 'tempTotp');
130-
131132
if (!decoded)
132133
return {status:'error',message:'Invalid token'}
133134

@@ -140,7 +141,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
140141
await connector.updateRecord({resource:this.authResource, recordId:decoded.pk, newValues:{[this.options.twoFaSecretFieldName]: decoded.newSecret}})
141142
}
142143
this.adminforth.auth.removeCustomCookie({response, name:'totpTemporaryJWT'})
143-
this.adminforth.auth.setAuthCookie({response, username:decoded.userName, pk:decoded.pk})
144+
this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
144145
return { status: 'ok', allowedLogin: true }
145146
} else {
146147
return {error: 'Wrong or expired OTP code'}
@@ -153,7 +154,7 @@ export default class TwoFactorsAuthPlugin extends AdminForthPlugin {
153154
const verified = twofactor.verifyToken(user[this.options.twoFaSecretFieldName], body.code, this.options.timeStepWindow);
154155
if (verified) {
155156
this.adminforth.auth.removeCustomCookie({response, name:'totpTemporaryJWT'})
156-
this.adminforth.auth.setAuthCookie({response, username:decoded.userName, pk:decoded.pk})
157+
this.adminforth.auth.setAuthCookie({expireInDays: decoded.rememberMeDays, response, username:decoded.userName, pk:decoded.pk})
157158
return { status: 'ok', allowedLogin: true }
158159
} else {
159160
return {error: 'Wrong or expired OTP code'}

0 commit comments

Comments
 (0)