Parcourir la source

feat(ui): tag settings that sit at their shipped default value (#6128)

* feat(ui): tag settings that sit at their shipped default value

A field showing 2096 reads identically whether the install never set
it or the operator saved 2096 — newcomers cannot tell which knobs
they have touched, and after the cleared-port fix (#6121) a port can
never visually return to an unset state. Add a small grey tag next to
numeric settings whose current value equals the shipped default.

The tag deliberately compares values, not provenance: a stored 2096
and a fallback 2096 behave identically, so they read identically, and
the tag reacts live as the user types.

The backing endpoint filters defaultValueMap through the AllSetting
field set, so per-install material (secret, panelGuid, node mTLS
keys) and redacted credential fields never leave the server; a test
pins that.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(ui): keep the default tag out of the accessible name, pin the defaults contract

From review, in order of severity:

The badge was rendered inside the element whose id feeds the
control's aria-labelledby, so a visible tag changed every field's
accessible name ('Panel Port Default'). The title text now carries
the id on its own span and the badge sits beside it.

The same default values live in three places: the Go defaultValueMap,
the frontend AllSetting class, and the tag's verdict. A new contract
test parses the Go map's string literals and asserts every shared key
matches the AllSetting class default through the tag's own
comparison — and on first run it caught two real drifts
(tgEnabledEvents / smtpEnabledEvents defaulted to '' in the class but
'login.attempt,cpu.high' on the server), now aligned.

matchesFactoryDefault no longer coerces blank or unparsable defaults
(Number('') is 0; a junk string is not false). The Go tests are
table-driven t.Run subtests and gained the structural invariant:
every returned key is an AllSetting json tag outside the credential
deny-list. The service doc comment now describes the projection
mechanism instead of overclaiming; the i18n key is re-indented and
placed at the head of pages.settings in all 13 locales; the fetch
falls back to {} when validation fails; and smtpPort gets the tag so
plain numeric settings-list fields are covered uniformly.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
PathGao il y a 11 heures
Parent
commit
87ebcc7a6f
31 fichiers modifiés avec 385 ajouts et 20 suppressions
  1. 30 0
      frontend/public/openapi.json
  2. 22 0
      frontend/src/api/queries/useFactoryDefaults.ts
  3. 1 0
      frontend/src/api/queryKeys.ts
  4. 37 0
      frontend/src/components/ui/DefaultSettingTag.tsx
  5. 8 1
      frontend/src/components/ui/SettingListItem.tsx
  6. 1 0
      frontend/src/components/ui/index.ts
  7. 2 2
      frontend/src/models/setting.ts
  8. 5 0
      frontend/src/pages/api-docs/endpoints.ts
  9. 2 2
      frontend/src/pages/settings/EmailTab.tsx
  10. 10 10
      frontend/src/pages/settings/GeneralTab.tsx
  11. 3 3
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  12. 4 0
      frontend/src/schemas/setting.ts
  13. 64 0
      frontend/src/test/default-setting-tag.test.tsx
  14. 53 0
      frontend/src/test/factory-defaults-contract.test.ts
  15. 12 2
      frontend/src/test/test-utils.tsx
  16. 5 0
      internal/web/controller/setting.go
  17. 30 0
      internal/web/service/setting.go
  18. 83 0
      internal/web/service/setting_factory_defaults_test.go
  19. 1 0
      internal/web/translation/ar-EG.json
  20. 1 0
      internal/web/translation/en-US.json
  21. 1 0
      internal/web/translation/es-ES.json
  22. 1 0
      internal/web/translation/fa-IR.json
  23. 1 0
      internal/web/translation/id-ID.json
  24. 1 0
      internal/web/translation/ja-JP.json
  25. 1 0
      internal/web/translation/pt-BR.json
  26. 1 0
      internal/web/translation/ru-RU.json
  27. 1 0
      internal/web/translation/tr-TR.json
  28. 1 0
      internal/web/translation/uk-UA.json
  29. 1 0
      internal/web/translation/vi-VN.json
  30. 1 0
      internal/web/translation/zh-CN.json
  31. 1 0
      internal/web/translation/zh-TW.json

+ 30 - 0
frontend/public/openapi.json

@@ -9769,6 +9769,36 @@
         }
       }
     },
