feat(frontend): add TOTP UI components and dependency
- add OTP modal component with its css file - add Profile2FA component for 2FA section in settings page - add @scure/base package to encode OTP secrets in Base32, enabling alternative import options for authenticator apps - add new phrases to the en.json localization filepull/3885/head
parent
b233d47459
commit
a18583640c
|
@ -19,6 +19,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@chenfengyuan/vue-number-input": "^2.0.1",
|
||||
"@scure/base": "^1.2.4",
|
||||
"@vueuse/core": "^12.5.0",
|
||||
"@vueuse/integrations": "^12.5.0",
|
||||
"ace-builds": "^1.37.5",
|
||||
|
|
|
@ -11,6 +11,9 @@ importers:
|
|||
'@chenfengyuan/vue-number-input':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(vue@3.5.13(typescript@5.6.3))
|
||||
'@scure/base':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4
|
||||
'@vueuse/core':
|
||||
specifier: ^12.5.0
|
||||
version: 12.5.0(typescript@5.6.3)
|
||||
|
@ -934,22 +937,26 @@ packages:
|
|||
resolution: {integrity: sha512-nmG512G8QOABsserleechwHGZxzKSAlggGf9hQX0nltvSwyKNVuB/4o6iFeG2OnjXK253r8p8eSDOZf8PgFdWw==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/message-compiler@11.0.0-rc.1':
|
||||
resolution: {integrity: sha512-TGw2uBfuTFTegZf/BHtUQBEKxl7Q/dVGLoqRIdw8lFsp9g/53sYn5iD+0HxIzdYjbWL6BTJMXCPUHp9PxDTRPw==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/message-compiler@11.1.2':
|
||||
resolution: {integrity: sha512-T/xbNDzi+Yv0Qn2Dfz2CWCAJiwNgU5d95EhhAEf4YmOgjCKktpfpiUSmLcBvK1CtLpPQ85AMMQk/2NCcXnNj1g==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@11.0.0-rc.1':
|
||||
resolution: {integrity: sha512-8tR1xe7ZEbkabTuE/tNhzpolygUn9OaYp9yuYAF4MgDNZg06C3Qny80bes2/e9/Wm3aVkPUlCw6WgU7mQd0yEg==}
|
||||
'@intlify/message-compiler@12.0.0-alpha.2':
|
||||
resolution: {integrity: sha512-PD9C+oQbb7BF52hec0+vLnScaFkvnfX+R7zSbODYuRo/E2niAtGmHd0wPvEMsDhf9Z9b8f/qyDsVeZnD/ya9Ug==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@11.1.2':
|
||||
resolution: {integrity: sha512-dF2iMMy8P9uKVHV/20LA1ulFLL+MKSbfMiixSmn6fpwqzvix38OIc7ebgnFbBqElvghZCW9ACtzKTGKsTGTWGA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@11.1.3':
|
||||
resolution: {integrity: sha512-pTFBgqa/99JRA2H1qfyqv97MKWJrYngXBA/I0elZcYxvJgcCw3mApAoPW3mJ7vx3j+Ti0FyKUFZ4hWxdjKaxvA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@12.0.0-alpha.2':
|
||||
resolution: {integrity: sha512-P2DULVX9nz3y8zKNqLw9Es1aAgQ1JGC+kgpx5q7yLmrnAKkPR5MybQWoEhxanefNJgUY5ehsgo+GKif59SrncA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/unplugin-vue-i18n@6.0.3':
|
||||
resolution: {integrity: sha512-9ZDjBlhUHtgjRl23TVcgfJttgu8cNepwVhWvOv3mUMRDAhjW0pur1mWKEUKr1I8PNwE4Gvv2IQ1xcl4RL0nG0g==}
|
||||
engines: {node: '>= 18'}
|
||||
|
@ -1140,6 +1147,9 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@scure/base@1.2.4':
|
||||
resolution: {integrity: sha512-5Yy9czTO47mqz+/J8GM6GIId4umdCk1wc1q8rKERQulIoc8VP9pzDcghv10Tl2E7R96ZUx/PhND3ESYUQX8NuQ==}
|
||||
|
||||
'@tsconfig/node22@22.0.0':
|
||||
resolution: {integrity: sha512-twLQ77zevtxobBOD4ToAtVmuYrpeYUh3qh+TEp+08IWhpsrIflVHqQ1F1CiPxQGL7doCdBIOOCF+1Tm833faNg==}
|
||||
|
||||
|
@ -3578,8 +3588,8 @@ snapshots:
|
|||
|
||||
'@intlify/bundle-utils@10.0.0(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))':
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 11.0.0-rc.1
|
||||
'@intlify/shared': 11.0.0-rc.1
|
||||
'@intlify/message-compiler': 12.0.0-alpha.2
|
||||
'@intlify/shared': 12.0.0-alpha.2
|
||||
acorn: 8.14.0
|
||||
escodegen: 2.1.0
|
||||
estree-walker: 2.0.2
|
||||
|
@ -3595,26 +3605,28 @@ snapshots:
|
|||
'@intlify/message-compiler': 11.1.2
|
||||
'@intlify/shared': 11.1.2
|
||||
|
||||
'@intlify/message-compiler@11.0.0-rc.1':
|
||||
dependencies:
|
||||
'@intlify/shared': 11.0.0-rc.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/message-compiler@11.1.2':
|
||||
dependencies:
|
||||
'@intlify/shared': 11.1.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/shared@11.0.0-rc.1': {}
|
||||
'@intlify/message-compiler@12.0.0-alpha.2':
|
||||
dependencies:
|
||||
'@intlify/shared': 12.0.0-alpha.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/shared@11.1.2': {}
|
||||
|
||||
'@intlify/shared@11.1.3': {}
|
||||
|
||||
'@intlify/shared@12.0.0-alpha.2': {}
|
||||
|
||||
'@intlify/unplugin-vue-i18n@6.0.3(@vue/compiler-dom@3.5.13)(eslint@9.19.0)(rollup@4.32.0)(typescript@5.6.3)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.19.0)
|
||||
'@intlify/bundle-utils': 10.0.0(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))
|
||||
'@intlify/shared': 11.1.2
|
||||
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.2)(@vue/compiler-dom@3.5.13)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3))
|
||||
'@intlify/shared': 11.1.3
|
||||
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3))
|
||||
'@rollup/pluginutils': 5.1.4(rollup@4.32.0)
|
||||
'@typescript-eslint/scope-manager': 8.21.0
|
||||
'@typescript-eslint/typescript-estree': 8.21.0(typescript@5.6.3)
|
||||
|
@ -3636,11 +3648,11 @@ snapshots:
|
|||
- supports-color
|
||||
- typescript
|
||||
|
||||
'@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.2)(@vue/compiler-dom@3.5.13)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3))':
|
||||
'@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.1.3)(@vue/compiler-dom@3.5.13)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.6.3)))(vue@3.5.13(typescript@5.6.3))':
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.7
|
||||
optionalDependencies:
|
||||
'@intlify/shared': 11.1.2
|
||||
'@intlify/shared': 11.1.3
|
||||
'@vue/compiler-dom': 3.5.13
|
||||
vue: 3.5.13(typescript@5.6.3)
|
||||
vue-i18n: 11.1.2(vue@3.5.13(typescript@5.6.3))
|
||||
|
@ -3764,6 +3776,8 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc@4.32.0':
|
||||
optional: true
|
||||
|
||||
'@scure/base@1.2.4': {}
|
||||
|
||||
'@tsconfig/node22@22.0.0': {}
|
||||
|
||||
'@types/estree@1.0.6': {}
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<div class="card floating otp-modal">
|
||||
<div class="card-title">
|
||||
<h2>{{ t("otp.name") }}</h2>
|
||||
<p>{{ t("otp.verifyInstructions") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<input
|
||||
v-model.trim="totpCode"
|
||||
:class="inputClassObject"
|
||||
:placeholder="t('otp.codeInputPlaceholder')"
|
||||
@keyup.enter="submit"
|
||||
id="focus-prompt"
|
||||
tabindex="1"
|
||||
class="input input--block"
|
||||
type="text"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
aria-describedby="totp-error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="layoutStore.closeHovers"
|
||||
:aria-label="t('buttons.cancel')"
|
||||
:title="t('buttons.cancel')"
|
||||
tabindex="3"
|
||||
>
|
||||
{{ t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button button--flat"
|
||||
:aria-label="t('buttons.verify')"
|
||||
:title="t('buttons.verify')"
|
||||
@click="submit"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ t("buttons.verify") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from "vue";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { StatusError } from "@/api/utils";
|
||||
import { computed } from "vue";
|
||||
|
||||
const $showError = inject<IToastError>("$showError")!;
|
||||
const layoutStore = useLayoutStore();
|
||||
const { t } = useI18n();
|
||||
const totpCode = ref<string>("");
|
||||
const inputClassObject = computed(() => ({
|
||||
empty: totpCode.value === "",
|
||||
}));
|
||||
|
||||
const submit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (totpCode.value.length !== 6 || !/^\d+$/.test(totpCode.value)) {
|
||||
throw new Error(t("otp.invalidCodeType"));
|
||||
}
|
||||
|
||||
try {
|
||||
await layoutStore.currentPrompt?.confirm(totpCode.value);
|
||||
} catch (e) {
|
||||
if (e instanceof StatusError) {
|
||||
console.error("TOTP Verification Error:", e);
|
||||
$showError(t("otp.verificationFailed"));
|
||||
} else if (e instanceof Error) {
|
||||
$showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
layoutStore.closeHovers();
|
||||
};
|
||||
</script>
|
|
@ -25,6 +25,7 @@ import Share from "./Share.vue";
|
|||
import ShareDelete from "./ShareDelete.vue";
|
||||
import Upload from "./Upload.vue";
|
||||
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
|
||||
import Otp from "./Otp.vue";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
|
@ -47,6 +48,7 @@ const components = new Map<string, any>([
|
|||
["share-delete", ShareDelete],
|
||||
["deleteUser", DeleteUser],
|
||||
["discardEditorChanges", DiscardEditorChanges],
|
||||
["otp", Otp],
|
||||
]);
|
||||
|
||||
watch(currentPromptName, (newValue) => {
|
||||
|
|
|
@ -0,0 +1,246 @@
|
|||
<template>
|
||||
<div class="column">
|
||||
<form v-if="authStore?.user?.otpEnabled" class="card">
|
||||
<div class="card-title">
|
||||
<h2>{{ t("otp.name") }}</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="otpSetupKey" class="card-content">
|
||||
<div class="qrcode-container">
|
||||
<qrcode-vue :value="otpSetupKey" :size="300" level="M" />
|
||||
</div>
|
||||
<div class="setup-key-container">
|
||||
<input
|
||||
:value="otpSecretB32"
|
||||
class="input input--block"
|
||||
type="text"
|
||||
name="otpSetupKey"
|
||||
disabled
|
||||
/>
|
||||
<button class="action copy-clipboard" @click="copyOtpSetupKey">
|
||||
<i class="material-icons">content_paste_go</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="setup-key-container">
|
||||
<input
|
||||
v-model="otpCode"
|
||||
:placeholder="t('settings.otpCodeCheckPlaceholder')"
|
||||
type="text"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
class="input input--block"
|
||||
/>
|
||||
<button class="action copy-clipboard" @click="checkOtpCode">
|
||||
<i class="material-icons">send</i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="button button--block button--red" @click="disableOtp">
|
||||
{{ t("buttons.disable") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!otpSetupKey" class="card-action">
|
||||
<button class="button button--flat" @click="showOtpInfo">
|
||||
{{ t("prompts.show") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else class="card" @submit="enable2FA">
|
||||
<div class="card-title">
|
||||
<h2>{{ t("otp.name") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<input
|
||||
v-if="!otpSetupKey"
|
||||
v-model="passwordForOTP"
|
||||
:placeholder="t('settings.password')"
|
||||
class="input input--block"
|
||||
type="password"
|
||||
name="password"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="qrcode-container">
|
||||
<qrcode-vue :value="otpSetupKey" :size="300" level="M" />
|
||||
</div>
|
||||
<div class="setup-key-container">
|
||||
<input
|
||||
:value="otpSecretB32"
|
||||
class="input input--block"
|
||||
type="text"
|
||||
name="otpSetupKey"
|
||||
disabled
|
||||
/>
|
||||
<button class="action copy-clipboard" @click="copyOtpSetupKey">
|
||||
<i class="material-icons">content_paste_go</i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="setup-key-container">
|
||||
<input
|
||||
v-model="otpCode"
|
||||
:placeholder="t('settings.otpCodeCheckPlaceholder')"
|
||||
type="text"
|
||||
pattern="[0-9]*"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
class="input input--block"
|
||||
/>
|
||||
<button class="action copy-clipboard" @click="checkOtpCode">
|
||||
<i class="material-icons">send</i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<input
|
||||
v-if="!otpSetupKey"
|
||||
:value="t('buttons.enable')"
|
||||
class="button button--flat"
|
||||
type="submit"
|
||||
name="submitEnableOTPForm"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { base32 } from "@scure/base";
|
||||
import QrcodeVue from "qrcode.vue";
|
||||
import { copy } from "@/utils/clipboard";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { users as api } from "@/api";
|
||||
import { inject, ref } from "vue";
|
||||
import { computed } from "vue";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const authStore = useAuthStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const $showSuccess = inject<IToastSuccess>("$showSuccess")!;
|
||||
const $showError = inject<IToastError>("$showError")!;
|
||||
|
||||
const passwordForOTP = ref<string>("");
|
||||
const otpSetupKey = ref<string>("");
|
||||
const otpCode = ref<string>("");
|
||||
|
||||
const otpSecretB32 = computed(() => {
|
||||
const otpURI = new URL(otpSetupKey.value);
|
||||
const encoder = new TextEncoder();
|
||||
const secstr = String(otpURI.searchParams.get("secret"));
|
||||
const secret = encoder.encode(secstr);
|
||||
|
||||
return base32.encode(secret);
|
||||
});
|
||||
|
||||
const showOtpInfo = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
layoutStore.showHover({
|
||||
prompt: "otp",
|
||||
confirm: async (code: string) => {
|
||||
if (authStore.user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.getOtpInfo(authStore.user.id, code);
|
||||
otpSetupKey.value = res.setupKey;
|
||||
} catch (err: any) {
|
||||
$showError(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const disableOtp = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
layoutStore.showHover({
|
||||
prompt: "otp",
|
||||
confirm: async (code: string) => {
|
||||
if (authStore.user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.disableOtp(authStore.user.id, code);
|
||||
otpSetupKey.value = "";
|
||||
authStore.user.otpEnabled = false;
|
||||
} catch (err: any) {
|
||||
$showError(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const enable2FA = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (authStore.user === null || otpSetupKey.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.enableOTP(authStore.user.id, passwordForOTP.value);
|
||||
|
||||
otpSetupKey.value = res.setupKey;
|
||||
authStore.user.otpEnabled = true;
|
||||
$showSuccess(t("otp.enabledSuccessfully"));
|
||||
} catch (err: any) {
|
||||
$showError(err);
|
||||
} finally {
|
||||
passwordForOTP.value = "";
|
||||
}
|
||||
};
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await copy({ text });
|
||||
$showSuccess(t("success.linkCopied"));
|
||||
} catch {
|
||||
try {
|
||||
await copy({ text }, { permission: true });
|
||||
$showSuccess(t("success.linkCopied"));
|
||||
} catch (e: any) {
|
||||
$showError(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
const copyOtpSetupKey = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await copyToClipboard(otpSecretB32.value);
|
||||
};
|
||||
const checkOtpCode = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (authStore.user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.checkOtp(authStore.user.id, otpCode.value);
|
||||
$showSuccess(t("otp.verificationSucceed"));
|
||||
} catch (err: any) {
|
||||
console.log(err);
|
||||
$showError(t("otp.verificationFailed"));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.qrcode-container,
|
||||
.setup-key-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.setup-key-container {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.setup-key-container > * {
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,29 @@
|
|||
.otp-modal .card-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.otp-modal .card-title h2 {
|
||||
text-align: center;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.otp-modal .card-title p {
|
||||
text-align: center;
|
||||
color: var(--blue);
|
||||
text-transform: lowercase;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.otp-modal .card-content input {
|
||||
font-size: 1.2em;
|
||||
text-align: center;
|
||||
letter-spacing: 0.5em;
|
||||
transition: border 0.2s ease;
|
||||
}
|
||||
|
||||
.otp-modal .card-content input.empty {
|
||||
letter-spacing: 0;
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
@import "./mobile.css";
|
||||
@import "./epubReader.css";
|
||||
@import "./mdPreview.css";
|
||||
@import "./otp-modal.css";
|
||||
|
||||
/* For testing only
|
||||
:focus {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"buttons": {
|
||||
"cancel": "Cancel",
|
||||
"check": "Check",
|
||||
"clear": "Clear",
|
||||
"close": "Close",
|
||||
"continue": "Continue",
|
||||
|
@ -11,6 +12,8 @@
|
|||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"download": "Download",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"file": "File",
|
||||
"folder": "Folder",
|
||||
"fullScreen": "Toggle full screen",
|
||||
|
@ -41,6 +44,7 @@
|
|||
"toggleSidebar": "Toggle sidebar",
|
||||
"update": "Update",
|
||||
"upload": "Upload",
|
||||
"verify": "Verify",
|
||||
"openFile": "Open file",
|
||||
"discardChanges": "Discard"
|
||||
},
|
||||
|
@ -182,6 +186,7 @@
|
|||
"disableExternalLinks": "Disable external links (except documentation)",
|
||||
"disableUsedDiskPercentage": "Disable used disk percentage graph",
|
||||
"documentation": "documentation",
|
||||
"otpCodeCheckPlaceholder": "Enter the otp code to check your setup key",
|
||||
"examples": "Examples",
|
||||
"executeOnShell": "Execute on shell",
|
||||
"executeOnShellDescription": "By default, File Browser executes the commands by calling their binaries directly. If you wish to run them on a shell instead (such as Bash or PowerShell), you can define it here with the required arguments and flags. If set, the command you execute will be appended as an argument. This applies to both user commands and event hooks.",
|
||||
|
@ -239,6 +244,15 @@
|
|||
"username": "Username",
|
||||
"users": "Users"
|
||||
},
|
||||
"otp": {
|
||||
"name": "Two-Factor Authentication",
|
||||
"verifyInstructions": "Enter the code from your authenticator app",
|
||||
"codeInputPlaceholder": "6-digit code",
|
||||
"invalidCodeType": "Verification code should be 6 english digits",
|
||||
"enabledSuccessfully": "OTP enabled successfully",
|
||||
"verificationFailed": "Verfication Failed",
|
||||
"verificationSucceed": "Verification Succeed"
|
||||
},
|
||||
"sidebar": {
|
||||
"help": "Help",
|
||||
"hugoNew": "Hugo New",
|
||||
|
|
Loading…
Reference in New Issue