Merge branch 'main' into fix/node-tag-unique-scope

This commit is contained in:
Sanaei 2026-05-13 17:05:17 +02:00 committed by GitHub
commit dd18130ece
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1119 additions and 323 deletions

View file

@ -41,6 +41,7 @@ func initModels() error {
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
&model.CustomGeoResource{}, &model.CustomGeoResource{},
&model.Node{}, &model.Node{},
&model.ApiToken{},
} }
for _, model := range models { for _, model := range models {
if err := db.AutoMigrate(model); err != nil { if err := db.AutoMigrate(model); err != nil {
@ -139,43 +140,80 @@ func runSeeders(isUsersEmpty bool) error {
hashSeeder := &model.HistoryOfSeeders{ hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash", SeederName: "UserPasswordHash",
} }
return db.Create(hashSeeder).Error if err := db.Create(hashSeeder).Error; err != nil {
} else { return err
var seedersHistory []string }
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil { return seedApiTokens()
log.Printf("Error fetching seeder history: %v", err) }
var seedersHistory []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
log.Printf("Error fetching seeder history: %v", err)
return err
}
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
var users []model.User
if err := db.Find(&users).Error; err != nil {
log.Printf("Error fetching users for password migration: %v", err)
return err return err
} }
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty { for _, user := range users {
var users []model.User hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
if err := db.Find(&users).Error; err != nil { if err != nil {
log.Printf("Error fetching users for password migration: %v", err) log.Printf("Error hashing password for user '%s': %v", user.Username, err)
return err return err
} }
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
for _, user := range users { log.Printf("Error updating password for user '%s': %v", user.Username, err)
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password) return err
if err != nil {
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
return err
}
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
log.Printf("Error updating password for user '%s': %v", user.Username, err)
return err
}
} }
}
hashSeeder := &model.HistoryOfSeeders{ hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash", SeederName: "UserPasswordHash",
} }
return db.Create(hashSeeder).Error if err := db.Create(hashSeeder).Error; err != nil {
return err
} }
} }
if !slices.Contains(seedersHistory, "ApiTokensTable") {
if err := seedApiTokens(); err != nil {
return err
}
}
return nil return nil
} }
// seedApiTokens copies the legacy `apiToken` setting into the new
// api_tokens table as a row named "default" so existing central panels
// keep working after the upgrade. Idempotent — records itself in
// history_of_seeders and only runs when api_tokens is empty.
func seedApiTokens() error {
empty, err := isTableEmpty("api_tokens")
if err != nil {
return err
}
if empty {
var legacy model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
if err == nil && legacy.Value != "" {
row := &model.ApiToken{
Name: "default",
Token: legacy.Value,
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
log.Printf("Error migrating legacy apiToken: %v", err)
return err
}
}
}
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
}
// isTableEmpty returns true if the named table contains zero rows. // isTableEmpty returns true if the named table contains zero rows.
func isTableEmpty(tableName string) (bool, error) { func isTableEmpty(tableName string) (bool, error) {
var count int64 var count int64

View file

@ -93,6 +93,14 @@ type HistoryOfSeeders struct {
SeederName string `json:"seederName"` SeederName string `json:"seederName"`
} }
type ApiToken struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;not null"`
Token string `json:"token" gorm:"not null"`
Enabled bool `json:"enabled" gorm:"default:true"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
}
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model. // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
listen := i.Listen listen := i.Listen

View file

@ -1,13 +1,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import { import {
KeyOutlined, KeyOutlined,
ReloadOutlined,
CopyOutlined,
EyeOutlined,
EyeInvisibleOutlined,
SearchOutlined, SearchOutlined,
ExpandOutlined, ExpandOutlined,
CompressOutlined, CompressOutlined,
@ -25,34 +19,28 @@ import {
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js'; import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import AppSidebar from '@/components/AppSidebar.vue'; import AppSidebar from '@/components/AppSidebar.vue';
import { HttpUtil, ClipboardManager } from '@/utils/index.js';
import { sections as allSections } from './endpoints.js'; import { sections as allSections } from './endpoints.js';
import EndpointSection from './EndpointSection.vue'; import EndpointSection from './EndpointSection.vue';
import CodeBlock from './CodeBlock.vue'; import CodeBlock from './CodeBlock.vue';
const { t } = useI18n();
const basePath = window.X_UI_BASE_PATH || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname; const requestUri = window.location.pathname;
const settingsHref = `${basePath}panel/settings#security`;
const apiToken = ref('');
const tokenLoading = ref(false);
const tokenRotating = ref(false);
const tokenVisible = ref(false);
const searchQuery = ref(''); const searchQuery = ref('');
const collapsedSections = ref(new Set()); const collapsedSections = ref(new Set());
const activeSection = ref(''); const activeSection = ref('');
const sectionIcons = { const sectionIcons = {
auth: SafetyCertificateOutlined, authentication: SafetyCertificateOutlined,
inbounds: NodeIndexOutlined, inbounds: NodeIndexOutlined,
server: CloudServerOutlined, server: CloudServerOutlined,
nodes: ClusterOutlined, nodes: ClusterOutlined,
customGeo: GlobalOutlined, 'custom-geo': GlobalOutlined,
backup: SaveOutlined, backup: SaveOutlined,
settings: SettingOutlined, settings: SettingOutlined,
xraySettings: WifiOutlined, 'api-tokens': KeyOutlined,
'xray-settings': WifiOutlined,
subscription: LinkOutlined, subscription: LinkOutlined,
websocket: ApiOutlined, websocket: ApiOutlined,
}; };
@ -103,46 +91,20 @@ function collapseAll() {
collapsedSections.value = new Set(allSections.map(s => s.id)); collapsedSections.value = new Set(allSections.map(s => s.id));
} }
async function loadApiToken() { function scrollToSection(id) {
tokenLoading.value = true; const el = document.getElementById(id);
try { if (!el) return;
const msg = await HttpUtil.get('/panel/setting/getApiToken'); el.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (msg?.success) apiToken.value = msg.obj || ''; if (window.location.hash !== `#${id}`) {
} finally { history.replaceState(null, '', `#${id}`);
tokenLoading.value = false;
} }
} }
function regenerateApiToken() { function scrollToHash() {
Modal.confirm({ const id = window.location.hash.slice(1);
title: t('pages.nodes.regenerateConfirm'), if (!id) return;
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) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
} }
let scrollObserver = null; let scrollObserver = null;
@ -162,16 +124,20 @@ function onScroll() {
} }
onMounted(() => { onMounted(() => {
loadApiToken();
scrollObserver = onScroll; scrollObserver = onScroll;
window.addEventListener('scroll', scrollObserver, { passive: true }); window.addEventListener('scroll', scrollObserver, { passive: true });
onScroll(); window.addEventListener('hashchange', scrollToHash);
requestAnimationFrame(() => {
scrollToHash();
onScroll();
});
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (scrollObserver) { if (scrollObserver) {
window.removeEventListener('scroll', scrollObserver); window.removeEventListener('scroll', scrollObserver);
} }
window.removeEventListener('hashchange', scrollToHash);
}); });
</script> </script>
@ -197,38 +163,17 @@ onBeforeUnmount(() => {
<div class="token-card-head"> <div class="token-card-head">
<div class="token-card-title"> <div class="token-card-title">
<KeyOutlined /> <KeyOutlined />
<span>API Token</span> <span>API Tokens</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>
</div> </div>
<a-button type="primary" size="small" :href="settingsHref">
Manage tokens
</a-button>
</div> </div>
<a-spin :spinning="tokenLoading" size="small">
<pre
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
</a-spin>
<p class="token-hint"> <p class="token-hint">
Send it on every request as <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated Create, enable, or revoke named Bearer tokens in
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately <a :href="settingsHref">Settings Security</a>. Send each request as
running bots will need the new value. <code>Authorization: Bearer &lt;token&gt;</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> </p>
</a-card> </a-card>
@ -387,25 +332,6 @@ onBeforeUnmount(() => {
font-size: 14px; 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 { .token-hint {
margin: 10px 0 0; margin: 10px 0 0;
color: rgba(0, 0, 0, 0.55); 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); background: rgba(255, 255, 255, 0.12);
} }
body.dark .token-value,
body.dark .code-block { body.dark .code-block {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.88); color: rgba(255, 255, 255, 0.88);
} }
html[data-theme='ultra-dark'] .token-value,
html[data-theme='ultra-dark'] .code-block { html[data-theme='ultra-dark'] .code-block {
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.08);

View file

@ -25,7 +25,7 @@ export function safeInlineHtml(input) {
export const sections = [ export const sections = [
{ {
id: 'auth', id: 'authentication',
title: 'Authentication', title: 'Authentication',
description: 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/*.', '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', title: 'Custom Geo',
description: description:
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.', 'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
@ -647,7 +647,7 @@ export const sections = [
id: 'settings', id: 'settings',
title: 'Settings', title: 'Settings',
description: 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: [ endpoints: [
{ {
method: 'POST', method: 'POST',
@ -688,23 +688,57 @@ export const sections = [
path: '/panel/setting/getDefaultJsonConfig', path: '/panel/setting/getDefaultJsonConfig',
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.', 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 &lt;token&gt;</code> on any /panel/api/* request.',
endpoints: [
{ {
method: 'GET', method: 'GET',
path: '/panel/setting/getApiToken', path: '/panel/setting/apiTokens',
summary: 'Return the current API Bearer token. The token is auto-generated on first read so existing installs upgrade transparently.', summary: 'List every API token, enabled or not.',
response: '{\n "success": true,\n "obj": "abcdef-12345-..."\n}', 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', method: 'POST',
path: '/panel/setting/regenerateApiToken', path: '/panel/setting/apiTokens/create',
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.', summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
response: '{\n "success": true,\n "obj": "new-token-string"\n}', 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', title: 'Xray Settings',
description: description:
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.', 'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',

View file

@ -75,34 +75,41 @@ function updateUser() {
} }
} }
// === API Token ========================================================= const apiTokens = ref([]);
// Surfaces the panel's API token so a remote central panel can register const apiTokensLoading = ref(false);
// this instance as a node. Lazy-loaded on tab mount; rotation requires const visibleTokenIds = ref(new Set());
// confirmation since it invalidates any cached value upstream. const createOpen = ref(false);
const apiToken = ref(''); const createName = ref('');
const apiTokenLoading = ref(false); const creating = ref(false);
const apiTokenRotating = ref(false);
async function loadApiToken() { async function loadApiTokens() {
apiTokenLoading.value = true; apiTokensLoading.value = true;
try { try {
const msg = await HttpUtil.get('/panel/setting/getApiToken'); const msg = await HttpUtil.get('/panel/setting/apiTokens');
if (msg?.success) apiToken.value = msg.obj || ''; if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
} finally { } finally {
apiTokenLoading.value = false; apiTokensLoading.value = false;
} }
} }
async function copyApiToken() { function isTokenVisible(id) {
if (!apiToken.value) return; 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 { try {
await navigator.clipboard.writeText(apiToken.value); await navigator.clipboard.writeText(token);
message.success(t('copySuccess')); message.success(t('copySuccess'));
} catch (_e) { } catch (_e) {
// navigator.clipboard can be undefined on http:// fall back to
// a transient input + execCommand path.
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = apiToken.value; ta.value = token;
document.body.appendChild(ta); document.body.appendChild(ta);
ta.select(); ta.select();
document.execCommand('copy'); 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({ Modal.confirm({
title: t('pages.nodes.regenerateConfirm'), title: `${t('delete')} "${row.name}"?`,
okText: t('confirm'), content: t('pages.settings.security.apiTokenDeleteWarning')
|| 'Any caller using this token will stop authenticating immediately.',
okText: t('delete'),
cancelText: t('cancel'), cancelText: t('cancel'),
okType: 'danger', okType: 'danger',
onOk: async () => { onOk: async () => {
apiTokenRotating.value = true; const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
try { if (msg?.success) await loadApiTokens();
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
if (msg?.success) {
apiToken.value = msg.obj || '';
message.success(t('success'));
}
} finally {
apiTokenRotating.value = false;
}
}, },
}); });
} }
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() { function toggleTwoFactor() {
// Switch read-only the actual flip happens after the modal succeeds. // Switch read-only the actual flip happens after the modal succeeds.
@ -216,24 +261,144 @@ function toggleTwoFactor() {
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')"> <a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
<SettingListItem paddings="small"> <div class="api-token-section">
<template #title>{{ t('pages.nodes.apiToken') }}</template> <div class="api-token-header">
<template #description>{{ t('pages.nodes.apiTokenHint') }}</template> <p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
<template #control> <a-button type="primary" size="small" @click="openCreateModal">
<a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" /> + {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
</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') }}
</a-button> </a-button>
</a-space> </div>
</a-list-item>
<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-panel>
</a-collapse> </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" <TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
:type="tfa.type" @confirm="onTfaConfirm" /> :type="tfa.type" @confirm="onTfaConfirm" />
</template> </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>

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Modal } from 'ant-design-vue'; import { Modal } from 'ant-design-vue';
import { import {
@ -152,6 +152,35 @@ const confAlerts = computed(() => {
}); });
const alertVisible = ref(true); 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> </script>
<template> <template>
@ -199,7 +228,7 @@ const alertVisible = ref(true);
</a-col> </a-col>
<a-col :span="24"> <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"> <a-tab-pane key="1" class="tab-pane">
<template #tab> <template #tab>
<SettingOutlined /> <SettingOutlined />

View file

@ -80,8 +80,17 @@ watch(() => props.open, (next) => {
primeAdvancedJson(); primeAdvancedJson();
}); });
watch(activeKey, (key) => { let isRevertingTab = false;
if (key === '2') primeAdvancedJson(); watch(activeKey, (key, prev) => {
if (isRevertingTab) { isRevertingTab = false; return; }
if (key === '2') {
primeAdvancedJson();
} else if (key === '1' && prev === '2') {
if (!applyAdvancedJsonToForm()) {
isRevertingTab = true;
activeKey.value = '2';
}
}
}); });
function primeAdvancedJson() { function primeAdvancedJson() {
@ -93,6 +102,33 @@ function primeAdvancedJson() {
} }
} }
function applyAdvancedJsonToForm() {
const raw = advancedJson.value.trim();
if (!raw) return true;
let currentJson = '';
try {
currentJson = JSON.stringify(outbound.value?.toJson() ?? {}, null, 2);
} catch (_e) { /* fall through */ }
if (raw === currentJson.trim()) return true;
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
message.error(`JSON: ${e.message}`);
return false;
}
try {
const fallbackTag = outbound.value?.tag;
const next = Outbound.fromJson(parsed);
if (!next.tag && fallbackTag) next.tag = fallbackTag;
outbound.value = next;
return true;
} catch (e) {
message.error(`JSON: ${e.message}`);
return false;
}
}
function close() { emit('update:open', false); } function close() { emit('update:open', false); }
function onProtocolChange(next) { function onProtocolChange(next) {
@ -131,27 +167,15 @@ const tagHelp = computed(() => {
// ============== Submit ============== // ============== Submit ==============
function onOk() { function onOk() {
if (!outbound.value) return; if (!outbound.value) return;
if (activeKey.value === '2' && !applyAdvancedJsonToForm()) return;
if (!outbound.value.tag?.trim()) { if (!outbound.value.tag?.trim()) {
message.error(t('somethingWentWrong')); message.error('Tag is required');
return; return;
} }
if (duplicateTag.value) { if (duplicateTag.value) {
message.error(t('somethingWentWrong')); message.error('Tag already used by another outbound');
return; return;
} }
// If user spent time in the JSON tab, prefer that body round-trip
// it through Outbound.fromJson so the wire shape stays consistent.
if (activeKey.value === '2' && advancedJson.value.trim()) {
try {
const parsed = JSON.parse(advancedJson.value);
const built = Outbound.fromJson(parsed);
emit('confirm', built.toJson());
return;
} catch (e) {
message.error(`JSON: ${e.message}`);
return;
}
}
emit('confirm', outbound.value.toJson()); emit('confirm', outbound.value.toJson());
} }

View file

@ -10,6 +10,7 @@ import {
ClusterOutlined, ClusterOutlined,
ArrowUpOutlined, ArrowUpOutlined,
ArrowDownOutlined, ArrowDownOutlined,
HolderOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue'; import { Modal } from 'ant-design-vue';
@ -22,9 +23,11 @@ const { t } = useI18n();
// "lead value + N more" pill per criterion (matches the legacy pill // "lead value + N more" pill per criterion (matches the legacy pill
// layout); full lists surface via tooltip on hover. // layout); full lists surface via tooltip on hover.
// //
// Reorder uses up/down buttons in the action menu rather than the // Reorder via Pointer Events on the grip icon unified mouse +
// jQuery-Sortable drag handle the legacy panel used same effect, // touch + pen path so the same code works on desktop and mobile
// no extra dep. The mobile column layout drops source/network/ // (HTML5 drag doesn't fire from touch on iOS Safari, hence the
// switch). Up/down buttons in the action menu stay as a keyboard
// fallback. The mobile column layout drops source/network/
// destination criteria for readability. // destination criteria for readability.
const props = defineProps({ const props = defineProps({
@ -162,6 +165,58 @@ function moveDown(idx) {
[rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]]; [rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
} }
const draggedIndex = ref(null);
const dropTargetIndex = ref(null);
let dragStartY = 0;
let dragMoved = false;
function onHandlePointerDown(idx, ev) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
draggedIndex.value = idx;
dropTargetIndex.value = idx;
dragStartY = ev.clientY;
dragMoved = false;
document.addEventListener('pointermove', onDragPointerMove);
document.addEventListener('pointerup', onDragPointerUp);
document.addEventListener('pointercancel', onDragPointerUp);
}
function onDragPointerMove(ev) {
if (draggedIndex.value == null) return;
if (!dragMoved && Math.abs(ev.clientY - dragStartY) < 5) return;
dragMoved = true;
const el = document.elementFromPoint(ev.clientX, ev.clientY);
if (!el) return;
const tr = el.closest('tr[data-row-key]');
if (!tr) return;
const idx = Number(tr.getAttribute('data-row-key'));
if (Number.isFinite(idx)) dropTargetIndex.value = idx;
}
function onDragPointerUp() {
document.removeEventListener('pointermove', onDragPointerMove);
document.removeEventListener('pointerup', onDragPointerUp);
document.removeEventListener('pointercancel', onDragPointerUp);
const from = draggedIndex.value;
const to = dropTargetIndex.value;
draggedIndex.value = null;
dropTargetIndex.value = null;
if (!dragMoved || from == null || to == null || from === to) return;
const rules = props.templateSettings.routing.rules;
const [moved] = rules.splice(from, 1);
rules.splice(to, 0, moved);
}
function rowProps(_record, index) {
const classes = [];
if (draggedIndex.value === index) classes.push('row-dragging');
if (dropTargetIndex.value === index && draggedIndex.value !== index) {
classes.push(index > draggedIndex.value ? 'drop-after' : 'drop-before');
}
return { class: classes.join(' ') };
}
// === Columns ========================================================= // === Columns =========================================================
// Computed so titles re-render after a locale swap. // Computed so titles re-render after a locale swap.
const desktopColumns = computed(() => [ const desktopColumns = computed(() => [
@ -170,14 +225,31 @@ const desktopColumns = computed(() => [
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' }, { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
{ title: 'Destination', align: 'left', key: 'destination' }, { title: 'Destination', align: 'left', key: 'destination' },
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' }, { title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' }, { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'outbound' },
{ title: t('pages.xray.Balancers'), align: 'left', width: 150, key: 'balancer' },
]); ]);
const mobileColumns = computed(() => [ const columns = computed(() => desktopColumns.value);
{ title: '#', align: 'center', width: 70, key: 'action' },
{ title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' }, function ruleCriteriaChips(rule) {
{ title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' }, const chips = [];
]); if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value)); if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
if (rule.port) chips.push({ label: 'Port', value: rule.port });
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
if (rule.network) chips.push({ label: 'L4', value: rule.network });
if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
if (rule.user) chips.push({ label: 'User', value: rule.user });
if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
return chips;
}
function chipPreview(value) {
const parts = csv(value);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0];
return `${parts[0]} +${parts.length - 1}`;
}
</script> </script>
<template> <template>
@ -189,12 +261,84 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
{{ t('pages.xray.Routings') }} {{ t('pages.xray.Routings') }}
</a-button> </a-button>
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" <!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
:scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table"> phone (~520px of columns alone), so render each rule as a
compact card with the routing summary + criteria chips. -->
<div v-if="isMobile" class="rule-list">
<div v-for="(rule, index) in rows" :key="rule.key" class="rule-card" :class="{
'row-dragging': draggedIndex === index,
'drop-before': dropTargetIndex === index && draggedIndex != null && index < draggedIndex,
'drop-after': dropTargetIndex === index && draggedIndex != null && index > draggedIndex,
}" :data-row-key="index">
<div class="rule-card-head">
<HolderOutlined class="drag-handle" @pointerdown="onHandlePointerDown(index, $event)" />
<span class="rule-number">#{{ index + 1 }}</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="rule-flow">
<div class="flow-side">
<span class="flow-label">{{ t('pages.xray.Inbounds') }}</span>
<a-tag v-if="rule.inboundTag" color="blue" class="flow-tag">
{{ chipPreview(rule.inboundTag) }}
</a-tag>
<span v-else class="criterion-empty">any</span>
</div>
<span class="flow-arrow"></span>
<div class="flow-side flow-side-target">
<span class="flow-label">{{
rule.balancerTag ? (t('pages.xray.balancer') || 'Balancer') : t('pages.xray.Outbounds')
}}</span>
<a-tag v-if="rule.outboundTag" color="green" class="flow-tag">
<ExportOutlined /> {{ rule.outboundTag }}
</a-tag>
<a-tag v-else-if="rule.balancerTag" color="purple" class="flow-tag">
<ClusterOutlined /> {{ rule.balancerTag }}
</a-tag>
<span v-else class="criterion-empty"></span>
</div>
</div>
<div v-if="ruleCriteriaChips(rule).length" class="rule-criteria">
<a-tooltip v-for="chip in ruleCriteriaChips(rule)" :key="chip.label" :title="`${chip.label}: ${chip.value}`">
<span class="criterion-chip">
<span class="criterion-chip-label">{{ chip.label }}</span>
<span class="criterion-chip-value">{{ chipPreview(chip.value) }}</span>
</span>
</a-tooltip>
</div>
</div>
<div v-if="!rows.length" class="rule-empty"></div>
</div>
<a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
:scroll="{ x: 1150 }" size="small" class="routing-table" :custom-row="rowProps">
<template #bodyCell="{ column, record, index }"> <template #bodyCell="{ column, record, index }">
<!-- ============== # / actions ============== --> <!-- ============== # / actions ============== -->
<template v-if="column.key === 'action'"> <template v-if="column.key === 'action'">
<div class="action-cell"> <div class="action-cell">
<HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
@pointerdown="onHandlePointerDown(index, $event)" />
<span class="row-index">{{ index + 1 }}</span> <span class="row-index">{{ index + 1 }}</span>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button shape="circle" size="small"> <a-button shape="circle" size="small">
@ -228,7 +372,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">IP</span> <span class="criterion-label">IP</span>
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span> <span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1 <span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
}}</span> }}</span>
</span> </span>
</a-tooltip> </a-tooltip>
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`"> <a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
@ -259,7 +403,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">L4</span> <span class="criterion-label">L4</span>
<span class="criterion-value">{{ csv(record.network)[0] }}</span> <span class="criterion-value">{{ csv(record.network)[0] }}</span>
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1 <span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
}}</span> }}</span>
</span> </span>
</a-tooltip> </a-tooltip>
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`"> <a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
@ -267,7 +411,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">Protocol</span> <span class="criterion-label">Protocol</span>
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span> <span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1 <span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
}}</span> }}</span>
</span> </span>
</a-tooltip> </a-tooltip>
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`"> <a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
@ -295,7 +439,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">Domain</span> <span class="criterion-label">Domain</span>
<span class="criterion-value">{{ csv(record.domain)[0] }}</span> <span class="criterion-value">{{ csv(record.domain)[0] }}</span>
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1 <span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
}}</span> }}</span>
</span> </span>
</a-tooltip> </a-tooltip>
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`"> <a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
@ -303,7 +447,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">Port</span> <span class="criterion-label">Port</span>
<span class="criterion-value">{{ csv(record.port)[0] }}</span> <span class="criterion-value">{{ csv(record.port)[0] }}</span>
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1 <span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
}}</span> }}</span>
</span> </span>
</a-tooltip> </a-tooltip>
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty"></span> <span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty"></span>
@ -326,25 +470,32 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
<span class="criterion-label">User</span> <span class="criterion-label">User</span>
<span class="criterion-value">{{ csv(record.user)[0] }}</span> <span class="criterion-value">{{ csv(record.user)[0] }}</span>
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1 <span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
}}</span> }}</span>
</span> </span>
</a-tooltip> </a-tooltip>
<span v-if="!record.inboundTag && !record.user" class="criterion-empty"></span> <span v-if="!record.inboundTag && !record.user" class="criterion-empty"></span>
</div> </div>
</template> </template>
<!-- ============== Outbound / balancer target ============== --> <!-- ============== Outbound ============== -->
<template v-else-if="column.key === 'target'"> <template v-else-if="column.key === 'outbound'">
<div class="target-cell"> <div class="target-cell">
<div v-if="record.outboundTag" class="target-row"> <div v-if="record.outboundTag" class="target-row">
<ExportOutlined class="target-icon" /> <ExportOutlined class="target-icon" />
<a-tag color="green">{{ record.outboundTag }}</a-tag> <a-tag color="green">{{ record.outboundTag }}</a-tag>
</div> </div>
<span v-else class="criterion-empty"></span>
</div>
</template>
<!-- ============== Balancer ============== -->
<template v-else-if="column.key === 'balancer'">
<div class="target-cell">
<div v-if="record.balancerTag" class="target-row"> <div v-if="record.balancerTag" class="target-row">
<ClusterOutlined class="target-icon" /> <ClusterOutlined class="target-icon" />
<a-tag color="purple">{{ record.balancerTag }}</a-tag> <a-tag color="purple">{{ record.balancerTag }}</a-tag>
</div> </div>
<span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty"></span> <span v-else class="criterion-empty"></span>
</div> </div>
</template> </template>
</template> </template>
@ -362,6 +513,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
gap: 6px; gap: 6px;
} }
.drag-handle {
cursor: grab;
opacity: 0.35;
font-size: 14px;
padding: 4px;
margin: -4px;
touch-action: none;
transition: opacity 0.15s;
}
.drag-handle:hover {
opacity: 0.8;
}
.drag-handle:active {
cursor: grabbing;
}
:deep(.row-dragging) {
opacity: 0.4;
}
:deep(.drop-before > td) {
box-shadow: inset 0 2px 0 0 #1677ff;
}
:deep(.drop-after > td) {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.row-index { .row-index {
font-weight: 500; font-weight: 500;
opacity: 0.7; opacity: 0.7;
@ -429,4 +610,136 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
.danger { .danger {
color: #ff4d4f; color: #ff4d4f;
} }
/* === Mobile card list ====================================== */
.rule-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.rule-card {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--bg-card, #fff);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 8px;
transition: opacity 0.15s, box-shadow 0.15s;
}
.rule-card.row-dragging {
opacity: 0.4;
}
.rule-card.drop-before {
box-shadow: inset 0 2px 0 0 #1677ff;
}
.rule-card.drop-after {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.rule-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.rule-number {
font-weight: 600;
font-size: 13px;
opacity: 0.75;
flex: 1;
}
.rule-flow {
display: flex;
align-items: center;
gap: 8px;
}
.flow-side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.flow-side-target {
align-items: flex-end;
text-align: right;
}
.flow-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
}
.flow-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.flow-arrow {
font-size: 16px;
opacity: 0.45;
flex-shrink: 0;
}
.rule-criteria {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding-top: 6px;
border-top: 1px dashed rgba(128, 128, 128, 0.2);
}
.criterion-chip {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 1px 6px;
font-size: 11px;
background: rgba(128, 128, 128, 0.08);
border-radius: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.criterion-chip-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
}
.criterion-chip-value {
font-weight: 500;
}
.rule-empty {
padding: 24px;
text-align: center;
opacity: 0.4;
}
:global(body.dark) .rule-card {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
:global(body.dark) .criterion-chip {
background: rgba(255, 255, 255, 0.06);
}
</style> </style>

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue'; import { Modal, message } from 'ant-design-vue';
import { import {
@ -208,6 +208,40 @@ function confirmRestart() {
onOk: () => restartXray(), 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> </script>
<template> <template>
@ -259,7 +293,7 @@ function confirmRestart() {
<!-- Tabs --> <!-- Tabs -->
<a-col :span="24"> <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"> <a-tab-pane key="tpl-basic" class="tab-pane">
<template #tab> <template #tab>
<SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span> <SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>

1
go.mod
View file

@ -26,6 +26,7 @@ require (
golang.org/x/sys v0.44.0 golang.org/x/sys v0.44.0
golang.org/x/text v0.37.0 golang.org/x/text v0.37.0
google.golang.org/grpc v1.81.0 google.golang.org/grpc v1.81.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )

2
go.sum
View file

@ -265,6 +265,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -11,17 +11,25 @@ import (
"github.com/mhsanaei/3x-ui/v3/config" "github.com/mhsanaei/3x-ui/v3/config"
"github.com/op/go-logging" "github.com/op/go-logging"
"gopkg.in/natefinch/lumberjack.v2"
) )
const ( const (
maxLogBufferSize = 10240 // Maximum log entries kept in memory maxLogBufferSize = 10240 // Maximum log entries kept in memory
logFileName = "3xui.log" // Log file name logFileName = "3xui.log" // Log file name
timeFormat = "2006/01/02 15:04:05" // Log timestamp format timeFormat = "2006/01/02 15:04:05" // Log timestamp format
// On-disk rotation limits — single file capped, old segments pruned automatically.
maxLogFileMB = 10 // rotate active log when larger than this
maxLogBackups = 5 // rotated files retained (beyond current segment)
maxLogAgeDays = 7 // remove rotated backups older than this (0 disables time-based pruning)
compressRotated = true
) )
var ( var (
logger *logging.Logger logger *logging.Logger
logFile *os.File fileRotate *lumberjack.Logger // nil when file backend disabled
// logBuffer maintains recent log entries in memory for web UI retrieval // logBuffer maintains recent log entries in memory for web UI retrieval
logBuffer []struct { logBuffer []struct {
@ -81,8 +89,8 @@ func initDefaultBackend() logging.Backend {
return logging.NewBackendFormatter(backend, newFormatter(includeTime)) return logging.NewBackendFormatter(backend, newFormatter(includeTime))
} }
// initFileBackend creates the file logging backend. // initFileBackend creates the file logging backend with size/agebounded rotation
// Creates log directory and truncates log file on startup for fresh logs. // so log volume cannot grow without limit on disk.
func initFileBackend() logging.Backend { func initFileBackend() logging.Backend {
logDir := config.GetLogFolder() logDir := config.GetLogFolder()
if err := os.MkdirAll(logDir, 0o750); err != nil { if err := os.MkdirAll(logDir, 0o750); err != nil {
@ -91,19 +99,16 @@ func initFileBackend() logging.Backend {
} }
logPath := filepath.Join(logDir, logFileName) logPath := filepath.Join(logDir, logFileName)
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660) fileRotate = &lumberjack.Logger{
if err != nil { Filename: logPath,
fmt.Fprintf(os.Stderr, "failed to open log file %s: %v\n", logPath, err) MaxSize: maxLogFileMB,
return nil MaxBackups: maxLogBackups,
MaxAge: maxLogAgeDays,
LocalTime: true,
Compress: compressRotated,
} }
// Close previous log file if exists backend := logging.NewLogBackend(fileRotate, "", 0)
if logFile != nil {
_ = logFile.Close()
}
logFile = file
backend := logging.NewLogBackend(file, "", 0)
return logging.NewBackendFormatter(backend, newFormatter(true)) return logging.NewBackendFormatter(backend, newFormatter(true))
} }
@ -116,12 +121,12 @@ func newFormatter(withTime bool) logging.Formatter {
return logging.MustStringFormatter(format) return logging.MustStringFormatter(format)
} }
// CloseLogger closes the log file and cleans up resources. // CloseLogger closes the rotating log writer and cleans up resources.
// Should be called during application shutdown. // Should be called during application shutdown.
func CloseLogger() { func CloseLogger() {
if logFile != nil { if fileRotate != nil {
_ = logFile.Close() _ = fileRotate.Close()
logFile = nil fileRotate = nil
} }
} }

View file

@ -19,6 +19,7 @@ type APIController struct {
nodeController *NodeController nodeController *NodeController
settingService service.SettingService settingService service.SettingService
userService service.UserService userService service.UserService
apiTokenService service.ApiTokenService
Tgbot service.Tgbot Tgbot service.Tgbot
} }
@ -33,7 +34,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") { if strings.HasPrefix(auth, "Bearer ") {
tok := strings.TrimPrefix(auth, "Bearer ") tok := strings.TrimPrefix(auth, "Bearer ")
if a.settingService.MatchApiToken(tok) { if a.apiTokenService.Match(tok) {
if u, err := a.userService.GetFirstUser(); err == nil { if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u) session.SetAPIAuthUser(c, u)
} }

View file

@ -2,6 +2,7 @@ package controller
import ( import (
"errors" "errors"
"strconv"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/util/crypto" "github.com/mhsanaei/3x-ui/v3/util/crypto"
@ -22,9 +23,10 @@ type updateUserForm struct {
// SettingController handles settings and user management operations. // SettingController handles settings and user management operations.
type SettingController struct { type SettingController struct {
settingService service.SettingService settingService service.SettingService
userService service.UserService userService service.UserService
panelService service.PanelService panelService service.PanelService
apiTokenService service.ApiTokenService
} }
// NewSettingController creates a new SettingController and initializes its routes. // NewSettingController creates a new SettingController and initializes its routes.
@ -44,8 +46,10 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/updateUser", a.updateUser) g.POST("/updateUser", a.updateUser)
g.POST("/restartPanel", a.restartPanel) g.POST("/restartPanel", a.restartPanel)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig) g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getApiToken", a.getApiToken) g.GET("/apiTokens", a.listApiTokens)
g.POST("/regenerateApiToken", a.regenerateApiToken) g.POST("/apiTokens/create", a.createApiToken)
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
} }
// getAllSetting retrieves all current settings. // getAllSetting retrieves all current settings.
@ -130,26 +134,56 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
jsonObj(c, defaultJsonConfig, nil) jsonObj(c, defaultJsonConfig, nil)
} }
// getApiToken returns the panel's API token used by remote central type apiTokenCreateForm struct {
// panels to authenticate as Bearer tokens. The token is auto-generated Name string `json:"name" form:"name"`
// on first read so existing installs upgrade transparently. }
func (a *SettingController) getApiToken(c *gin.Context) {
tok, err := a.settingService.GetApiToken() type apiTokenEnabledForm struct {
Enabled bool `json:"enabled" form:"enabled"`
}
func (a *SettingController) listApiTokens(c *gin.Context) {
rows, err := a.apiTokenService.List()
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return return
} }
jsonObj(c, tok, nil) jsonObj(c, rows, nil)
} }
// regenerateApiToken rotates the API token. Any central panel that had func (a *SettingController) createApiToken(c *gin.Context) {
// the old value cached will start failing heartbeats until it is updated form := &apiTokenCreateForm{}
// with the new token — that's intentional, it's the whole point of rotation. if err := c.ShouldBind(form); err != nil {
func (a *SettingController) regenerateApiToken(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
tok, err := a.settingService.RegenerateApiToken() return
}
row, err := a.apiTokenService.Create(form.Name)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return return
} }
jsonObj(c, tok, nil) jsonObj(c, row, nil)
}
func (a *SettingController) deleteApiToken(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
}
func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
form := &apiTokenEnabledForm{}
if bindErr := c.ShouldBind(form); bindErr != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
} }