+    "/panel/api/setting/factoryDefaults": {
+      "post": {
+        "tags": [
+          "Settings"
+        ],
+        "summary": "Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.",
+        "operationId": "post_panel_api_setting_factoryDefaults",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/setting/update": {
       "post": {
         "tags": [

+ 22 - 0
frontend/src/api/queries/useFactoryDefaults.ts

@@ -0,0 +1,22 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { HttpUtil } from '@/utils';
+import { parseMsg } from '@/utils/zodValidate';
+import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting';
+import { keys } from '@/api/queryKeys';
+
+async function fetchFactoryDefaults(): Promise<FactoryDefaults> {
+  const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults');
+  const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults');
+  const parsed = FactoryDefaultsSchema.safeParse(validated.obj);
+  return parsed.success ? parsed.data : {};
+}
+
+export function useFactoryDefaults() {
+  return useQuery({
+    queryKey: keys.settings.factoryDefaults(),
+    queryFn: fetchFactoryDefaults,
+    staleTime: Infinity,
+  });
+}

+ 1 - 0
frontend/src/api/queryKeys.ts

@@ -17,6 +17,7 @@ export const keys = {
     root: () => ['settings'] as const,
     all: () => ['settings', 'all'] as const,
     defaults: () => ['settings', 'defaults'] as const,
+    factoryDefaults: () => ['settings', 'factoryDefaults'] as const,
   },
   inbounds: {
     root: () => ['inbounds'] as const,

+ 37 - 0
frontend/src/components/ui/DefaultSettingTag.tsx

@@ -0,0 +1,37 @@
+import { Tag } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults';
+
+/**
+ * Value semantics on purpose: the tag answers "does this equal the shipped
+ * default?", not "has the user ever saved this key?" — a stored 2096 and a
+ * fallback 2096 behave identically, so they read identically.
+ */
+export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean {
+  if (factoryDefault === undefined) return false;
+  if (typeof current === 'number') {
+    const parsed = Number(factoryDefault);
+    return factoryDefault.trim() !== '' && !Number.isNaN(parsed) && parsed === current;
+  }
+  if (typeof current === 'boolean') {
+    if (factoryDefault !== 'true' && factoryDefault !== 'false') return false;
+    return (factoryDefault === 'true') === current;
+  }
+  if (typeof current === 'string') return factoryDefault === current;
+  return false;
+}
+
+interface DefaultSettingTagProps {
+  settingKey: string;
+  value: unknown;
+}
+
+export default function DefaultSettingTag({ settingKey, value }: DefaultSettingTagProps) {
+  const { t } = useTranslation();
+  const defaults = useFactoryDefaults();
+
+  if (!matchesFactoryDefault(value, defaults.data?.[settingKey])) return null;
+
+  return <Tag style={{ marginLeft: 8 }}>{t('pages.settings.defaultTag')}</Tag>;
+}

+ 8 - 1
frontend/src/components/ui/SettingListItem.tsx

@@ -5,6 +5,7 @@ import './SettingListItem.css';
 interface SettingListItemProps {
   paddings?: 'small' | 'default';
   title?: ReactNode;
+  badge?: ReactNode;
   description?: ReactNode;
   children?: ReactNode;
   control?: ReactNode;
@@ -13,6 +14,7 @@ interface SettingListItemProps {
 export default function SettingListItem({
   paddings = 'default',
   title,
+  badge,
   description,
   children,
   control,
@@ -28,7 +30,12 @@ export default function SettingListItem({
       <Row gutter={[8, 16]} style={{ width: '100%' }}>
         <Col xs={24} lg={12}>
           <div className="setting-list-meta">
-            {title && <div className="setting-list-title" id={titleId}>{title}</div>}
+            {title && (
+              <div className="setting-list-title">
+                <span id={titleId}>{title}</span>
+                {badge}
+              </div>
+            )}
             {description && <div className="setting-list-description">{description}</div>}
           </div>
         </Col>

+ 1 - 0
frontend/src/components/ui/index.ts

@@ -1,3 +1,4 @@
 export { default as InputAddon } from './InputAddon';
 export { default as InfinityIcon } from './InfinityIcon';
 export { default as SettingListItem } from './SettingListItem';
+export { default as DefaultSettingTag } from './DefaultSettingTag';

+ 2 - 2
frontend/src/models/setting.ts

@@ -91,7 +91,7 @@ export class AllSetting {
   ldapDefaultTotalGB = 0;
   ldapDefaultExpiryDays = 0;
   ldapDefaultLimitIP = 0;
-  tgEnabledEvents = '';
+  tgEnabledEvents = 'login.attempt,cpu.high';
   smtpEnable = false;
   smtpHost = '';
   smtpPort = 587;
@@ -101,7 +101,7 @@ export class AllSetting {
   smtpFromName = '';
   smtpTo = '';
   smtpEncryptionType = 'starttls';
-  smtpEnabledEvents = '';
+  smtpEnabledEvents = 'login.attempt,cpu.high';
   smtpCpu = 80;
   smtpMemory = 80;
   outboundDownThreshold = 3;

+ 5 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -1169,6 +1169,11 @@ export const sections: readonly Section[] = [
         path: '/panel/api/setting/defaultSettings',
         summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/setting/factoryDefaults',
+        summary: 'Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.',
+      },
       {
         method: 'POST',
         path: '/panel/api/setting/update',

+ 2 - 2
frontend/src/pages/settings/EmailTab.tsx

@@ -5,7 +5,7 @@ import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
 import { HttpUtil } from '@/utils';
 import { onNumber } from '@/utils/onNumber';
 import type { AllSetting } from '@/models/setting';
-import { SettingListItem } from '@/components/ui';
+import { DefaultSettingTag, SettingListItem } from '@/components/ui';
 import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
@@ -63,7 +63,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
                 onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
             </SettingListItem>
 
-            <SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.smtpPort')} badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />} description={t('pages.settings.smtpPortDesc')}>
               <InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ smtpPort: v }))} />
             </SettingListItem>

+ 10 - 10
frontend/src/pages/settings/GeneralTab.tsx

@@ -18,7 +18,7 @@ import {
 import type { AllSetting } from '@/models/setting';
 import { HttpUtil, LanguageManager } from '@/utils';
 import { onNumber } from '@/utils/onNumber';
-import { SettingListItem } from '@/components/ui';
+import { DefaultSettingTag, SettingListItem } from '@/components/ui';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath } from './uriPath';
@@ -169,7 +169,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
               <Input value={allSetting.webDomain} onChange={(e) => updateSetting({ webDomain: e.target.value })} />
             </SettingListItem>
 
-            <SettingListItem paddings="small" title={t('pages.settings.panelPort')} description={t('pages.settings.panelPortDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.panelPort')} badge={<DefaultSettingTag settingKey="webPort" value={allSetting.webPort} />} description={t('pages.settings.panelPortDesc')}>
               <InputNumber value={allSetting.webPort} min={1} max={65535} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ webPort: v }))} />
             </SettingListItem>
@@ -178,7 +178,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
               <Input value={allSetting.webBasePath} onChange={(e) => updateSetting({ webBasePath: sanitizePath(e.target.value) })} />
             </SettingListItem>
 
-            <SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} description={t('pages.settings.sessionMaxAgeDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.sessionMaxAge')} badge={<DefaultSettingTag settingKey="sessionMaxAge" value={allSetting.sessionMaxAge} />} description={t('pages.settings.sessionMaxAgeDesc')}>
               <InputNumber value={allSetting.sessionMaxAge} min={60} max={525600} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ sessionMaxAge: v }))} />
             </SettingListItem>
@@ -207,7 +207,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
               />
             </SettingListItem>
 
-            <SettingListItem paddings="small" title={t('pages.settings.pageSize')} description={t('pages.settings.pageSizeDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.pageSize')} badge={<DefaultSettingTag settingKey="pageSize" value={allSetting.pageSize} />} description={t('pages.settings.pageSizeDesc')}>
               <InputNumber value={allSetting.pageSize} min={0} max={1000} step={5} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ pageSize: v }))} />
             </SettingListItem>
