feat(frontend): include OTP components to login and settings pages

- add OTP APIs
- add OTP prompt to Login page
- add Profile2FA to Profile page
pull/3885/head
KhashayarKhm 2025-04-29 14:02:40 +03:30
parent a18583640c
commit 0439b20740
5 changed files with 102 additions and 7 deletions

View File

@ -41,3 +41,45 @@ export async function remove(id: number) {
method: "DELETE", method: "DELETE",
}); });
} }
export async function enableOTP(id: number, password: string) {
const res = await fetchURL(`/api/users/${id}/otp`, {
method: "POST",
body: JSON.stringify({
password,
}),
});
const payload: IOtpSetupKey = await res.json();
return payload;
}
export async function checkOtp(id: number, code: string) {
return fetchURL(`/api/users/${id}/otp/check`, {
method: "POST",
body: JSON.stringify({
code,
}),
});
}
export async function getOtpInfo(id: number, code: string) {
const res = await fetchURL(`/api/users/${id}/otp`, {
method: "GET",
headers: {
"X-TOTP-CODE": code,
},
});
const payload: IOtpSetupKey = await res.json();
return payload;
}
export async function disableOtp(id: number, code: string) {
return fetchURL(`/api/users/${id}/otp`, {
method: "DELETE",
headers: {
"X-TOTP-CODE": code,
},
});
}

View File

@ -12,6 +12,7 @@ interface IUser {
singleClick: boolean; singleClick: boolean;
dateFormat: boolean; dateFormat: boolean;
viewMode: ViewModeType; viewMode: ViewModeType;
otpEnabled: boolean;
sorting?: Sorting; sorting?: Sorting;
} }
@ -64,3 +65,7 @@ interface IRegexp {
} }
type UserTheme = "light" | "dark" | ""; type UserTheme = "light" | "dark" | "";
interface IOtpSetupKey {
setupKey: string;
}

View File

@ -33,7 +33,7 @@ export async function login(
username: string, username: string,
password: string, password: string,
recaptcha: string recaptcha: string
) { ): Promise<{ otp: boolean; token: string }> {
const data = { username, password, recaptcha }; const data = { username, password, recaptcha };
const res = await fetch(`${baseURL}/api/login`, { const res = await fetch(`${baseURL}/api/login`, {
@ -47,7 +47,29 @@ export async function login(
const body = await res.text(); const body = await res.text();
if (res.status === 200) { if (res.status === 200) {
parseToken(body); const payload = JSON.parse(body);
return payload;
} else {
throw new StatusError(
body || `${res.status} ${res.statusText}`,
res.status
);
}
}
export async function verifyTOTP(code: string, token: string): Promise<void> {
const res = await fetch(`${baseURL}/api/login/otp`, {
method: "POST",
headers: {
"X-TOTP-CODE": code,
"X-TOTP-Auth": token,
},
});
const body = await res.text();
if (res.status === 200) {
const payload = JSON.parse(body);
parseToken(payload.token);
} else { } else {
throw new StatusError( throw new StatusError(
body || `${res.status} ${res.statusText}`, body || `${res.status} ${res.statusText}`,
@ -67,7 +89,8 @@ export async function renew(jwt: string) {
const body = await res.text(); const body = await res.text();
if (res.status === 200) { if (res.status === 200) {
parseToken(body); const x = JSON.parse(body);
parseToken(x.token);
} else { } else {
throw new StatusError( throw new StatusError(
body || `${res.status} ${res.statusText}`, body || `${res.status} ${res.statusText}`,

View File

@ -1,5 +1,6 @@
<template> <template>
<div id="login" :class="{ recaptcha: recaptcha }"> <div id="login" :class="{ recaptcha: recaptcha }">
<prompts></prompts>
<form @submit="submit"> <form @submit="submit">
<img :src="logoURL" alt="File Browser" /> <img :src="logoURL" alt="File Browser" />
<h1>{{ name }}</h1> <h1>{{ name }}</h1>
@ -43,6 +44,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { StatusError } from "@/api/utils"; import { StatusError } from "@/api/utils";
import { useLayoutStore } from "@/stores/layout";
import Prompts from "@/components/prompts/Prompts.vue";
import * as auth from "@/utils/auth"; import * as auth from "@/utils/auth";
import { import {
name, name,
@ -65,6 +68,7 @@ const passwordConfirm = ref<string>("");
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n({}); const { t } = useI18n({});
const layoutStore = useLayoutStore();
// Define functions // Define functions
const toggleMode = () => (createMode.value = !createMode.value); const toggleMode = () => (createMode.value = !createMode.value);
@ -97,11 +101,29 @@ const submit = async (event: Event) => {
if (createMode.value) { if (createMode.value) {
await auth.signup(username.value, password.value); await auth.signup(username.value, password.value);
} }
const res = await auth.login(username.value, password.value, captcha);
await auth.login(username.value, password.value, captcha); if (res.otp) {
router.push({ path: redirect }); layoutStore.showHover({
prompt: "otp",
confirm: async (code: string) => {
try {
await auth.verifyTOTP(code, res.token);
router.push({ path: redirect });
} catch (e: any) {
if (e instanceof StatusError) {
error.value = t("otp.verificationFailed");
} else {
$showError(e);
}
}
},
});
} else {
auth.parseToken(res.token);
router.push({ path: redirect });
}
} catch (e: any) { } catch (e: any) {
// console.error(e); console.error(e);
if (e instanceof StatusError) { if (e instanceof StatusError) {
if (e.status === 409) { if (e.status === 409) {
error.value = t("login.usernameTaken"); error.value = t("login.usernameTaken");

View File

@ -74,6 +74,8 @@
</div> </div>
</form> </form>
</div> </div>
<profile-2fa />
</div> </div>
</template> </template>
@ -84,6 +86,7 @@ import { users as api } from "@/api";
import Languages from "@/components/settings/Languages.vue"; import Languages from "@/components/settings/Languages.vue";
import { computed, inject, onMounted, ref } from "vue"; import { computed, inject, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import Profile2fa from "@/components/settings/Profile2FA.vue";
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const authStore = useAuthStore(); const authStore = useAuthStore();