119
web/service/api_token.go Normal file
View file

@ -0,0 +1,119 @@
package service
import (
"crypto/subtle"
"errors"
"strings"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/random"
)
type ApiTokenService struct{}
const apiTokenLength = 48
type ApiTokenView struct {
Id int `json:"id"`
Name string `json:"name"`
Token string `json:"token"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"createdAt"`
}
func toView(t *model.ApiToken) *ApiTokenView {
return &ApiTokenView{
Id: t.Id,
Name: t.Name,
Token: t.Token,
Enabled: t.Enabled,
CreatedAt: t.CreatedAt,
}
}
func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*ApiTokenView, 0, len(rows))
for _, r := range rows {
out = append(out, toView(r))
}
return out, nil
}
func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, common.NewError("token name is required")
}
if len(name) > 64 {
return nil, common.NewError("token name must be 64 characters or fewer")
}
db := database.GetDB()
var count int64
if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewError("a token with that name already exists")
}
row := &model.ApiToken{
Name: name,
Token: random.Seq(apiTokenLength),
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
return nil, err
}
return toView(row), nil
}
func (s *ApiTokenService) Delete(id int) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
}
func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("token not found")
}
return nil
}
// Match returns true when the presented bearer token matches any enabled
// row in api_tokens. Uses constant-time compare per row so a remote
// attacker can't time-attack tokens byte-by-byte.
func (s *ApiTokenService) Match(presented string) bool {
if presented == "" {
return false
}
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
return false
}
presentedBytes := []byte(presented)
matched := false
for _, r := range rows {
if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
matched = true
}
}
return matched
}

View file

@ -1,7 +1,6 @@
package service package service
import ( import (
"crypto/subtle"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors" "errors"
@ -211,7 +210,10 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
view.HasLdapPassword = secretConfigured(allSetting.LdapPassword) view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord())) view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
view.HasApiToken = secretConfigured(mustString(s.getString("apiToken"))) var apiTokenCount int64
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
view.HasApiToken = apiTokenCount > 0
}
view.TgBotToken = "" view.TgBotToken = ""
view.TwoFactorToken = "" view.TwoFactorToken = ""
view.LdapPassword = "" view.LdapPassword = ""
@ -467,48 +469,6 @@ func (s *SettingService) GetSecret() ([]byte, error) {
return []byte(secret), err return []byte(secret), err
} }
// GetApiToken returns the panel's API token, lazily generating one on
// first read so existing installs upgrade transparently. The token is
// stored plaintext to match how the existing tg/ldap secrets are kept.
func (s *SettingService) GetApiToken() (string, error) {
tok, err := s.getString("apiToken")
if err != nil {
return "", err
}
if tok == "" {
tok = random.Seq(48)
if saveErr := s.saveSetting("apiToken", tok); saveErr != nil {
logger.Warning("save apiToken failed:", saveErr)
return "", saveErr
}
}
return tok, nil
}
// RegenerateApiToken rotates the API token, invalidating any central
// panel that has the old value cached.
func (s *SettingService) RegenerateApiToken() (string, error) {
tok := random.Seq(48)
if err := s.saveSetting("apiToken", tok); err != nil {
return "", err
}
return tok, nil
}
// MatchApiToken returns true when the supplied bearer token matches the
// stored API token. Uses constant-time compare so a remote attacker
// can't time-attack the token byte-by-byte.
func (s *SettingService) MatchApiToken(presented string) bool {
if presented == "" {
return false
}
stored, err := s.getString("apiToken")
if err != nil || stored == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1
}
func (s *SettingService) SetBasePath(basePath string) error { func (s *SettingService) SetBasePath(basePath string) error {
if !strings.HasPrefix(basePath, "/") { if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath basePath = "/" + basePath
@ -877,7 +837,7 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
func (s *SettingService) UpdateSecret(key string, value string) error { func (s *SettingService) UpdateSecret(key string, value string) error {
switch key { switch key {
case "tgBotToken", "ldapPassword", "twoFactorToken", "apiToken": case "tgBotToken", "ldapPassword", "twoFactorToken":
return s.saveSetting(key, strings.TrimSpace(value)) return s.saveSetting(key, strings.TrimSpace(value))
default: default:
return common.NewError("secret key is not replaceable:", key) return common.NewError("secret key is not replaceable:", key)

View file

@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/mhsanaei/3x-ui/v3/database" "github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
) )
func setupSettingTestDB(t *testing.T) { func setupSettingTestDB(t *testing.T) {
@ -31,7 +32,7 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil { if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := s.saveSetting("apiToken", "api-secret"); err != nil { if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "أدخل الرمز من التطبيق لتغيير بيانات اعتماد المسؤول.", "twoFactorModalChangeCredentialsStep": "أدخل الرمز من التطبيق لتغيير بيانات اعتماد المسؤول.",
"twoFactorModalSetSuccess": "تم إنشاء المصادقة الثنائية بنجاح", "twoFactorModalSetSuccess": "تم إنشاء المصادقة الثنائية بنجاح",
"twoFactorModalDeleteSuccess": "تم حذف المصادقة الثنائية بنجاح", "twoFactorModalDeleteSuccess": "تم حذف المصادقة الثنائية بنجاح",
"twoFactorModalError": "رمز خاطئ" "twoFactorModalError": "رمز خاطئ",
"show": "إظهار",
"hide": "إخفاء",
"apiTokenNew": "رمز جديد",
"apiTokenName": "الاسم",
"apiTokenNamePlaceholder": "مثل central-panel-a",
"apiTokenNameRequired": "الاسم مطلوب",
"apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
"apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
}, },
"toasts": { "toasts": {
"modifySettings": "تم تغيير المعلمات.", "modifySettings": "تم تغيير المعلمات.",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Enter the code from the application to change administrator credentials.", "twoFactorModalChangeCredentialsStep": "Enter the code from the application to change administrator credentials.",
"twoFactorModalSetSuccess": "Two-factor authentication has been successfully established", "twoFactorModalSetSuccess": "Two-factor authentication has been successfully established",
"twoFactorModalDeleteSuccess": "Two-factor authentication has been successfully deleted", "twoFactorModalDeleteSuccess": "Two-factor authentication has been successfully deleted",
"twoFactorModalError": "Wrong code" "twoFactorModalError": "Wrong code",
"show": "Show",
"hide": "Hide",
"apiTokenNew": "New token",
"apiTokenName": "Name",
"apiTokenNamePlaceholder": "e.g. central-panel-a",
"apiTokenNameRequired": "Name is required",
"apiTokenEmpty": "No tokens yet — create one to authenticate bots or remote panels.",
"apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately."
}, },
"toasts": { "toasts": {
"modifySettings": "The parameters have been changed.", "modifySettings": "The parameters have been changed.",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Ingrese el código de la aplicación para cambiar las credenciales del administrador.", "twoFactorModalChangeCredentialsStep": "Ingrese el código de la aplicación para cambiar las credenciales del administrador.",
"twoFactorModalSetSuccess": "La autenticación de dos factores se ha establecido con éxito", "twoFactorModalSetSuccess": "La autenticación de dos factores se ha establecido con éxito",
"twoFactorModalDeleteSuccess": "La autenticación de dos factores se ha eliminado con éxito", "twoFactorModalDeleteSuccess": "La autenticación de dos factores se ha eliminado con éxito",
"twoFactorModalError": "Código incorrecto" "twoFactorModalError": "Código incorrecto",
"show": "Mostrar",
"hide": "Ocultar",
"apiTokenNew": "Nuevo token",
"apiTokenName": "Nombre",
"apiTokenNamePlaceholder": "por ejemplo central-panel-a",
"apiTokenNameRequired": "El nombre es obligatorio",
"apiTokenEmpty": "Aún no hay tokens — crea uno para autenticar bots o paneles remotos.",
"apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente."
}, },
"toasts": { "toasts": {
"modifySettings": "Los parámetros han sido modificados.", "modifySettings": "Los parámetros han sido modificados.",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "برای تغییر اعتبارنامه‌های مدیر، کد را از برنامه وارد کنید.", "twoFactorModalChangeCredentialsStep": "برای تغییر اعتبارنامه‌های مدیر، کد را از برنامه وارد کنید.",
"twoFactorModalSetSuccess": "احراز هویت دو مرحله‌ای با موفقیت برقرار شد", "twoFactorModalSetSuccess": "احراز هویت دو مرحله‌ای با موفقیت برقرار شد",
"twoFactorModalDeleteSuccess": "احراز هویت دو مرحله‌ای با موفقیت حذف شد", "twoFactorModalDeleteSuccess": "احراز هویت دو مرحله‌ای با موفقیت حذف شد",
"twoFactorModalError": "کد نادرست" "twoFactorModalError": "کد نادرست",
"show": "نمایش",
"hide": "پنهان",
"apiTokenNew": "توکن جدید",
"apiTokenName": "نام",
"apiTokenNamePlaceholder": "مثلاً central-panel-a",
"apiTokenNameRequired": "نام الزامی است",
"apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت ربات‌ها یا پنل‌های راه دور یکی بسازید.",
"apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود."
}, },
"toasts": { "toasts": {
"modifySettings": "پارامترها تغییر کرده‌اند.", "modifySettings": "پارامترها تغییر کرده‌اند.",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Masukkan kode dari aplikasi untuk mengubah kredensial administrator.", "twoFactorModalChangeCredentialsStep": "Masukkan kode dari aplikasi untuk mengubah kredensial administrator.",
"twoFactorModalSetSuccess": "Autentikasi dua faktor telah berhasil dibuat", "twoFactorModalSetSuccess": "Autentikasi dua faktor telah berhasil dibuat",
"twoFactorModalDeleteSuccess": "Autentikasi dua faktor telah berhasil dihapus", "twoFactorModalDeleteSuccess": "Autentikasi dua faktor telah berhasil dihapus",
"twoFactorModalError": "Kode salah" "twoFactorModalError": "Kode salah",
"show": "Tampilkan",
"hide": "Sembunyikan",
"apiTokenNew": "Token baru",
"apiTokenName": "Nama",
"apiTokenNamePlaceholder": "misalnya central-panel-a",
"apiTokenNameRequired": "Nama wajib diisi",
"apiTokenEmpty": "Belum ada token — buat satu untuk mengautentikasi bot atau panel jarak jauh.",
"apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera."
}, },
"toasts": { "toasts": {
"modifySettings": "Parameter telah diubah.", "modifySettings": "Parameter telah diubah.",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "管理者の認証情報を変更するには、アプリケーションからコードを入力してください。", "twoFactorModalChangeCredentialsStep": "管理者の認証情報を変更するには、アプリケーションからコードを入力してください。",
"twoFactorModalSetSuccess": "二要素認証が正常に設定されました", "twoFactorModalSetSuccess": "二要素認証が正常に設定されました",
"twoFactorModalDeleteSuccess": "二要素認証が正常に削除されました", "twoFactorModalDeleteSuccess": "二要素認証が正常に削除されました",
"twoFactorModalError": "コードが間違っています" "twoFactorModalError": "コードが間違っています",
"show": "表示",
"hide": "非表示",
"apiTokenNew": "新規トークン",
"apiTokenName": "名前",
"apiTokenNamePlaceholder": "例: central-panel-a",
"apiTokenNameRequired": "名前は必須です",
"apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
"apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
}, },
"toasts": { "toasts": {
"modifySettings": "パラメーターが変更されました。", "modifySettings": "パラメーターが変更されました。",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Insira o código do aplicativo para alterar as credenciais do administrador.", "twoFactorModalChangeCredentialsStep": "Insira o código do aplicativo para alterar as credenciais do administrador.",
"twoFactorModalSetSuccess": "A autenticação de dois fatores foi estabelecida com sucesso", "twoFactorModalSetSuccess": "A autenticação de dois fatores foi estabelecida com sucesso",
"twoFactorModalDeleteSuccess": "A autenticação de dois fatores foi excluída com sucesso", "twoFactorModalDeleteSuccess": "A autenticação de dois fatores foi excluída com sucesso",
"twoFactorModalError": "Código incorreto" "twoFactorModalError": "Código incorreto",
"show": "Mostrar",
"hide": "Ocultar",
"apiTokenNew": "Novo token",
"apiTokenName": "Nome",
"apiTokenNamePlaceholder": "ex.: central-panel-a",
"apiTokenNameRequired": "O nome é obrigatório",
"apiTokenEmpty": "Nenhum token ainda — crie um para autenticar bots ou painéis remotos.",
"apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente."
}, },
"toasts": { "toasts": {
"modifySettings": "Os parâmetros foram alterados.", "modifySettings": "Os parâmetros foram alterados.",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Введите код из приложения, чтобы изменить учетные данные администратора.", "twoFactorModalChangeCredentialsStep": "Введите код из приложения, чтобы изменить учетные данные администратора.",
"twoFactorModalSetSuccess": "Двухфакторная аутентификация была успешно установлена", "twoFactorModalSetSuccess": "Двухфакторная аутентификация была успешно установлена",
"twoFactorModalDeleteSuccess": "Двухфакторная аутентификация была успешно удалена", "twoFactorModalDeleteSuccess": "Двухфакторная аутентификация была успешно удалена",
"twoFactorModalError": "Неверный код" "twoFactorModalError": "Неверный код",
"show": "Показать",
"hide": "Скрыть",
"apiTokenNew": "Новый токен",
"apiTokenName": "Имя",
"apiTokenNamePlaceholder": "например, central-panel-a",
"apiTokenNameRequired": "Имя обязательно",
"apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
"apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
}, },
"toasts": { "toasts": {
"modifySettings": "Настройки изменены", "modifySettings": "Настройки изменены",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Yönetici kimlik bilgilerini değiştirmek için uygulamadaki kodu girin.", "twoFactorModalChangeCredentialsStep": "Yönetici kimlik bilgilerini değiştirmek için uygulamadaki kodu girin.",
"twoFactorModalSetSuccess": "İki faktörlü kimlik doğrulama başarıyla kuruldu", "twoFactorModalSetSuccess": "İki faktörlü kimlik doğrulama başarıyla kuruldu",
"twoFactorModalDeleteSuccess": "İki faktörlü kimlik doğrulama başarıyla silindi", "twoFactorModalDeleteSuccess": "İki faktörlü kimlik doğrulama başarıyla silindi",
"twoFactorModalError": "Yanlış kod" "twoFactorModalError": "Yanlış kod",
"show": "Göster",
"hide": "Gizle",
"apiTokenNew": "Yeni token",
"apiTokenName": "Ad",
"apiTokenNamePlaceholder": "örn. central-panel-a",
"apiTokenNameRequired": "Ad zorunludur",
"apiTokenEmpty": "Henüz token yok — bot veya uzak panelleri doğrulamak için bir tane oluşturun.",
"apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder."
}, },
"toasts": { "toasts": {
"modifySettings": "Parametreler değiştirildi.", "modifySettings": "Parametreler değiştirildi.",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Введіть код з додатку, щоб змінити облікові дані адміністратора.", "twoFactorModalChangeCredentialsStep": "Введіть код з додатку, щоб змінити облікові дані адміністратора.",
"twoFactorModalSetSuccess": "Двофакторна аутентифікація була успішно встановлена", "twoFactorModalSetSuccess": "Двофакторна аутентифікація була успішно встановлена",
"twoFactorModalDeleteSuccess": "Двофакторна аутентифікація була успішно видалена", "twoFactorModalDeleteSuccess": "Двофакторна аутентифікація була успішно видалена",
"twoFactorModalError": "Невірний код" "twoFactorModalError": "Невірний код",
"show": "Показати",
"hide": "Сховати",
"apiTokenNew": "Новий токен",
"apiTokenName": "Назва",
"apiTokenNamePlaceholder": "наприклад, central-panel-a",
"apiTokenNameRequired": "Назва обов'язкова",
"apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
"apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
}, },
"toasts": { "toasts": {
"modifySettings": "Параметри було змінено.", "modifySettings": "Параметри було змінено.",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Nhập mã từ ứng dụng để thay đổi thông tin xác thực quản trị viên.", "twoFactorModalChangeCredentialsStep": "Nhập mã từ ứng dụng để thay đổi thông tin xác thực quản trị viên.",
"twoFactorModalSetSuccess": "Xác thực hai yếu tố đã được thiết lập thành công", "twoFactorModalSetSuccess": "Xác thực hai yếu tố đã được thiết lập thành công",
"twoFactorModalDeleteSuccess": "Xác thực hai yếu tố đã được xóa thành công", "twoFactorModalDeleteSuccess": "Xác thực hai yếu tố đã được xóa thành công",
"twoFactorModalError": "Mã sai" "twoFactorModalError": "Mã sai",
"show": "Hiển thị",
"hide": "Ẩn",
"apiTokenNew": "Token mới",
"apiTokenName": "Tên",
"apiTokenNamePlaceholder": "ví dụ: central-panel-a",
"apiTokenNameRequired": "Tên là bắt buộc",
"apiTokenEmpty": "Chưa có token nào — tạo một token để xác thực bot hoặc panel từ xa.",
"apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức."
}, },
"toasts": { "toasts": {
"modifySettings": "Các tham số đã được thay đổi.", "modifySettings": "Các tham số đã được thay đổi.",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "输入应用程序中的代码以更改管理员凭据。", "twoFactorModalChangeCredentialsStep": "输入应用程序中的代码以更改管理员凭据。",
"twoFactorModalSetSuccess": "双因素认证已成功建立", "twoFactorModalSetSuccess": "双因素认证已成功建立",
"twoFactorModalDeleteSuccess": "双因素认证已成功删除", "twoFactorModalDeleteSuccess": "双因素认证已成功删除",
"twoFactorModalError": "验证码错误" "twoFactorModalError": "验证码错误",
"show": "显示",
"hide": "隐藏",
"apiTokenNew": "新建令牌",
"apiTokenName": "名称",
"apiTokenNamePlaceholder": "例如 central-panel-a",
"apiTokenNameRequired": "名称必填",
"apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
"apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。"
}, },
"toasts": { "toasts": {
"modifySettings": "参数已更改。", "modifySettings": "参数已更改。",

View file

@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "輸入應用程式中的代碼以更改管理員憑證。", "twoFactorModalChangeCredentialsStep": "輸入應用程式中的代碼以更改管理員憑證。",
"twoFactorModalSetSuccess": "雙重身份驗證已成功建立", "twoFactorModalSetSuccess": "雙重身份驗證已成功建立",
"twoFactorModalDeleteSuccess": "雙重身份驗證已成功刪除", "twoFactorModalDeleteSuccess": "雙重身份驗證已成功刪除",
"twoFactorModalError": "驗證碼錯誤" "twoFactorModalError": "驗證碼錯誤",
"show": "顯示",
"hide": "隱藏",
"apiTokenNew": "新增令牌",
"apiTokenName": "名稱",
"apiTokenNamePlaceholder": "例如 central-panel-a",
"apiTokenNameRequired": "名稱必填",
"apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
"apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。"
}, },
"toasts": { "toasts": {
"modifySettings": "參數已更改。", "modifySettings": "參數已更改。",