@@ -233,11 +233,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
         label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
         children: (
           <>
-            <SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />} description={t('pages.settings.expireTimeDiffDesc')}>
               <InputNumber value={allSetting.expireDiff} min={0} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ expireDiff: v }))} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} description={t('pages.settings.trafficDiffDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.trafficDiff')} badge={<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />} description={t('pages.settings.trafficDiffDesc')}>
               <InputNumber value={allSetting.trafficDiff} min={0} max={100} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ trafficDiff: v }))} />
             </SettingListItem>
@@ -307,7 +307,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             <SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
               <Input value={allSetting.ldapHost} onChange={(e) => updateSetting({ ldapHost: e.target.value })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.ldap.port')}>
+            <SettingListItem paddings="small" title={t('pages.settings.ldap.port')} badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}>
               <InputNumber value={allSetting.ldapPort} min={1} max={65535} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ ldapPort: v }))} />
             </SettingListItem>
@@ -386,15 +386,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
             <SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
               <Switch checked={allSetting.ldapAutoDelete} onChange={(v) => updateSetting({ ldapAutoDelete: v })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')}>
+            <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultTotalGb')} badge={<DefaultSettingTag settingKey="ldapDefaultTotalGB" value={allSetting.ldapDefaultTotalGB} />}>
               <InputNumber value={allSetting.ldapDefaultTotalGB} min={0} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')}>
+            <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultExpiryDays')} badge={<DefaultSettingTag settingKey="ldapDefaultExpiryDays" value={allSetting.ldapDefaultExpiryDays} />}>
               <InputNumber value={allSetting.ldapDefaultExpiryDays} min={0} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')}>
+            <SettingListItem paddings="small" title={t('pages.settings.ldap.defaultIpLimit')} badge={<DefaultSettingTag settingKey="ldapDefaultLimitIP" value={allSetting.ldapDefaultLimitIP} />}>
               <InputNumber value={allSetting.ldapDefaultLimitIP} min={0} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))} />
             </SettingListItem>

