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:
MHSanaei 2026-05-08 13:08:39 +02:00
parent 56cdf05909
commit bd20b8fd7f
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 348 additions and 2 deletions

View file

@ -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",

View file

@ -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",

View 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>

View file

@ -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>

View 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>