feat(api-tokens): manage multiple named tokens; add tab/section anchor URLs
Replace the single regenerable API token with a named-token list: - New ApiToken model + service with constant-time auth matching - Seeder migrates the legacy `apiToken` setting into a "default" row - Security tab gets create/enable/delete UI; api-docs page links to it - Dedicated "API Tokens" section in the in-panel docs URL anchors now reflect the active tab/section on Settings, Xray, and API Docs pages, so deep links like `/panel/settings#security` work. Translations for the 8 new SecurityTab strings added across all locales.
This commit is contained in:
@@ -1,13 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
KeyOutlined,
|
||||
ReloadOutlined,
|
||||
CopyOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
SearchOutlined,
|
||||
ExpandOutlined,
|
||||
CompressOutlined,
|
||||
@@ -25,34 +19,28 @@ import {
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import { HttpUtil, ClipboardManager } from '@/utils/index.js';
|
||||
import { sections as allSections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection.vue';
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
const apiToken = ref('');
|
||||
const tokenLoading = ref(false);
|
||||
const tokenRotating = ref(false);
|
||||
const tokenVisible = ref(false);
|
||||
const settingsHref = `${basePath}panel/settings#security`;
|
||||
|
||||
const searchQuery = ref('');
|
||||
const collapsedSections = ref(new Set());
|
||||
const activeSection = ref('');
|
||||
|
||||
const sectionIcons = {
|
||||
auth: SafetyCertificateOutlined,
|
||||
authentication: SafetyCertificateOutlined,
|
||||
inbounds: NodeIndexOutlined,
|
||||
server: CloudServerOutlined,
|
||||
nodes: ClusterOutlined,
|
||||
customGeo: GlobalOutlined,
|
||||
'custom-geo': GlobalOutlined,
|
||||
backup: SaveOutlined,
|
||||
settings: SettingOutlined,
|
||||
xraySettings: WifiOutlined,
|
||||
'api-tokens': KeyOutlined,
|
||||
'xray-settings': WifiOutlined,
|
||||
subscription: LinkOutlined,
|
||||
websocket: ApiOutlined,
|
||||
};
|
||||
@@ -103,46 +91,20 @@ function collapseAll() {
|
||||
collapsedSections.value = new Set(allSections.map(s => s.id));
|
||||
}
|
||||
|
||||
async function loadApiToken() {
|
||||
tokenLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||
if (msg?.success) apiToken.value = msg.obj || '';
|
||||
} finally {
|
||||
tokenLoading.value = false;
|
||||
function scrollToSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (window.location.hash !== `#${id}`) {
|
||||
history.replaceState(null, '', `#${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateApiToken() {
|
||||
Modal.confirm({
|
||||
title: t('pages.nodes.regenerateConfirm'),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
tokenRotating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
||||
if (msg?.success) {
|
||||
apiToken.value = msg.obj || '';
|
||||
message.success(t('success'));
|
||||
}
|
||||
} finally {
|
||||
tokenRotating.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
const ok = await ClipboardManager.copyText(apiToken.value);
|
||||
if (ok) message.success(t('success'));
|
||||
}
|
||||
|
||||
function scrollToSection(id) {
|
||||
function scrollToHash() {
|
||||
const id = window.location.hash.slice(1);
|
||||
if (!id) return;
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
}
|
||||
|
||||
let scrollObserver = null;
|
||||
@@ -162,16 +124,20 @@ function onScroll() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiToken();
|
||||
scrollObserver = onScroll;
|
||||
window.addEventListener('scroll', scrollObserver, { passive: true });
|
||||
onScroll();
|
||||
window.addEventListener('hashchange', scrollToHash);
|
||||
requestAnimationFrame(() => {
|
||||
scrollToHash();
|
||||
onScroll();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (scrollObserver) {
|
||||
window.removeEventListener('scroll', scrollObserver);
|
||||
}
|
||||
window.removeEventListener('hashchange', scrollToHash);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -197,38 +163,17 @@ onBeforeUnmount(() => {
|
||||
<div class="token-card-head">
|
||||
<div class="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Token</span>
|
||||
</div>
|
||||
<div class="token-actions">
|
||||
<a-button size="small" @click="tokenVisible = !tokenVisible">
|
||||
<template #icon>
|
||||
<EyeInvisibleOutlined v-if="tokenVisible" />
|
||||
<EyeOutlined v-else />
|
||||
</template>
|
||||
{{ tokenVisible ? 'Hide' : 'Show' }}
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="!apiToken" @click="copyApiToken">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
<a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
Regenerate
|
||||
</a-button>
|
||||
<span>API Tokens</span>
|
||||
</div>
|
||||
<a-button type="primary" size="small" :href="settingsHref">
|
||||
Manage tokens
|
||||
</a-button>
|
||||
</div>
|
||||
<a-spin :spinning="tokenLoading" size="small">
|
||||
<pre
|
||||
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
|
||||
</a-spin>
|
||||
<p class="token-hint">
|
||||
Send it on every request as <code>Authorization: Bearer <token></code>. Token-authenticated
|
||||
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
|
||||
running bots will need the new value.
|
||||
Create, enable, or revoke named Bearer tokens in
|
||||
<a :href="settingsHref">Settings → Security</a>. Send each request as
|
||||
<code>Authorization: Bearer <token></code>. Token-authenticated callers skip CSRF and don't
|
||||
need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
@@ -387,25 +332,6 @@ onBeforeUnmount(() => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
@@ -573,14 +499,12 @@ html[data-theme='ultra-dark'] .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .token-value,
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .token-value,
|
||||
html[data-theme='ultra-dark'] .code-block {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
|
||||
@@ -25,7 +25,7 @@ export function safeInlineHtml(input) {
|
||||
|
||||
export const sections = [
|
||||
{
|
||||
id: 'auth',
|
||||
id: 'authentication',
|
||||
title: 'Authentication',
|
||||
description:
|
||||
'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
|
||||
@@ -576,7 +576,7 @@ export const sections = [
|
||||
},
|
||||
|
||||
{
|
||||
id: 'customGeo',
|
||||
id: 'custom-geo',
|
||||
title: 'Custom Geo',
|
||||
description:
|
||||
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
|
||||
@@ -647,7 +647,7 @@ export const sections = [
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
description:
|
||||
'Panel configuration, user credentials, and API token management. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
|
||||
'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -688,23 +688,57 @@ export const sections = [
|
||||
path: '/panel/setting/getDefaultJsonConfig',
|
||||
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'api-tokens',
|
||||
title: 'API Tokens',
|
||||
description:
|
||||
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/setting/getApiToken',
|
||||
summary: 'Return the current API Bearer token. The token is auto-generated on first read so existing installs upgrade transparently.',
|
||||
response: '{\n "success": true,\n "obj": "abcdef-12345-..."\n}',
|
||||
path: '/panel/setting/apiTokens',
|
||||
summary: 'List every API token, enabled or not.',
|
||||
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "token": "abcdef-12345-...",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/regenerateApiToken',
|
||||
summary: 'Rotate the API Bearer token. Any remote central panel that cached the old value will start failing heartbeats until updated with the new token.',
|
||||
response: '{\n "success": true,\n "obj": "new-token-string"\n}',
|
||||
path: '/panel/setting/apiTokens/create',
|
||||
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
|
||||
params: [
|
||||
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
|
||||
],
|
||||
body: '{\n "name": "central-panel-a"\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}',
|
||||
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/apiTokens/delete/:id',
|
||||
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||
],
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/apiTokens/setEnabled/:id',
|
||||
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||
{ name: 'enabled', in: 'body', type: 'boolean', desc: 'New enabled state.' },
|
||||
],
|
||||
body: '{\n "enabled": false\n}',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'xraySettings',
|
||||
id: 'xray-settings',
|
||||
title: 'Xray Settings',
|
||||
description:
|
||||
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
|
||||
|
||||
@@ -75,34 +75,41 @@ function updateUser() {
|
||||
}
|
||||
}
|
||||
|
||||
// === API Token =========================================================
|
||||
// Surfaces the panel's API token so a remote central panel can register
|
||||
// this instance as a node. Lazy-loaded on tab mount; rotation requires
|
||||
// confirmation since it invalidates any cached value upstream.
|
||||
const apiToken = ref('');
|
||||
const apiTokenLoading = ref(false);
|
||||
const apiTokenRotating = ref(false);
|
||||
const apiTokens = ref([]);
|
||||
const apiTokensLoading = ref(false);
|
||||
const visibleTokenIds = ref(new Set());
|
||||
const createOpen = ref(false);
|
||||
const createName = ref('');
|
||||
const creating = ref(false);
|
||||
|
||||
async function loadApiToken() {
|
||||
apiTokenLoading.value = true;
|
||||
async function loadApiTokens() {
|
||||
apiTokensLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||
if (msg?.success) apiToken.value = msg.obj || '';
|
||||
const msg = await HttpUtil.get('/panel/setting/apiTokens');
|
||||
if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
apiTokenLoading.value = false;
|
||||
apiTokensLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
function isTokenVisible(id) {
|
||||
return visibleTokenIds.value.has(id);
|
||||
}
|
||||
|
||||
function toggleTokenVisibility(id) {
|
||||
const next = new Set(visibleTokenIds.value);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
visibleTokenIds.value = next;
|
||||
}
|
||||
|
||||
async function copyToken(token) {
|
||||
if (!token) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiToken.value);
|
||||
await navigator.clipboard.writeText(token);
|
||||
message.success(t('copySuccess'));
|
||||
} catch (_e) {
|
||||
// navigator.clipboard can be undefined on http:// — fall back to
|
||||
// a transient input + execCommand path.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = apiToken.value;
|
||||
ta.value = token;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
@@ -111,28 +118,66 @@ async function copyApiToken() {
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateApiToken() {
|
||||
function openCreateModal() {
|
||||
createName.value = '';
|
||||
createOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmCreateToken() {
|
||||
const name = createName.value.trim();
|
||||
if (!name) {
|
||||
message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name });
|
||||
if (msg?.success) {
|
||||
createOpen.value = false;
|
||||
await loadApiTokens();
|
||||
if (msg.obj?.id != null) {
|
||||
const next = new Set(visibleTokenIds.value);
|
||||
next.add(msg.obj.id);
|
||||
visibleTokenIds.value = next;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteToken(row) {
|
||||
Modal.confirm({
|
||||
title: t('pages.nodes.regenerateConfirm'),
|
||||
okText: t('confirm'),
|
||||
title: `${t('delete')} "${row.name}"?`,
|
||||
content: t('pages.settings.security.apiTokenDeleteWarning')
|
||||
|| 'Any caller using this token will stop authenticating immediately.',
|
||||
okText: t('delete'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
apiTokenRotating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
||||
if (msg?.success) {
|
||||
apiToken.value = msg.obj || '';
|
||||
message.success(t('success'));
|
||||
}
|
||||
} finally {
|
||||
apiTokenRotating.value = false;
|
||||
}
|
||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
|
||||
if (msg?.success) await loadApiTokens();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(loadApiToken);
|
||||
async function toggleTokenEnabled(row) {
|
||||
const target = !row.enabled;
|
||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target });
|
||||
if (msg?.success) row.enabled = target;
|
||||
}
|
||||
|
||||
function maskToken(token) {
|
||||
if (!token) return '';
|
||||
return '•'.repeat(Math.min(token.length, 24));
|
||||
}
|
||||
|
||||
function formatTokenDate(ts) {
|
||||
if (!ts) return '';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
onMounted(loadApiTokens);
|
||||
|
||||
function toggleTwoFactor() {
|
||||
// Switch read-only — the actual flip happens after the modal succeeds.
|
||||
@@ -216,24 +261,144 @@ function toggleTwoFactor() {
|
||||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.nodes.apiToken') }}</template>
|
||||
<template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
|
||||
<template #control>
|
||||
<a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
<a-list-item>
|
||||
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
|
||||
<a-button :disabled="!apiToken" @click="copyApiToken">{{ t('copy') }}</a-button>
|
||||
<a-button danger :loading="apiTokenRotating" @click="regenerateApiToken">
|
||||
{{ t('pages.nodes.regenerate') }}
|
||||
<div class="api-token-section">
|
||||
<div class="api-token-header">
|
||||
<p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
|
||||
<a-button type="primary" size="small" @click="openCreateModal">
|
||||
+ {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-list-item>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="apiTokensLoading">
|
||||
<a-empty v-if="!apiTokens.length && !apiTokensLoading"
|
||||
:description="t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'" />
|
||||
|
||||
<div v-for="row in apiTokens" :key="row.id" class="api-token-row" :class="{ disabled: !row.enabled }">
|
||||
<div class="api-token-row-head">
|
||||
<div class="api-token-name-wrap">
|
||||
<span class="api-token-name">{{ row.name }}</span>
|
||||
<span class="api-token-created">{{ formatTokenDate(row.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="api-token-actions">
|
||||
<a-switch size="small" :checked="row.enabled" @change="toggleTokenEnabled(row)" />
|
||||
<a-button size="small" danger type="text" @click="confirmDeleteToken(row)">
|
||||
{{ t('delete') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="api-token-value-wrap">
|
||||
<code class="api-token-value">{{ isTokenVisible(row.id) ? row.token : maskToken(row.token) }}</code>
|
||||
<a-button size="small" @click="toggleTokenVisibility(row.id)">
|
||||
{{ isTokenVisible(row.id)
|
||||
? (t('pages.settings.security.hide') || 'Hide')
|
||||
: (t('pages.settings.security.show') || 'Show') }}
|
||||
</a-button>
|
||||
<a-button size="small" @click="copyToken(row.token)">{{ t('copy') }}</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
|
||||
<a-modal v-model:open="createOpen" :title="t('pages.settings.security.apiTokenNew') || 'New API token'"
|
||||
:confirm-loading="creating" :ok-text="t('confirm')" :cancel-text="t('cancel')" @ok="confirmCreateToken">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="t('pages.settings.security.apiTokenName') || 'Name'" required>
|
||||
<a-input v-model:value="createName" maxlength="64"
|
||||
:placeholder="t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'"
|
||||
@keyup.enter="confirmCreateToken" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
|
||||
:type="tfa.type" @confirm="onTfaConfirm" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-token-section {
|
||||
padding: 8px 20px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.api-token-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-token-hint {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
opacity: 0.7;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.api-token-row {
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.api-token-row.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.api-token-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-token-name-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.api-token-name {
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.api-token-created {
|
||||
font-size: 11px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.api-token-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.api-token-value-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.api-token-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import {
|
||||
@@ -152,6 +152,35 @@ const confAlerts = computed(() => {
|
||||
});
|
||||
|
||||
const alertVisible = ref(true);
|
||||
|
||||
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
|
||||
const slugToKey = (slug) => {
|
||||
const i = tabSlugs.indexOf(slug);
|
||||
return i >= 0 ? String(i + 1) : '1';
|
||||
};
|
||||
const keyToSlug = (key) => tabSlugs[Number(key) - 1] || tabSlugs[0];
|
||||
|
||||
const activeTabKey = ref(slugToKey(window.location.hash.slice(1)));
|
||||
|
||||
function onTabChange(key) {
|
||||
activeTabKey.value = key;
|
||||
const slug = keyToSlug(key);
|
||||
if (window.location.hash !== `#${slug}`) {
|
||||
history.replaceState(null, '', `#${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
function syncTabFromHash() {
|
||||
activeTabKey.value = slugToKey(window.location.hash.slice(1));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('hashchange', syncTabFromHash);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('hashchange', syncTabFromHash);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -199,7 +228,7 @@ const alertVisible = ref(true);
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-tabs default-active-key="1">
|
||||
<a-tabs :active-key="activeTabKey" @change="onTabChange">
|
||||
<a-tab-pane key="1" class="tab-pane">
|
||||
<template #tab>
|
||||
<SettingOutlined />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
@@ -208,6 +208,40 @@ function confirmRestart() {
|
||||
onOk: () => restartXray(),
|
||||
});
|
||||
}
|
||||
|
||||
const tabKeys = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
|
||||
const slugByKey = {
|
||||
'tpl-basic': 'basic',
|
||||
'tpl-routing': 'routing',
|
||||
'tpl-outbound': 'outbound',
|
||||
'tpl-balancer': 'balancer',
|
||||
'tpl-dns': 'dns',
|
||||
'tpl-advanced': 'advanced',
|
||||
};
|
||||
const keyBySlug = Object.fromEntries(Object.entries(slugByKey).map(([k, v]) => [v, k]));
|
||||
|
||||
const activeTabKey = ref(keyBySlug[window.location.hash.slice(1)] || tabKeys[0]);
|
||||
|
||||
function onTabChange(key) {
|
||||
activeTabKey.value = key;
|
||||
const slug = slugByKey[key];
|
||||
if (slug && window.location.hash !== `#${slug}`) {
|
||||
history.replaceState(null, '', `#${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
function syncTabFromHash() {
|
||||
const key = keyBySlug[window.location.hash.slice(1)];
|
||||
if (key) activeTabKey.value = key;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('hashchange', syncTabFromHash);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('hashchange', syncTabFromHash);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -259,7 +293,7 @@ function confirmRestart() {
|
||||
|
||||
<!-- Tabs -->
|
||||
<a-col :span="24">
|
||||
<a-tabs default-active-key="tpl-basic">
|
||||
<a-tabs :active-key="activeTabKey" @change="onTabChange">
|
||||
<a-tab-pane key="tpl-basic" class="tab-pane">
|
||||
<template #tab>
|
||||
<SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
|
||||
|
||||
Reference in New Issue
Block a user