+ 3 - 3
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router';
 import type { AllSetting } from '@/models/setting';
 import { onNumber } from '@/utils/onNumber';
-import { SettingListItem } from '@/components/ui';
+import { DefaultSettingTag, SettingListItem } from '@/components/ui';
 import { RemarkTemplateField } from '@/components/form';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
@@ -56,7 +56,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
             <SettingListItem paddings="small" title={t('pages.settings.subDomain')} description={t('pages.settings.subDomainDesc')}>
               <Input value={allSetting.subDomain} onChange={(e) => updateSetting({ subDomain: e.target.value })} />
             </SettingListItem>
-            <SettingListItem paddings="small" title={t('pages.settings.subPort')} description={t('pages.settings.subPortDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.subPort')} badge={<DefaultSettingTag settingKey="subPort" value={allSetting.subPort} />} description={t('pages.settings.subPortDesc')}>
               <InputNumber value={allSetting.subPort} min={1} max={65535} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ subPort: v }))} />
             </SettingListItem>
@@ -105,7 +105,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
               />
             </SettingListItem>
 
-            <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>
+            <SettingListItem paddings="small" title={t('pages.settings.subUpdates')} badge={<DefaultSettingTag settingKey="subUpdates" value={allSetting.subUpdates} />} description={t('pages.settings.subUpdatesDesc')}>
               <InputNumber value={allSetting.subUpdates} min={0} max={525600} style={{ width: '100%' }}
                 onChange={onNumber((v) => updateSetting({ subUpdates: v }))} />
             </SettingListItem>

