mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 17:46:02 +00:00
feat(frontend): Phase 5d-iii — settings Security tab + 2FA modal
Ports the panel/security partial: change-credentials form and 2FA toggle. The 2FA modal is a new shared component since enabling 2FA, disabling 2FA, and changing credentials all funnel through it with slightly different copy. - TwoFactorModal.vue: 'set' flow renders a QR code + manual key + a 6-digit verifier; 'confirm' flow renders just the verifier. The parent passes a confirm(success) callback that fires only when the entered code matches the live TOTP value (otpauth lib). - SecurityTab.vue: holds the local user form (oldUsername/oldPassword/ new*), POSTs /panel/setting/updateUser, and on success force-redirects to logout. When 2FA is on, the credentials change goes through the confirm-modal first. - toggleTwoFactor leaves the switch read-only (the v-bound :checked matches AllSetting) and only flips after the modal succeeds, so cancelling out leaves state unchanged. - Adds otpauth ^9.5.1 dep (qrious was already present). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
56cdf05909
commit
bd20b8fd7f
5 changed files with 348 additions and 2 deletions
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.7.9",
|
||||
"moment": "^2.30.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"qrious": "^4.0.2",
|
||||
"qs": "^6.13.1",
|
||||
"vue": "^3.5.13",
|
||||
|
|
@ -423,6 +424,17 @@
|
|||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.128.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
|
||||
|
|
@ -2219,6 +2231,17 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otpauth": {
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
|
||||
"integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/hectorm/otpauth?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@
|
|||
"lint": "eslint src --ext .js,.vue"
|
||||
},
|
||||
"dependencies": {
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.7.9",
|
||||
"moment": "^2.30.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"qrious": "^4.0.2",
|
||||
"qs": "^6.13.1",
|
||||
"vue": "^3.5.13",
|
||||
|
|
|
|||
166
frontend/src/pages/settings/SecurityTab.vue
Normal file
166
frontend/src/pages/settings/SecurityTab.vue
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import SettingListItem from '@/components/SettingListItem.vue';
|
||||
import TwoFactorModal from './TwoFactorModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
allSetting: { type: Object, required: true },
|
||||
});
|
||||
|
||||
// 2FA modal state — both the "set" (enabling) and "confirm" (changing
|
||||
// password / disabling) flows route through the same component.
|
||||
const tfa = reactive({
|
||||
open: false,
|
||||
title: '',
|
||||
description: '',
|
||||
token: '',
|
||||
type: 'set',
|
||||
// resolveConfirm is called by the modal's @confirm with the success bool;
|
||||
// it then routes the value back to whichever flow opened the modal.
|
||||
resolveConfirm: (_success) => {},
|
||||
});
|
||||
|
||||
function openTfa({ title, description = '', token = '', type, onConfirm }) {
|
||||
tfa.title = title;
|
||||
tfa.description = description;
|
||||
tfa.token = token;
|
||||
tfa.type = type;
|
||||
tfa.resolveConfirm = onConfirm;
|
||||
tfa.open = true;
|
||||
}
|
||||
|
||||
function onTfaConfirm(success) {
|
||||
tfa.resolveConfirm(success);
|
||||
}
|
||||
|
||||
const user = reactive({
|
||||
oldUsername: '',
|
||||
oldPassword: '',
|
||||
newUsername: '',
|
||||
newPassword: '',
|
||||
});
|
||||
const updating = ref(false);
|
||||
|
||||
async function sendUpdateUser() {
|
||||
updating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/updateUser', user);
|
||||
if (msg?.success) {
|
||||
// Force re-login at the standard logout path; basePath is handled
|
||||
// by the Go router so a relative redirect is correct here.
|
||||
const basePath = window.__X_UI_BASE_PATH__ || '';
|
||||
window.location.replace(`${basePath}logout`);
|
||||
}
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateUser() {
|
||||
if (props.allSetting.twoFactorEnable) {
|
||||
openTfa({
|
||||
title: 'Confirm with 2FA',
|
||||
description: 'Enter the current 6-digit code to change credentials.',
|
||||
token: props.allSetting.twoFactorToken,
|
||||
type: 'confirm',
|
||||
onConfirm: (ok) => { if (ok) sendUpdateUser(); },
|
||||
});
|
||||
} else {
|
||||
sendUpdateUser();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTwoFactor() {
|
||||
// Switch read-only — the actual flip happens after the modal succeeds.
|
||||
if (!props.allSetting.twoFactorEnable) {
|
||||
const newToken = RandomUtil.randomBase32String();
|
||||
openTfa({
|
||||
title: 'Enable two-factor authentication',
|
||||
token: newToken,
|
||||
type: 'set',
|
||||
onConfirm: (ok) => {
|
||||
if (ok) {
|
||||
message.success('Two-factor authentication enabled.');
|
||||
props.allSetting.twoFactorToken = newToken;
|
||||
}
|
||||
props.allSetting.twoFactorEnable = ok;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
openTfa({
|
||||
title: 'Disable two-factor authentication',
|
||||
description: 'Enter the current 6-digit code to disable 2FA.',
|
||||
token: props.allSetting.twoFactorToken,
|
||||
type: 'confirm',
|
||||
onConfirm: (ok) => {
|
||||
if (!ok) return;
|
||||
message.success('Two-factor authentication disabled.');
|
||||
props.allSetting.twoFactorEnable = false;
|
||||
props.allSetting.twoFactorToken = '';
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-collapse default-active-key="1">
|
||||
<a-collapse-panel key="1" header="Admin">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Current username</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="user.oldUsername" autocomplete="username" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Current password</template>
|
||||
<template #control>
|
||||
<a-input-password v-model:value="user.oldPassword" autocomplete="current-password" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>New username</template>
|
||||
<template #control>
|
||||
<a-input v-model:value="user.newUsername" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>New password</template>
|
||||
<template #control>
|
||||
<a-input-password v-model:value="user.newPassword" autocomplete="new-password" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<a-list-item>
|
||||
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
|
||||
<a-button type="primary" :loading="updating" @click="updateUser">Confirm</a-button>
|
||||
</a-space>
|
||||
</a-list-item>
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="2" header="Two-factor authentication">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>Enable 2FA</template>
|
||||
<template #description>Require a 6-digit TOTP code on every panel login.</template>
|
||||
<template #control>
|
||||
<a-switch :checked="allSetting.twoFactorEnable" @click="toggleTwoFactor" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
|
||||
<TwoFactorModal
|
||||
v-model:open="tfa.open"
|
||||
:title="tfa.title"
|
||||
:description="tfa.description"
|
||||
:token="tfa.token"
|
||||
:type="tfa.type"
|
||||
@confirm="onTfaConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -15,6 +15,7 @@ import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
|||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import { useAllSetting } from './useAllSetting.js';
|
||||
import GeneralTab from './GeneralTab.vue';
|
||||
import SecurityTab from './SecurityTab.vue';
|
||||
|
||||
const antdThemeConfig = computed(() => ({
|
||||
algorithm: themeState.isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
|
|
@ -214,7 +215,7 @@ const alertVisible = ref(true);
|
|||
<SafetyOutlined />
|
||||
<span>Security</span>
|
||||
</template>
|
||||
<a-empty description="Security — coming in 5d-iii" />
|
||||
<SecurityTab :all-setting="allSetting" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="3" class="tab-pane">
|
||||
<template #tab>
|
||||
|
|
|
|||
155
frontend/src/pages/settings/TwoFactorModal.vue
Normal file
155
frontend/src/pages/settings/TwoFactorModal.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import QRious from 'qrious';
|
||||
|
||||
import { ClipboardManager } from '@/utils';
|
||||
|
||||
// Two flavors of this modal:
|
||||
// • type='set' shows a QR code + manual key + a 6-digit verifier
|
||||
// (used when enabling 2FA the first time);
|
||||
// • type='confirm' shows just the 6-digit verifier (used when
|
||||
// toggling 2FA off and when changing the admin user/password).
|
||||
//
|
||||
// Either way the parent supplies a `confirm(success: boolean)`
|
||||
// callback — we run it with `true` only if the entered code matches
|
||||
// the live TOTP value, otherwise `false`.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
token: { type: String, default: '' },
|
||||
type: { type: String, default: 'set', validator: (v) => ['set', 'confirm'].includes(v) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
|
||||
const enteredCode = ref('');
|
||||
const qrCanvas = ref(null);
|
||||
|
||||
let totp = null;
|
||||
|
||||
function buildTotp() {
|
||||
totp = new OTPAuth.TOTP({
|
||||
issuer: '3x-ui',
|
||||
label: 'Administrator',
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: props.token,
|
||||
});
|
||||
}
|
||||
|
||||
async function paintQr() {
|
||||
await nextTick();
|
||||
if (!qrCanvas.value || !totp) return;
|
||||
// QRious draws into a <canvas>; we don't need a wrapping div.
|
||||
// eslint-disable-next-line no-new
|
||||
new QRious({
|
||||
element: qrCanvas.value,
|
||||
size: 200,
|
||||
value: totp.toString(),
|
||||
background: 'white',
|
||||
backgroundAlpha: 0,
|
||||
foreground: 'black',
|
||||
padding: 2,
|
||||
level: 'L',
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
enteredCode.value = '';
|
||||
if (props.token) {
|
||||
buildTotp();
|
||||
if (props.type === 'set') paintQr();
|
||||
}
|
||||
});
|
||||
|
||||
function close(success) {
|
||||
emit('confirm', success);
|
||||
emit('update:open', false);
|
||||
enteredCode.value = '';
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
if (!totp) return;
|
||||
if (totp.generate() === enteredCode.value) {
|
||||
close(true);
|
||||
} else {
|
||||
message.error('Invalid code — check your authenticator and try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
close(false);
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
const ok = await ClipboardManager.copyText(props.token);
|
||||
if (ok) message.success('Copied');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="title"
|
||||
:closable="true"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<template v-if="type === 'set'">
|
||||
<p>Scan the QR code with your authenticator app, then enter the 6-digit code it shows.</p>
|
||||
<a-divider />
|
||||
<p>Step 1 — Scan the QR code (click to copy the secret).</p>
|
||||
<div class="qr-wrap">
|
||||
<div class="qr-bg">
|
||||
<canvas ref="qrCanvas" class="qr-cv" @click="copyToken" />
|
||||
</div>
|
||||
<span class="qr-token">{{ token }}</span>
|
||||
</div>
|
||||
<a-divider />
|
||||
<p>Step 2 — Enter the 6-digit code from your authenticator.</p>
|
||||
<a-input v-model:value="enteredCode" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p>{{ description }}</p>
|
||||
<a-input v-model:value="enteredCode" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="onCancel">Cancel</a-button>
|
||||
<a-button type="primary" :disabled="enteredCode.length < 6" @click="onOk">Confirm</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.qr-bg {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.qr-cv {
|
||||
cursor: pointer;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.qr-token {
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue