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:
+62
-24
@@ -40,6 +40,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 {
|
||||||
@@ -86,43 +87,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
|
||||||
|
|||||||
@@ -88,6 +88,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
|
||||||
|
|||||||
@@ -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 <token></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 <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>
|
</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);
|
||||||
|
|||||||
@@ -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 <token></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.',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-17
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+5
-45
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": "تم تغيير المعلمات.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "پارامترها تغییر کردهاند.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "パラメーターが変更されました。",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "Настройки изменены",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "Параметри було змінено.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "参数已更改。",
|
||||||
|
|||||||
@@ -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": "參數已更改。",
|
||||||
|
|||||||
Reference in New Issue
Block a user