+ 4 - 0
frontend/src/schemas/setting.ts

@@ -103,3 +103,7 @@ export const AllSettingSchema = z.object({
 }).loose();
 
 export type AllSettingInput = z.infer<typeof AllSettingSchema>;
+
+export const FactoryDefaultsSchema = z.record(z.string(), z.string());
+
+export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;

+ 64 - 0
frontend/src/test/default-setting-tag.test.tsx

@@ -0,0 +1,64 @@
+import { screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { keys } from '@/api/queryKeys';
+import DefaultSettingTag, { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag';
+import { makeTestQueryClient, renderWithProviders } from './test-utils';
+
+function clientWithDefaults(defaults: Record<string, string>) {
+  const queryClient = makeTestQueryClient();
+  queryClient.setQueryData(keys.settings.factoryDefaults(), defaults);
+  return queryClient;
+}
+
+describe('matchesFactoryDefault', () => {
+  it('compares by value with type-aware coercion', () => {
+    expect(matchesFactoryDefault(2096, '2096')).toBe(true);
+    expect(matchesFactoryDefault(8443, '2096')).toBe(false);
+    expect(matchesFactoryDefault(true, 'true')).toBe(true);
+    expect(matchesFactoryDefault(false, 'true')).toBe(false);
+    expect(matchesFactoryDefault('/sub/', '/sub/')).toBe(true);
+    expect(matchesFactoryDefault('/other/', '/sub/')).toBe(false);
+  });
+
+  it('never matches when the key has no shipped default', () => {
+    expect(matchesFactoryDefault(2096, undefined)).toBe(false);
+  });
+
+  it('rejects blank or unparsable defaults instead of coercing them', () => {
+    expect(matchesFactoryDefault(0, '')).toBe(false);
+    expect(matchesFactoryDefault(0, '  ')).toBe(false);
+    expect(matchesFactoryDefault(0, 'none')).toBe(false);
+    expect(matchesFactoryDefault(false, '')).toBe(false);
+    expect(matchesFactoryDefault(false, 'no')).toBe(false);
+  });
+});
+
+describe('DefaultSettingTag', () => {
+  it('shows the tag when the current value equals the shipped default, however it got there', () => {
+    renderWithProviders(
+      <DefaultSettingTag settingKey="subPort" value={2096} />,
+      { queryClient: clientWithDefaults({ subPort: '2096' }) },
+    );
+
+    expect(screen.getByText('Default')).toBeDefined();
+  });
+
+  it('renders nothing when the value differs from the default', () => {
+    renderWithProviders(
+      <DefaultSettingTag settingKey="subPort" value={8443} />,
+      { queryClient: clientWithDefaults({ subPort: '2096' }) },
+    );
+
+    expect(screen.queryByText('Default')).toBeNull();
+  });
+
+  it('renders nothing while defaults are unknown', () => {
+    renderWithProviders(
+      <DefaultSettingTag settingKey="subPort" value={2096} />,
+      { queryClient: makeTestQueryClient() },
+    );
+
+    expect(screen.queryByText('Default')).toBeNull();
+  });
+});

+ 53 - 0
frontend/src/test/factory-defaults-contract.test.ts

@@ -0,0 +1,53 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+import { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag';
+import { AllSetting } from '@/models/setting';
+
+/*
+ * Contract test between the three homes of a setting's default value: the Go
+ * defaultValueMap (authoritative, served by /setting/factoryDefaults), the
+ * frontend AllSetting class defaults (what the form shows when the API omits
+ * a value), and the tag's own comparison. If someone bumps a default on one
+ * side only, the Default tag would start calling a different value "Default"
+ * than the one the form displays — this test fails instead.
+ */
+
+function goDefaultLiterals(): Record<string, string> {
+  const source = readFileSync(
+    resolve(process.cwd(), '..', 'internal', 'web', 'service', 'setting.go'),
+    'utf8',
+  );
+  const start = source.indexOf('var defaultValueMap = map[string]string{');
+  const end = source.indexOf('\n}', start);
+  const block = source.slice(start, end);
+  const literals: Record<string, string> = {};
+  for (const match of block.matchAll(/"([A-Za-z0-9]+)":\s+"((?:[^"\\]|\\.)*)"\s*,/g)) {
+    literals[match[1]] = JSON.parse(`"${match[2]}"`);
+  }
+  return literals;
+}
+
+describe('factory defaults contract', () => {
+  const goDefaults = goDefaultLiterals();
+  const frontend = new AllSetting() as unknown as Record<string, unknown>;
+  const sharedKeys = Object.keys(goDefaults).filter((key) => {
+    const value = frontend[key];
+    return typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string';
+  });
+
+  it('parses a plausible slice of the Go map', () => {
+    expect(goDefaults.webPort).toBe('2053');
+    expect(goDefaults.subPort).toBe('2096');
+    expect(sharedKeys.length).toBeGreaterThan(20);
+  });
+
+  it.each(sharedKeys)('frontend default for %s matches the shipped default', (key) => {
+    expect(
+      matchesFactoryDefault(frontend[key], goDefaults[key]),
+      `AllSetting.${key} = ${JSON.stringify(frontend[key])} vs defaultValueMap ${JSON.stringify(goDefaults[key])}`,
+    ).toBe(true);
+  });
+});

+ 12 - 2
frontend/src/test/test-utils.tsx

@@ -1,10 +1,20 @@
 import type { ReactElement } from 'react';
 import { render, fireEvent } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 
 import { ThemeProvider } from '@/hooks/useTheme';
 
-export function renderWithProviders(ui: ReactElement) {
-  return render(<ThemeProvider>{ui}</ThemeProvider>);
+export function makeTestQueryClient() {
+  return new QueryClient({ defaultOptions: { queries: { retry: false } } });
+}
+
+export function renderWithProviders(ui: ReactElement, options?: { queryClient?: QueryClient }) {
+  const queryClient = options?.queryClient ?? makeTestQueryClient();
+  return render(
+    <QueryClientProvider client={queryClient}>
+      <ThemeProvider>{ui}</ThemeProvider>
+    </QueryClientProvider>,
+  );
 }
 
 export function fieldLabels(): string[] {

+ 5 - 0
internal/web/controller/setting.go

@@ -65,6 +65,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
 
 	g.POST("/all", a.getAllSetting)
 	g.POST("/defaultSettings", a.getDefaultSettings)
+	g.POST("/factoryDefaults", a.getFactoryDefaults)
 	g.POST("/update", a.updateSetting)
 	g.POST("/validateRegex", a.validateRegex)
 	g.POST("/updateUser", a.updateUser)
@@ -112,6 +113,10 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
 	jsonObj(c, result, nil)
 }
 
+func (a *SettingController) getFactoryDefaults(c *gin.Context) {
+	jsonObj(c, a.settingService.GetFactoryDefaults(), nil)
+}
+
 // updateSetting updates all settings with the provided data.
 func (a *SettingController) updateSetting(c *gin.Context) {
 	form, ok := middleware.BindAndValidate[updateSettingForm](c)

+ 30 - 0
internal/web/service/setting.go

@@ -1445,3 +1445,33 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
 
 	return result, nil
 }
+
+var factoryDefaultSecretKeys = map[string]bool{
+	"tgBotToken":     true,
+	"twoFactorToken": true,
+	"ldapPassword":   true,
+	"smtpPassword":   true,
+}
+
+/*
+GetFactoryDefaults returns the shipped default value per setting, keyed by
+the AllSetting json field name. Unlike GetDefaultSettings (which reports
+current effective values), this is defaultValueMap projected through the
+AllSetting field set: only keys that exist as an AllSetting json tag are
+returned, minus the credential fields in factoryDefaultSecretKeys. Keys
+with no AllSetting field (secret, panelGuid, the node mTLS material,
+xrayTemplateConfig) are excluded structurally rather than by deny-list.
+*/
+func (s *SettingService) GetFactoryDefaults() map[string]string {
+	result := make(map[string]string)
+	for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
+		key := field.Tag.Get("json")
+		if key == "" || factoryDefaultSecretKeys[key] {
+			continue
+		}
+		if value, ok := defaultValueMap[key]; ok {
+			result[key] = value
+		}
+	}
+	return result
+}

+ 83 - 0
internal/web/service/setting_factory_defaults_test.go

@@ -0,0 +1,83 @@
+package service
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+)
+
+func allSettingJSONTags(t *testing.T) map[string]bool {
+	t.Helper()
+	tags := make(map[string]bool)
+	for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
+		if tag := field.Tag.Get("json"); tag != "" {
+			tags[tag] = true
+		}
+	}
+	return tags
+}
+
+func TestGetFactoryDefaultsExposesBrowserSafeKeys(t *testing.T) {
+	defaults := (&SettingService{}).GetFactoryDefaults()
+
+	tests := []struct {
+		key  string
+		want string
+	}{
+		{key: "webPort", want: "2053"},
+		{key: "subPort", want: "2096"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.key, func(t *testing.T) {
+			got, ok := defaults[tc.key]
+			if !ok {
+				t.Fatalf("expected key %q in factory defaults", tc.key)
+			}
+			if got != tc.want {
+				t.Errorf("factory default for %q = %q, want %q", tc.key, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
+	defaults := (&SettingService{}).GetFactoryDefaults()
+
+	for _, key := range []string{
+		"secret",
+		"panelGuid",
+		"nodeMtlsCaCertPem",
+		"nodeMtlsCaKeyPem",
+		"nodeMtlsClientCertPem",
+		"nodeMtlsClientKeyPem",
+		"xrayTemplateConfig",
+		"tgBotToken",
+		"twoFactorToken",
+		"ldapPassword",
+		"smtpPassword",
+	} {
+		t.Run(key, func(t *testing.T) {
+			if _, ok := defaults[key]; ok {
+				t.Errorf("factory defaults must not expose %q", key)
+			}
+		})
+	}
+}
+
+func TestGetFactoryDefaultsInvariant(t *testing.T) {
+	defaults := (&SettingService{}).GetFactoryDefaults()
+	tags := allSettingJSONTags(t)
+
+	for key := range defaults {
+		t.Run(key, func(t *testing.T) {
+			if !tags[key] {
+				t.Errorf("key %q is not an entity.AllSetting json tag", key)
+			}
+			if factoryDefaultSecretKeys[key] {
+				t.Errorf("key %q is in the credential deny-list and must not be returned", key)
+			}
+		})
+	}
+}

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "تعذّر جلب الشهادة"
     },
     "settings": {
+      "defaultTag": "افتراضي",
       "title": "إعدادات البانل",
       "save": "حفظ",
       "infoDesc": "كل تغيير هتعمله هنا لازم يتخزن. ياريت تعيد تشغيل البانل عشان التعديلات تتفعل.",

+ 1 - 0
internal/web/translation/en-US.json

@@ -1115,6 +1115,7 @@
       "pinFetchFailed": "Could not fetch the certificate"
     },
     "settings": {
+      "defaultTag": "Default",
       "title": "Panel Settings",
       "save": "Save",
       "infoDesc": "Every change made here needs to be saved. Please restart the panel to apply changes.",

+ 1 - 0
internal/web/translation/es-ES.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "No se pudo obtener el certificado"
     },
     "settings": {
+      "defaultTag": "Predeterminado",
       "title": "Configuraciones",
       "save": "Guardar",
       "infoDesc": "Cada cambio realizado aquí debe ser guardado. Por favor, reinicie el panel para aplicar los cambios.",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "دریافت گواهی ممکن نشد"
     },
     "settings": {
+      "defaultTag": "پیش‌فرض",
       "title": "تنظیمات پنل",
       "save": "ذخیره",
       "infoDesc": "برای اعمال تغییرات در این بخش باید پس از ذخیره کردن، پنل را ریستارت کنید",

+ 1 - 0
internal/web/translation/id-ID.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "Tidak dapat mengambil sertifikat"
     },
     "settings": {
+      "defaultTag": "Bawaan",
       "title": "Pengaturan Panel",
       "save": "Simpan",
       "infoDesc": "Setiap perubahan yang dibuat di sini perlu disimpan. Harap restart panel untuk menerapkan perubahan.",

+ 1 - 0
internal/web/translation/ja-JP.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "証明書を取得できませんでした"
     },
     "settings": {
+      "defaultTag": "デフォルト",
       "title": "パネル設定",
       "save": "保存",
       "infoDesc": "ここでのすべての変更は、保存してパネルを再起動する必要があります",

+ 1 - 0
internal/web/translation/pt-BR.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "Não foi possível obter o certificado"
     },
     "settings": {
+      "defaultTag": "Padrão",
       "title": "Configurações do Painel",
       "save": "Salvar",
       "infoDesc": "Toda alteração feita aqui precisa ser salva. Reinicie o painel para aplicar as alterações.",

+ 1 - 0
internal/web/translation/ru-RU.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "Не удалось получить сертификат"
     },
     "settings": {
+      "defaultTag": "По умолчанию",
       "title": "Настройки",
       "save": "Сохранить",
       "infoDesc": "Сохраните изменения и перезапустите панель для их применения.",

+ 1 - 0
internal/web/translation/tr-TR.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "Sertifika alınamadı"
     },
     "settings": {
+      "defaultTag": "Varsayılan",
       "title": "Panel Ayarları",
       "save": "Kaydet",
       "infoDesc": "Burada yapılan her değişikliğin kaydedilmesi gerekir. Değişikliklerin uygulanması için paneli yeniden başlatın.",

+ 1 - 0
internal/web/translation/uk-UA.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "Не вдалося отримати сертифікат"
     },
     "settings": {
+      "defaultTag": "Типово",
       "title": "Параметри панелі",
       "save": "Зберегти",
       "infoDesc": "Кожна внесена тут зміна повинна бути збережена. Перезапустіть панель, щоб застосувати зміни.",

+ 1 - 0
internal/web/translation/vi-VN.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "Không thể lấy chứng chỉ"
     },
     "settings": {
+      "defaultTag": "Mặc định",
       "title": "Cài đặt",
       "save": "Lưu",
       "infoDesc": "Mọi thay đổi được thực hiện ở đây cần phải được lưu. Vui lòng khởi động lại bảng điều khiển để áp dụng các thay đổi.",

+ 1 - 0
internal/web/translation/zh-CN.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "无法获取证书"
     },
     "settings": {
+      "defaultTag": "默认",
       "title": "面板设置",
       "save": "保存",
       "infoDesc": "此处的所有更改都需要保存并重启面板才能生效",

+ 1 - 0
internal/web/translation/zh-TW.json

@@ -998,6 +998,7 @@
       "pinFetchFailed": "無法取得憑證"
     },
     "settings": {
+      "defaultTag": "預設",
       "title": "面板設定",
       "save": "儲存",
       "infoDesc": "此處的所有更改都需要儲存並重啟面板才能生效",