Compare commits

...

41 Commits

Author SHA1 Message Date
MHSanaei
f3a57d4c57 3.4.2 2026-06-29 20:28:08 +02:00
MHSanaei
86813758cc fix(node): stop the offline-sync toast firing on saves to online nodes
IsNodePending fed the user-facing "saved locally, node offline, will
sync on reconnect" toast off three conditions, one of which was the
node's config_dirty flag. But every node-backed client/inbound edit
marks the node dirty unconditionally inside its write transaction — it
is the reconcile self-heal marker, set even for edits pushed live to a
healthy online node. The controller reads that freshly-set flag right
after the save, so the warning fired on every save to a node-backed
inbound regardless of the node actually being online.

Drop the dirty term so the predicate reflects only what the message
claims: the node being unreachable (offline or disabled). Offline and
disabled nodes still mark dirty and still surface the toast.

Add regression tests: online+dirty must not be pending; offline and
disabled must be.
2026-06-29 18:35:38 +02:00
MHSanaei
8332ba67ae chore(deps): bump antd to 6.5 and migrate deprecated component props
Upgrade frontend deps (antd 6.4.5 -> 6.5.0, Ant Design icons, TanStack
Query, i18next, eslint) and fasthttp 1.71 -> 1.72.

AntD 6.5 deprecated several Input/Card/Space props, so adapt the panel UI:
- Input/InputNumber addonBefore/addonAfter -> prefix/suffix
- Card bordered -> variant="outlined"
- Space direction -> orientation
- swap the hand-rolled Telegram SVG for the new TelegramFilled icon
- guard SettingListItem against cloning aria-labelledby onto a Fragment,
  which only accepts key/children
2026-06-29 16:57:55 +02:00
MHSanaei
d8221a8153 fix(sub): bake Host VLESS Route into subscription UUIDs
The Host VLESS Route field was stored and shown in the panel but never applied to any generated subscription (raw, JSON, Clash), so the UUID was emitted unmodified (#5655).

Xray reads the route from the UUID's 3rd group (bytes 6-7, net.PortFromBytes) and masks those bytes to zero before authenticating, so a value can be baked into the share/JSON/Clash UUIDs without breaking the user match. A shared applyVlessRoute helper encodes a single 0-65535 value as the 3rd group; empty/invalid/non-UUID input is left unchanged, so legacy data never yields a broken link and no DB migration is needed.

The field was wrongly validated as a multi-segment port spec (that form belongs to the separate server-side routing rule). It is now a single value 0-65535, with frontend validation, link-preview parity (genVlessLink/hostToExternalProxyEntry), hint + error translations across all 13 locales, and tests on every path.

Closes #5655
2026-06-29 14:32:23 +02:00
MHSanaei
789e92cddc fix(clients): re-enable depleted clients on API renewal (#5619)
Renewing a subscription via POST /panel/api/clients/bulkAdjust extended a client's expiry/quota but left it disabled. The enforcement loop disables a depleted client across client_traffics, client_records and the inbound settings JSON (and pushes that to the node), while BulkAdjust only updated expiry/total and never cleared enable. On a node its UpdateUser push was built from the stale ClientRecord (Enable=false), which the next traffic poll merged back onto the master, so the client never recovered.

BulkAdjust now re-enables a client only when it was disabled because it was depleted and the adjustment lifts it back within limits, computed as a set-difference of the production depletedCond predicate and applied through the canonical BulkSetEnable (run after the per-inbound loop, since lockInbound is non-reentrant). Manually-disabled or still-depleted clients stay disabled.

Update now writes the clients.enable column explicitly so re-enabling sticks for inbound-less clients and stops feeding a stale record into node pushes.
2026-06-29 13:39:03 +02:00
nima1024m
7a5d6da28c fix(xray): clean stale routing references when a balancer or outbound is deleted (#5648)
* feat(xray): reference-cleanup helpers for entity deletion

When an outbound or balancer is deleted on the Xray page, routing rules and
balancers that reference it must be repaired in the same edit, or the saved
config breaks the core: a dangling balancerTag stops Router.Init (whole core
down), a dangling outboundTag black-holes matched traffic at the dispatcher.

Add pure plan*/apply* helpers that compute and apply the cleanup. A rule is
kept when a destination (outboundTag or balancerTag) remains and dropped when
none does. Deleting an outbound cascades: emptying a balancer selector removes
that balancer too, then repairs its rules in one pass against the full removed
set; fallbackTag and dialerProxy references are cleared and observatories
re-synced.

* fix(balancers): clean routing rules referencing a deleted balancer

Deleting a balancer left routing rules pointing at its balancerTag. xray-core's
Router.Init then fails ("balancer <tag> not found"), the core won't restart and
every inbound drops — the saved config passes CheckXrayConfig (JSON shape only),
so it breaks only on the next restart.

The delete confirm now lists the affected rules (modified vs removed) next to
the existing observatory warning and applies planBalancerDeletion's cleanup: a
rule keeps its outboundTag when present, otherwise the whole rule is dropped.
Adds the shared DeletionImpactList and refCleanup strings across all 13 locales.

* fix(outbounds): clean rules, balancer selectors and dialerProxy on outbound delete

Deleting an outbound left routing rules pointing at its outboundTag (matched
traffic black-holed at the dispatcher), plus stale references in balancer
selectors / fallbackTag and other outbounds' dialerProxy.

The delete confirm now shows planOutboundDeletion's impact and applies the
cascade: rules keep a remaining balancerTag (else are dropped), the tag is
pulled from balancer selectors and fallbacks, dialerProxy references are
cleared, and a balancer whose selector is emptied is removed along with its
own now-targetless rules.

* refactor(xray): share one rule classifier across preview and apply

Code review flagged that the keep/drop predicate was transcribed twice — in
ruleImpacts (the delete-modal preview) and in applyCleanup (the mutation) — kept
in sync only by a parity test. Extract a single classifyRule() that both call,
so the preview can never disagree with what apply actually does.

Also harden balancersEmptiedBy to skip tagless balancers: an empty/missing tag
would otherwise enter the removed set as "" and silently drop every other
tagless balancer (only reachable via a hand-edited config, but a silent data
loss). And remove observersRemovedByDeletingBalancer, orphaned once BalancersTab
switched to planBalancerDeletion.

* fix(xray): null-guard reference cleanup against unvalidated configs

The PR review noted that classifyRule and applyCleanup dereferenced rule /
balancer entries directly, while the sibling propagateOutboundTagRename uses
optional chaining — because fetchXrayConfig falls back to the unvalidated parsed
object when Zod validation fails, a stray null in rules / balancers can survive
into the editor and would throw during the delete preview/apply.

Match that defensive style: classifyRule and balancersEmptiedBy read through
optional chaining, the balancer loop skips nullish entries, and the dialerProxy
walk guards the outbound. A delete on a hand-edited config with null entries now
degrades gracefully instead of throwing.
2026-06-29 12:52:18 +02:00
nima1024m
71aca2018a feat(a11y): screen-reader & keyboard accessibility across the panel (#5486) (#5652)
* feat(a11y): label list, toolbar & dashboard actions for screen readers

Phase 1 of #5486 (Android TalkBack support). Icon-only controls across
the management surfaces previously announced only their untranslated
icon name (e.g. "edit", "ellipsis") or nothing at all.

- Add aria-label to icon-only row-action and toolbar buttons across
  inbounds, clients, groups, hosts, nodes and xray
  (outbounds/routing/dns/balancers) lists, plus the dashboard cards.
- Make clickable bare icons and AntD Card actions keyboard-operable via
  role/tabIndex + Enter/Space (new activateOnKey helper); convert mobile
  dropdown triggers to buttons so they open from the keyboard.
- Fix the sidebar hamburger's mislabeled aria-label (was the dashboard
  label) and translate previously-hardcoded outbound menu labels.

New i18n keys in all 13 locales: sort, menu.openMenu,
pages.xray.outbound.moveToTop.

* feat(a11y): label modal, QR and copy/download controls for screen readers

Phase 2 of #5486. Modal and overlay controls relied on tooltips (not a
reliable accessible name) or were bare clickable icons with no keyboard
or screen-reader support.

- Add aria-label to copy/QR/download/info icon buttons in the inbound and
  client info modals, sub-links modal, QR panel, backup/log modals, and
  to the bare search/select inputs of the attach/detach client modals.
- Make click-to-copy QR codes and the IP-log refresh/clear, geofile
  reload and log refresh icons keyboard-operable (role/tabIndex +
  Enter/Space) with translated labels.
- Label the 2FA code input; drop the QrPanel download-image string
  fallback now that the key exists.

New i18n key in all 13 locales: downloadImage.

* feat(a11y): label form fields and shared form components for screen readers

Phase 3 of #5486. Form controls and shared form widgets were largely
unlabelled, and several remove controls were not keyboard-operable.

- SettingListItem now ties its title to the control via aria-labelledby,
  giving accessible names to the ~90 settings-tab inputs at once.
- InputAddon gains button semantics (role/tabIndex/Enter+Space) and an
  ariaLabel prop when used as an interactive remove control.
- Sparkline charts expose a role="img" summary of their latest values.
- Add aria-label to add/remove/regenerate icon buttons and bare
  inputs/selects across inbound, client and xray (dns/routing/balancer/
  outbound) forms; make clickable remove icons keyboard-operable; mark
  decorative help/target icons aria-hidden; label the JSON editor,
  date-time clear button, header-map remove, notification select-all and
  remark token chips.

New i18n keys in all 13 locales: regenerate, jsonEditor,
pages.xray.balancer.{costMatch,costValue,costRegexp}.

* chore(a11y): add eslint-plugin-jsx-a11y harness and fix flagged interactions

Phase 4 of #5486. Adds eslint-plugin-jsx-a11y (recommended ruleset,
scoped to .tsx) so screen-reader/keyboard regressions fail lint.

- Make the mobile node-card header a proper keyboard disclosure
  (role=button, aria-expanded, Enter/Space activation that ignores
  clicks on the nested action buttons) and drop the now-redundant
  stop-propagation click handlers the linter flagged on card-action
  wrappers in the node, client and inbound mobile cards.
- Disable jsx-a11y/no-autofocus: the autofocus on the login field and
  modal primary inputs is intentional focus management that helps
  screen-reader and keyboard users land on the right control.

make lint passes with the a11y ruleset enforced.

* feat(a11y): cover remaining deferred spots (settings tabs, sockopt, API docs)

Completes the panel sweep for #5486 by labelling the spots previously
left out of phases 1-4:

- NotifyTimeField (Telegram notifications): the mode, interval, unit and
  custom-cron inputs now carry aria-labels.
- The Sockopt toggle in transport options.
- Settings category tabs in icons-only (mobile) mode now expose the tab
  name as the icon's aria-label instead of the raw icon name.
- The Swagger API-docs view is wrapped in a labelled region landmark.

New i18n keys in all 13 locales: pages.settings.notifyTime.{interval,unit}.

* feat(a11y): label shared xray form components and remark field

Code review surfaced frontend/src/lib/xray/forms/ — shared form components
used by the host and inbound JSON forms — which the initial audit missed.

- FinalMaskForm (TCP/UDP final-mask editor): label the icon-only add and
  regenerate buttons and make all six remove icons keyboard-operable
  (role/tabIndex/Enter+Space); adds useTranslation to its sub-components.
- CustomSockoptList: the remove icon is now keyboard-operable.
- SniffingFields: aria-label on the otherwise label-less destOverride select.
- RemarkTemplateField: aria-label on the remark-variable picker button.

New i18n key in all 13 locales: pages.inbounds.sniffingDestOverride.

* feat(a11y): label client info modal and WireGuard config block

After rebasing onto the WireGuard client-config feature, re-apply the
ClientInfoModal copy/QR/IP-log aria-labels (the modal was restructured
upstream, so the original labels did not carry over) and label the new
ConfigBlock component's copy/download/QR actions. ConfigBlock's action
wrapper keeps its stop-propagation handler (a non-interactive guard for
the Collapse header) under a scoped jsx-a11y exception.

* fix(frontend): let npm install jsx-a11y under ESLint 10

eslint-plugin-jsx-a11y@6.10.2 declares a peer range that stops at ESLint 9,
but the panel is on ESLint 10, so `npm ci` aborts with ERESOLVE even though
the plugin runs fine on ESLint 10 with flat config. Add an npm override so
jsx-a11y accepts the project's ESLint version. This keeps normal peer
resolution (recharts' react-is peer still auto-installs) — no global
legacy-peer-deps and no manual react-is pin needed.

* fix(a11y): size mobile row triggers and move node expand role to chevron

Address automated review on #5652:
- add size="small" to the inbound/client/node mobile-card "more" dropdown
  triggers so they match the adjacent small Switch and the established
  desktop RowActions pattern.
- move the node card-head disclosure semantics (role/tabIndex/aria-expanded/
  keyboard) onto the chevron affordance so the expand control is no longer a
  role="button" wrapping the Switch, info button and dropdown. Mouse
  click-anywhere-to-expand is preserved on the header div.
2026-06-29 12:51:29 +02:00
MHSanaei
6c71b725da fix(clients): hide WireGuard config after detaching the WG inbound
The client info and QR modals rendered a WireGuard config whenever the
client still carried leftover WG key material (privateKey / publicKey /
allowedIPs / preSharedKey / keepAlive), regardless of whether a WireGuard
inbound was actually attached. After detaching the WG inbound the config
kept showing, built with an empty endpoint port and public key.

Gate wgConfigText on an attached WireGuard inbound (wgInbound) being
present, not just isWireguardClient(client), in both ClientInfoModal and
ClientQrModal.

Also rename the i18n key pages.clients.conf -> config and add the missing
pages.clients keys (wireguardConfig, config, bulkFlow, bulkFlowNoChange,
bulkFlowDisable) to all 12 non-English locales so each one matches en-US.
2026-06-29 01:15:37 +02:00
MHSanaei
a329882e0e feat(wireguard): client config UX, collapsible config card, configurable DNS
Land the WireGuard client-config UX work on main (the upstream PR #5642
branch could not be pushed to).

- Reusable collapsible ConfigBlock (copy/download/QR, actions aligned right)
  for the client .conf, used by client info and the public sub page.
- Correct .conf: canonical PresharedKey casing and DNS sourced from the inbound
  (configurable per-inbound, default 1.1.1.1, 1.0.0.1).
- Configurable per-inbound DNS for WireGuard (schema + form + backend hint via
  InboundOption.WgDns); inert at the Xray layer.
- Public sub page now shows the WireGuard config, rebuilt from the share link;
  the Go wireguard:// link carries dns/presharedkey/keepalive for completeness.
- QR enabled for the wireguard:// link; link rows are compact like other protocols.
- Client information order is subscription, copy URL, WireGuard config; the
  redundant config tab is removed from the add/edit client modal.
- Drop the Inbound Information and QR Code row actions for WireGuard inbounds.
2026-06-29 00:50:34 +02:00
Nikan Zeyaei
60c54827aa feat: ldap skip tls verify (#5637)
* feat(ldap): add InsecureSkipVerify field and tlsConfig helper

Extract the inline TLS config at both LDAPS dial sites (FetchVlessFlags,
AuthenticateUser) into a tlsConfig(cfg) helper, and add a new
Config.InsecureSkipVerify bool that flows through to
tls.Config.InsecureSkipVerify. This unblocks enterprise environments
(e.g. Microsoft AD CS with internal CAs) where the server certificate
chain cannot be imported into the system trust store.

Behavior is identical when InsecureSkipVerify is false (the default) -
pure refactor + plumbing. The helper is unit-testable without a live
server, which is why it is extracted.

Closes https://github.com/MHSanaei/3x-ui/issues/5538

* feat(settings): add LdapInsecureSkipVerify setting

Plumb the new LDAP skip-TLS-verify toggle through the settings stack:
- AllSetting struct field (json/form tag: ldapInsecureSkipVerify)
- defaultValueMap default ("false")
- GetLdapInsecureSkipVerify() getter
- ldap_sync_job wiring into ldaputil.Config (FetchVlessFlags path)
- panel/user.go wiring into ldaputil.Config (AuthenticateUser path;
  the original issue's file list missed this)

Persistence is handled by UpdateAllSetting's reflect loop, matching
the existing pattern used by ldapUseTLS (no explicit setter).

Closes https://github.com/MHSanaei/3x-ui/issues/5538

* feat(ui): add Skip TLS verification switch in LDAP settings

Wire the new ldapInsecureSkipVerify setting into the hand-written
frontend model and Zod schema, and render it as a new Switch in
GeneralTab right under "Use TLS (LDAPS)". The switch is disabled
when TLS is off (the setting is meaningless without LDAPS) and shows
an insecure-warning description to make the security implication
visible to operators.

Also adds a Vitest round-trip test pinning schema acceptance and
model default-to-false behavior.

Closes https://github.com/MHSanaei/3x-ui/issues/5538

* chore(i18n): add Skip TLS verification strings to all locales

Add pages.settings.ldap.skipTlsVerify and skipTlsVerifyDesc to all 13
backend-served translation files, matching the existing repo
convention of keeping LDAP keys present in every locale (en-US, fa-IR,
ru-RU, zh-CN, zh-TW, pt-BR, ar-EG, uk-UA, id-ID, tr-TR, vi-VN, ja-JP,
es-ES). No translation-parity test exists in CI, but every other
LDAP key is replicated across all files, so this keeps the
invariant intact.

Closes https://github.com/MHSanaei/3x-ui/issues/5538

* chore(codegen): regenerate frontend artifacts

Regenerate frontend/src/generated/{zod,types,schemas,examples}.ts
and frontend/public/openapi.json via `npm run gen` to reflect the
new ldapInsecureSkipVerify field. The codegen CI job runs
`git diff --exit-code` on these files; failing to commit them would
break the build.

Closes https://github.com/MHSanaei/3x-ui/issues/5538
2026-06-28 18:10:38 +02:00
n0ctal
aef35ee0de fix(sync): mark node dirty inside the mutation transaction (atomic ConfigDirty) (#5611)
* fix(sync): mark node dirty inside the mutation transaction

ConfigDirty is currently set by MarkNodeDirty AFTER the mutation, on a
separate DB handle outside the mutation's transaction. A crash or error
between the committed change and the mark leaves a committed config
change that never reconciles to the node (silent drift). Add
MarkNodeDirtyTx(tx, id) and call it inside each mutation's transaction so
the dirty mark commits atomically with the change.

* fix(test): initialize DB in TestResolveInboundAddress and group gorm import

Two CI failures on this branch:

- race (-shuffle=on): TestResolveInboundAddress reaches resolveInboundAddress -> configuredPublicHost -> GetSubDomain, which reads the global DB. The test never initialized one, relying on another sub-package test to do so first; under shuffle it ran first and nil-dereferenced gorm. Call initSubDB(t) so it is self-sufficient (empty DB yields an empty subDomain, so the subscriber-host fallback still holds).

- golangci goimports: gorm.io/gorm was grouped with the github.com/mhsanaei/3x-ui local imports in node_dirty_test.go. Move it into the third-party group.
2026-06-28 15:18:28 +02:00
n0ctal
2b10808fbd fix(settings): require re-2FA confirmation for sensitive setting changes (#5610)
* fix(settings): require server-side 2fa for sensitive changes

* fix(lint): group third-party imports separately from local (goimports)

golangci-lint goimports flagged setting.go and setting_security_test.go because xlzd/gotp and gorm.io/gorm were mixed into the github.com/mhsanaei/3x-ui local-prefix group. Move them into the third-party group so the local imports stand alone.
2026-06-28 15:17:15 +02:00
nima1024m
25a86b9ee2 feat(balancers): tabbed Observatory/Burst Observatory form (#5627)
* feat(balancers): tabbed Observatory/Burst form replacing raw JSON

Replace the raw JSON editor for the Observatory / Burst Observatory sections
with a proper Ant Design form, and split the Balancers page into two sub-tabs:
"Balancer Settings" (the existing table) and "Observatory".

Observers stay fully auto-managed by balancer strategy through the existing
syncObservatories logic: users edit only the tunable probe fields, the
subjectSelector is shown read-only since it is derived from the balancers, and
deleting the last balancer that needs an observer now warns in the confirm
dialog that the observer will be removed too. Overlapping selectors keep an
observer alive while any balancer still references it.

Also add the previously missing pingConfig.httpMethod field (HEAD/GET) and
translations for the new strings across all 13 locales.

* refactor(balancers): tighten httpMethod typing and align connectivity default

Address automated review feedback on the Observatory form:
- Use the ObservatoryHttpMethodSchema enum for pingConfig.httpMethod instead of
  a free-form z.string(), and drive the HTTP method Select from its options.
  Removes the previously dead enum export and the duplicate local list, and
  types the field as 'HEAD' | 'GET'.
- Align the schema's connectivity default with DEFAULT_BURST_OBSERVATORY (the
  hicloud URL) so it matches what burst observers are actually created with.

No behavior change.
2026-06-28 15:02:18 +02:00
nima1024m
51ffba5961 fix(balancers): defer validation errors until touched or save (#5626)
The Add Balancer modal parsed its empty initial state through
BalancerFormSchema on mount and bound Form.Item validateStatus/help
directly to the result, so "Tag is required" and "Pick at least one
outbound" rendered the moment the modal opened, before any user input.

Gate the inline errors behind per-field touched tracking plus a
submit-attempted flag, and drop the disabled Create button so a save
attempt surfaces the errors (matching RuleFormModal). The existing
key-based remount in BalancersTab resets the flags on each open.

Add a regression test asserting no errors on open and errors only
after a save attempt.
2026-06-28 15:01:53 +02:00
n0ctal
5713c09980 fix(runtime): refresh cached node remotes on identity change (#5614) 2026-06-28 15:01:18 +02:00
n0ctal
7f8cbf4c4b fix(web): tighten database restore body-cap exemption (#5609) 2026-06-28 15:00:55 +02:00
MHSanaei
bbfbd7eba6 Bump minimum eligible Xray version
Update Xray release filtering to only include versions at or above v26.6.27 (previously v26.4.25). Also mark `google.golang.org/protobuf` as a direct dependency in `go.mod` by removing the `// indirect` annotation.
2026-06-28 14:57:43 +02:00
MHSanaei
79069d2b64 fix(wireguard): allocate client IPs in the existing peer subnet
defaultWireguardClients always allocated new tunnel addresses from the
hardcoded 10.0.0.0/24 base, so a legacy or migrated inbound whose peers
live in a different subnet (e.g. 172.16.0.0/24) got new clients in an
unrelated, unroutable range. Derive the allocation base from the existing
peers' /24 and fall back to 10.0.0.0/24 only when there are none.
2026-06-28 14:41:24 +02:00
MHSanaei
9c8cd08f90 feat(wireguard): multi-client support
WireGuard inbounds now manage per-client peers using xray-core's native WireGuard users (AddUser/RemoveUser). Each client lives in settings.clients (canonical, like every other protocol) and is projected to peers[] only when emitting the xray config, at level 0 so the dispatcher's per-user traffic/online counters work with no extra plumbing.

Backend: internal/util/wireguard gains KeyToHex (base64 to hex for the gRPC path), PublicKeyFromPrivate and GenerateWireguardPSK; xray/api.go builds a wireguard account in AddUser with hex keys (RemoveUser already worked); client CRUD generates a keypair and allocates a unique tunnel address per client and never rotates keys on edit; an idempotent migration converts legacy settings.peers into managed clients; WireGuard is included in the raw subscription.

Frontend: WireGuard in the add-client modal with keys on the credential tab, client schema, per-client QR/link/.conf, inbound form reduced to server settings; i18n added across 13 locales.

Fix: guard the settings[clients] assertion in add/update so a legacy WireGuard inbound stored without a clients key no longer panics.
2026-06-28 00:44:38 +02:00
MHSanaei
33aada0c7c feat(xhttp): default xmux maxConnections to 6
xray-core v26.6.27 changed the XHTTP client xmux default to maxConnections=6 (anti-RKN). The panel previously sent maxConnections=0, which overrode that default; default XHttpXmuxSchema to 6 so new outbounds adopt it and the wire-exclusivity rule drops maxConcurrency accordingly.
2026-06-27 20:26:03 +02:00
MHSanaei
e44075a6e0 chore(deps): bump xray-core to v26.6.27
Update the xray-core Go module (infra/conf builders + gRPC command clients) and the bundled binary pin in DockerInit.sh and the release workflow from v26.6.22 to v26.6.27. No gRPC command-API breaking changes. The release's other inbound work rides along with the bump: TUN autoSystemRoutingTable/autoOutboundsInterface are already modeled in the frontend tun schema, while Hysteria vlessRoute (UUID-derived) and the TUN traffic counters are internal to xray-core and need no panel changes.
2026-06-27 20:25:45 +02:00
MHSanaei
56b0be0b6a fix(lint): use errors.Is for io.EOF comparison in sys_linux
The errorlint linter rejects direct error comparison with != because it
fails on wrapped errors. Compare via errors.Is(err, io.EOF) instead.
2026-06-27 16:38:07 +02:00
MHSanaei
9b8a0c9b17 feat(groups): reset group traffic without touching client counters
The group page shows traffic counting per group, but the only reset
available zeroed every member client's up/down counters (and their
quotas) via bulkResetTraffic. Group traffic is a derived sum of client
traffic, so zeroing the group display previously required mutating the
clients themselves.

Add a display-only baseline: ClientGroup gains reset_up/reset_down
columns (additive, handled by AutoMigrate). ResetGroupTraffic snapshots
the group's current up/down sum into the baseline, and ListGroups now
reports max(0, sum - baseline). Client counters are left untouched and
no Xray restart is triggered. A new POST /panel/api/clients/groups/
resetTraffic endpoint drives it, creating the client_groups row when the
group exists only as a derived label.

The groups page action now calls the new endpoint; confirm/success
strings updated across all 13 locales to reflect group-only semantics.
2026-06-27 16:33:36 +02:00
MHSanaei
d1c0d77023 chore(ci): bump golangci-lint action to v9
Update the GitHub Actions CI workflow to use golangci/golangci-lint-action@v9 instead of v8. This keeps the lint job aligned with the latest major version and ongoing action maintenance.
2026-06-27 15:58:36 +02:00
MHSanaei
63fca9ef88 docs: correct false RTL claim and stale Vite version in CONTRIBUTING.md
RTL is not wired through AntD ConfigProvider direction (no such code exists; only the Jalali date picker is RTL-aware), so the guide now states that accurately instead of claiming a mechanism that is absent. Replace the hardcoded Vite version (said 8.0.16; package.json pins 8.1.0) with a pointer to read the live version, removing the drift source.
2026-06-27 15:48:51 +02:00
MHSanaei
2e851978e6 chore: add Makefile as canonical task runner
make verify reproduces the CI PR gate locally (gen-check, lint, typecheck, test, build) with the same flags as ci.yml: go test -shuffle=on -count=1 over the node_modules-filtered package list, the internal/web/dist go:embed stub, and the generated-file staleness diff. Run make help for all targets.
2026-06-27 15:42:23 +02:00
MHSanaei
fa1a19c03c style: adopt golangci-lint v2 and resolve all findings
Add .golangci.yml (v2): the standard linters plus bodyclose, errorlint, noctx, misspell, rowserrcheck, sqlclosecheck, unconvert, usestdlibvars, with gofumpt + goimports formatters. Enable the std-error-handling exclusion preset for idiomatic Close/Remove/Setenv ignores; scope-exclude SA1019 (parser.ParseDir in tools/openapigen) and ST1005 (intentional capitalized user-facing error copy that tests assert verbatim). No inline nolint directives were introduced.

Resolve all 217 findings behavior-preserving: gofumpt/goimports formatting, explicit blank assignment on intentionally ignored errors, errors.Is/errors.As and %w wrapping, context-aware stdlib calls (CommandContext/QueryContext/NewRequestWithContext/Dialer), staticcheck simplifications, removed redundant conversions, http.StatusOK and http.MethodGet, inlined the go:fix intPtr helper, and deferred sql rows Close. Add a golangci CI job mirroring the existing Go jobs.
2026-06-27 15:42:22 +02:00
MHSanaei
7efa0d9ddd docs: add CLAUDE.md agent guides for root and frontend
Operational guides the Claude Code CLI auto-loads. The root file covers the stack, repo map, hard rules (no // comments, the endpoints.ts registry, the openapigen StructAllow allowlist, i18n locales, migrations), Go and frontend conventions, and the make verify gate. frontend/CLAUDE.md covers the React + AntD 6 + Vite setup. Both link to CONTRIBUTING.md and frontend/README.md instead of duplicating them, and every claim was fact-checked against the source.
2026-06-27 15:42:11 +02:00
MHSanaei
d12b186a69 test(sub): align identity-token test with first-link-only EMAIL
876d55f2 made {{EMAIL}}/{{USERNAME}} appear on the first sub-body link
only, but TestIdentityTokensEverywhere still asserted the email survived
on every repeat body link, breaking the go-test and race CI jobs. Update
it to assert the repeat body link drops the identity token while the
display/QR remark keeps it; the first-link case is covered by
TestEmailOnFirstLinkOnly.
2026-06-27 13:56:45 +02:00
MHSanaei
39eb5baf42 fix(inbound): convert legacy externalProxy to hosts on import
An inbound exported from a build that predated the hosts table carries
its external proxies inline in streamSettings.externalProxy. The startup
migration that converts those to host rows runs once and is gated off
afterwards, so it never sees a freshly imported inbound, leaving its
external proxies stranded in streamSettings (never surfaced as Hosts).

Extract the migration's per-inbound conversion into a shared
database.CreateHostsFromExternalProxy and run it inside the AddInbound
transaction. No-op for inbounds without externalProxy (everything the
current UI builds), so it only fires on such imports.
2026-06-27 13:50:06 +02:00
MHSanaei
876d55f274 fix(sub): show {{EMAIL}} on first sub-body link only
The remark template's {{EMAIL}}/{{USERNAME}} were repeated on every link
of a subscription. Strip them from subsequent body links like the usage
tokens, so the email appears once on the first link. Display/QR remarks
and the other client tokens are unaffected.
2026-06-27 12:42:12 +02:00
Nikan Zeyaei
1bad2fcba1 feat(backup): prefix backup filenames with date and time (#5606)
* feat(backup): add YYYY-MM-DD_ date prefix to backup filenames

Refs #5584

* feat(backup): prefix backup filenames with date and time

* fix(backup): put host before date in backup filename

Backup filenames now read {host}_{date}{ext} (e.g. panel.example.com_2026-06-27_000000.db) instead of {date}_{host}{ext}, so files group by server first then sort chronologically within each server.
2026-06-27 12:08:20 +02:00
MHSanaei
4c177f0cf1 fix(shadowsocks): send per-user Account for SS-2022 runtime AddUser
SS-2022 user updates passed shadowsocks_2022.ServerConfig (the inbound-level
config) as the gRPC user account. The core rejects it with "Unknown account
type" because only shadowsocks_2022.Account implements AsAccount(), so live
AddUser failed and renewed/reset/added users stayed inactive until the 30s
auto-restart rebuilt the inbound from the DB.

Use shadowsocks_2022.Account{Key: password} (the per-user type, matching
xray-core's own multi-user builder) so changes apply immediately without a
restart.

Fixes #5597
2026-06-27 12:00:38 +02:00
MHSanaei
797b08cd07 fix(balancers): create burst observer for random/roundRobin with fallbackTag
xray-core's Random/RoundRobinStrategy calls RequireFeatures(Observatory) whenever a fallbackTag is set, so a balancer that declares a fallback but has no observatory aborts startup with 'core: not all dependencies are resolved'. syncObservatories never created an observer for these strategies, crashing the core on any load balancer that used a fallback (the default 'random' strategy with a fallbackTag, exactly issue #5605).

Treat random/roundRobin balancers that set a fallbackTag as requiring the burst observer. Also make the burst observer strictly requirement-driven (mirroring the leastPing/observatory path) so clearing the last fallbackTag drops it again instead of leaving a dead observer that forces needless restarts and probing.

Closes #5605
2026-06-27 11:46:19 +02:00
MHSanaei
439245d42b feat(inbounds): apply remark template to Export all inbound links
Export-all now renders links through the subscription engine via a new GET /panel/api/inbounds/allLinks endpoint, so the configured remark template (name-only display part) is applied per client -- matching the client info/QR pages. Previously it generated links client-side with a hardcoded inbound-email remark.

Host-aware: managed Host endpoints win over the plain link, so HOST and per-host variants render; duplicate client JSON entries are deduped by email and the list is scoped to the logged-in user.
2026-06-27 11:22:45 +02:00
MHSanaei
535b89a352 fix(routing): write lowercase L4 network to xray config, display uppercase in UI 2026-06-27 11:15:13 +02:00
Tomi lla
7a2179535a fix(settings): normalize API token timestamps (#5599)
* fix(settings): normalize API token timestamps

* refactor(api-token): share timestamp threshold

---------

Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
2026-06-27 10:30:58 +02:00
MHSanaei
6964d84742 feat(reality): add live REALITY target scanner with IP/CIDR discovery
Replace the static reality-targets list with a server-side TLS 1.3 probe that checks TLS 1.3 + HTTP/2 + X25519 + a trusted certificate.

- Single-domain validate auto-fills target and serverNames from the cert SAN
- Discovery scans an IP/CIDR without SNI to find new targets from their certificates, deduped and ranked by feasibility then latency, private-IP guarded via netsafe
- New endpoints scanRealityTarget and scanRealityTargets with RealityScanResult, plus openapigen and api-docs entries
- Add scanner strings to all 13 locales
- Replace deprecated AntD Alert message prop with title across the panel
2026-06-26 22:18:47 +02:00
MHSanaei
451263f1db feat(sidebar): add documentation link button
Add a Docs button next to the donate button in the sidebar and mobile drawer linking to https://docs.sanaei.dev/, with menu.docs translations across all 13 languages.
2026-06-26 18:55:32 +02:00
MHSanaei
8e4c368200 feat(update): allow opting into the dev channel from a stable build
The panel version button opened the GitHub releases page on a stable, up-to-date build, and the dev-channel toggle only rendered on dev builds, so there was no in-panel path from stable to dev. Drop the IsDevBuild() guard in devChannelActive (the toggle alone drives the channel now), always open the update modal instead of releases, and always render the Dev channel switch.
2026-06-26 18:01:51 +02:00
MHSanaei
522b1b64b0 fix(logger): prevent nil-deref panic in migrate/setting CLI paths
The package-level logger is nil until InitLogger runs, which only happens in runWebServer. The migrate and setting subcommands log without initializing it; PR #5520 added a logger.Info on a success path in MigrationRestoreVisionFlow, so 'x-ui migrate' segfaults on installs with a VLESS inbound needing Vision-flow restoration.

Initialize logger to a usable default at package load so no code path can nil-deref it, and set up the dual backend in migrateDb so migration steps are logged like runWebServer.

Fixes #5581
2026-06-26 11:40:13 +02:00
290 changed files with 11087 additions and 1641 deletions

View File

@@ -102,6 +102,21 @@ jobs:
go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/
go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/
golangci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: latest
frontend:
runs-on: ubuntu-latest
steps:

View File

@@ -118,7 +118,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.6.22/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.6.27/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
wget -q ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -267,7 +267,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.6.22/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/"
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"

53
.golangci.yml Normal file
View File

@@ -0,0 +1,53 @@
version: "2"
run:
build-tags: []
timeout: 5m
linters:
default: standard
enable:
- bodyclose
- errorlint
- noctx
- misspell
- rowserrcheck
- sqlclosecheck
- unconvert
- usestdlibvars
exclusions:
generated: lax
presets:
- std-error-handling
paths:
- frontend
- internal/web/dist
rules:
- path: _test\.go
linters:
- errcheck
- bodyclose
- noctx
# tools/openapigen relies on go/parser.ParseDir; migrating it to
# golang.org/x/tools/go/packages is a generator change, out of scope here.
- linters:
- staticcheck
text: "SA1019: parser.ParseDir"
# ST1005 (capitalized error strings) conflicts with intentional
# user-facing error copy that tests assert verbatim.
- linters:
- staticcheck
text: "ST1005:"
formatters:
enable:
- gofumpt
- goimports
settings:
goimports:
local-prefixes:
- github.com/mhsanaei/3x-ui
exclusions:
paths:
- frontend
- internal/web/dist

101
CLAUDE.md Normal file
View File

@@ -0,0 +1,101 @@
# CLAUDE.md
Operational guide for AI agents working in this repo. Long-form human docs:
`CONTRIBUTING.md` (setup, testing philosophy) and `frontend/README.md`.
Read those before large changes. This file is the short, must-follow version.
## Stack
- Backend: Go 1.26 (`module github.com/mhsanaei/3x-ui/v3`), Gin, GORM.
Runs Xray-core as a managed child process (`internal/xray/process.go`) and
imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router
API. MTProto inbounds run a second managed child — the `mtg` binary
(`internal/mtproto/`) — outside Xray.
- Storage: SQLite by default (`/etc/x-ui/x-ui.db` on Linux; the executable dir on
Windows), PostgreSQL optional (`XUI_DB_TYPE` / `XUI_DB_DSN`). The CGo SQLite
driver (`mattn/go-sqlite3`) needs a C compiler — `CGO_ENABLED=0` builds fail.
- Frontend: React 19 + Ant Design 6 + Vite 8 + TypeScript in `frontend/`,
built into `internal/web/dist/` (gitignored) and embedded via `embed.FS`.
## Repo map
- `main.go` — entry point + `x-ui` CLI (run, migrate, migrate-db, setting, cert).
- `internal/config/` — env parsing (XUI_DEBUG, XUI_LOG_LEVEL, XUI_LOG_FOLDER,
XUI_BIN_FOLDER, XUI_SKIP_HSTS, XUI_PORT, XUI_DB_*).
- `internal/database/` + `internal/database/model/` — GORM schema (Inbound,
Client, Setting, User), inbound Protocol enum, AutoMigrate + hand-written
migrations in `db.go`.
- `internal/xray/` — Xray child-process lifecycle, config generation, gRPC API.
- `internal/mtproto/` — MTProto inbounds via the bundled `mtg` binary.
- `internal/sub/` — subscription server (raw / JSON / Clash).
- `internal/eventbus/` — in-process pub/sub (outbound/node health, xray.crash,
cpu.high, memory.high, login.attempt).
- `internal/logger/`, `internal/util/` (link, crypto, sys, ldap, …),
`internal/tunnelmonitor/` — shared infrastructure.
- `internal/web/` — Gin server (embeds `dist/` + `translation/`).
- `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
- `service/` — business logic (InboundService, SettingService, XrayService,
node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
- `job/` — cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP).
- `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
`runtime/` (master/sub-node over mTLS), `websocket/`.
- `locale/` + `translation/` — i18n, 13 embedded locale JSON files.
- `frontend/` — React + TS source (see `frontend/CLAUDE.md`).
- `tools/openapigen/` — Go generator that emits frontend types + Zod/JSON schemas
into `frontend/src/generated/` from Go structs. The OpenAPI doc itself
(`frontend/public/openapi.json`) is assembled from those + `endpoints.ts` by
`frontend/scripts/build-openapi.mjs`.
## Hard rules (non-negotiable)
- NO `//` line comments in committed Go/TS. Names carry meaning; rename instead
of annotating. Exempt: `//go:build`, `//go:generate`, and other directives.
HTML `<!-- -->` is fine. (A linter cannot enforce this — you must.)
- New `g.POST`/`g.GET` in `internal/web/controller/` REQUIRES a matching entry
in `frontend/src/pages/api-docs/endpoints.ts`, then `make gen` (or
`cd frontend && npm run gen`). It is a hand-maintained registry — nothing checks
it against the Go routes, so an omitted route silently vanishes from the docs.
- Response examples come from Go struct `example:` tags via `tools/openapigen`
never hand-write them. A new struct must be added to openapigen's `StructAllow`
allowlist (`tools/openapigen/main.go`) or it is silently omitted from
schemas/examples (and `build-openapi.mjs` then fails on the missing schema).
- A new English i18n key must be added to EVERY locale JSON in
`internal/web/translation/` (13 files). Missing keys fall back to en-US (or
render the raw key if absent there too); nothing fails the build, so they are
easy to miss.
- DB / model changes require a migration in `internal/database/db.go`.
- Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `docs`,
`style`): `<area>: short imperative summary`, then a body explaining the why.
## Go conventions
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
`t.Helper()` on helpers. Assert the exact value / typed error / emitted
string, never just `err != nil`. Prefer real deps over mocks: throwaway DB via
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
`internal/sub`'s `initSubDB(t)` is the template.
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
`src/schemas/` are the source of truth; infer types with `z.infer`, never
hand-write. Do not edit `src/generated/`.
- Editing `frontend/src` does NOT change what users see until the Vite build is
regenerated into `internal/web/dist/`. In `XUI_DEBUG=true`, HTML is served from
the frozen embedded FS but JS/CSS off disk — after `npm run build` you MUST
restart `go run .` or you get a blank page with 404s.
- After touching share-link logic (`src/lib/xray/`), run `npm run test` (golden
fixtures); regenerate snapshots (`npx vitest run -u`) only for intentional
output changes, never to make a red test green.
## Build, test, verify
Run `make help` for all targets. The full local gate that mirrors CI:
make verify
Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend),
`make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`.
## Definition of done (before opening a PR)
1. `make gen` and confirm `git diff` on `frontend/src/generated` +
`frontend/public/openapi.json` is clean.
2. `make verify` passes.
3. Diff is focused; refactors are separate from feature work.

View File

@@ -184,11 +184,11 @@ Only a genuinely **standalone bundle** (like `login` or `subpage`, reachable wit
- **Ant Design 6** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back. Small, targeted UX tweaks beat sweeping rewrites; raise broader visual changes for discussion before implementing.
- **Function components + hooks** everywhere. No class components.
- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
- **Persian and Arabic users are first-class.** When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows. (Full RTL layout is not currently wired through AntD `ConfigProvider direction` — only the Jalali date picker is RTL-aware — so treat RTL as an open area, not a solved one.)
- **Schemas over `any`.** New config shapes go in `src/schemas/`; `@typescript-eslint/no-explicit-any` is an error and production schemas use no `.loose()`. Validate form fields with `antdRule(Schema.shape.field, t)` rather than inline `z.string()` in rules.
- **Document new endpoints.** Every new `g.POST`/`g.GET` in `internal/web/controller/` needs a matching entry in `src/pages/api-docs/endpoints.ts` — it drives both the in-panel API docs and the generated OpenAPI/Zod (`npm run gen:api` / `gen:zod`).
- **Do not break link generation.** Share-link logic lives in `src/lib/xray/` (`inbound-link.ts`, `outbound-link-parser.ts`, …) and is round-tripped by the golden fixture suite — run `npm run test` after any change to URL generation, defaults, or TLS/Reality handling, and regenerate snapshots (`npx vitest run -u`) only for intentional changes. Two runtime paths consume it: the **inbounds page** and the **clients page** subscription links (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`); exercise both.
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — currently `8.0.16` — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
- **Vite is pinned to an exact version** (no `^`) in `frontend/package.json` — read the live version there rather than trusting a number quoted here — so local, CI, and release builds resolve identically. Bump it deliberately and verify both `npm run dev` and `npm run build` afterward.
### Project layout

View File

@@ -34,7 +34,7 @@ esac
MTG_VER="2.2.8"
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.22/Xray-linux-${ARCH}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"

75
Makefile Normal file
View File

@@ -0,0 +1,75 @@
# Canonical task runner. Mirrors .github/workflows/ci.yml so `make verify`
# reproduces the PR gate locally. Run `make help` for the list.
SHELL := bash
GO_PKGS = $(shell go list ./... | grep -v '/frontend/node_modules/')
FRONTEND = frontend
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-14s %s\n", $$1, $$2}'
# go:embed of internal/web/dist needs the dir to exist even when the
# frontend bundle has not been built. CI stubs it the same way.
.PHONY: dist-stub
dist-stub:
@mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
.PHONY: gen
gen: ## Regenerate Zod schemas + OpenAPI from Go sources
cd $(FRONTEND) && npm run gen
.PHONY: gen-check
gen-check: gen ## Fail if generated files are stale
git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
.PHONY: lint-go
lint-go: dist-stub ## golangci-lint on Go sources
golangci-lint run
.PHONY: lint-fe
lint-fe: ## ESLint on frontend sources
cd $(FRONTEND) && npm run lint
.PHONY: lint
lint: lint-go lint-fe ## All linters
.PHONY: typecheck
typecheck: ## tsc --noEmit
cd $(FRONTEND) && npm run typecheck
.PHONY: test-go
test-go: dist-stub ## Go tests (shuffle, no cache)
go test -shuffle=on -count=1 $(GO_PKGS)
.PHONY: race
race: dist-stub ## Go tests with the race detector (needs a C compiler)
go test -race -shuffle=on -count=1 $(GO_PKGS)
.PHONY: test-fe
test-fe: ## Frontend tests (vitest)
cd $(FRONTEND) && npm test
.PHONY: test
test: test-go test-fe ## All tests
.PHONY: vulncheck
vulncheck: dist-stub ## govulncheck
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
.PHONY: build-fe
build-fe: ## Build the Vite bundles into internal/web/dist
cd $(FRONTEND) && npm run build
.PHONY: build
build: build-fe ## Build the frontend then the Go binary
go build ./...
# The PR gate. Matches ci.yml: codegen freshness, both linters, typecheck,
# both test suites, and a full build.
.PHONY: verify
verify: gen-check lint typecheck test build ## Full local gate (mirrors CI)
@echo "verify: OK"

60
frontend/CLAUDE.md Normal file
View File

@@ -0,0 +1,60 @@
# frontend/CLAUDE.md
Frontend agent guide. Full detail: `frontend/README.md` and the root
`CONTRIBUTING.md` ("Working on the frontend"). This is the short version.
## What this is
React 19 + Ant Design 6 + Vite 8 + TypeScript. The Vite config is
`vite.config.js` (plain JS). Three bundles, each emitted into
`internal/web/dist/` and embedded into the Go binary:
- `index.html` — admin panel SPA (entry `src/main.tsx`; react-router under
`/panel`, lazy routes).
- `login.html` — login + 2FA (`src/entries/login.tsx`).
- `subpage.html` — public subscription viewer (`src/entries/subpage.tsx`).
The `@` import alias maps to `src/`.
## Data flow
- Server state via TanStack Query (`src/api/`, keys in `src/api/queryKeys.ts`);
invalidate on mutation. WebSocket pushes feed the cache
(`src/api/websocketBridge.ts`).
- Local UI state in the page (`useState`); shared concerns via `src/hooks/`.
Extend an existing hook before adding a global.
- Zod (`src/schemas/`) is the single source of truth for the xray config model.
Infer types with `z.infer`. Go-side types are mirrored into `src/generated/`
by `npm run gen:zod` (`go run ./tools/openapigen`) — do not hand-edit that
folder (every file is marked `DO NOT EDIT`).
- xray domain logic (links, defaults, form<->wire adapters) is pure functions in
`src/lib/xray/`. HTTP goes through `HttpUtil` in `src/utils/index.ts`.
## Rules
- Ant Design 6 only; no Tailwind/shadcn (a migration was rolled back).
- Function components + hooks only; no class components.
- No `//` line comments in committed TS/TSX. HTML comments are fine.
- TS strict; `no-explicit-any` is an error. Validate form fields with
`antdRule(Schema.shape.field, t)` from `@/utils/zodForm`, not inline
`z.string()`.
- New `g.POST`/`g.GET` route => add it to `src/pages/api-docs/endpoints.ts`,
then `npm run gen`.
- i18n strings live in `internal/web/translation/<locale>.json`, NOT under
`frontend/`, and are shared with the Go backend. A new English key must be
added to every locale. Interpolation here uses single braces `{var}`, not the
i18next default `{{var}}`.
- Persian/Arabic (RTL) users are first-class — isolate code identifiers on their
own line when writing Persian text in labels/toasts.
- Vite is pinned to an exact version (no `^`) — bump deliberately, then verify
`npm run dev` AND `npm run build`.
## Adding a panel route
1. `src/pages/<page>/<Page>.tsx` (kebab folder, PascalCase component).
2. Register in `src/routes.tsx` under `/panel` (lazy import).
3. Add a sidebar link in `src/layouts/AppSidebar.tsx` if it needs nav.
Only standalone bundles (login/subpage) need a new `.html` + `src/entries/*` +
`rollupOptions.input` (in `vite.config.js`) + a Go controller route.
## Commands
- `npm run dev` (HMR on :5173, proxies to the Go panel on :2053 — start Go first).
- `npm run typecheck` / `npm run lint` / `npm run test` / `npm run build`.
- `npm run gen` = `gen:zod` (Go → `src/generated/`) + `gen:api`
(`build-openapi.mjs``public/openapi.json`).
- After `npm run build`, RESTART `go run .` (see the XUI_DEBUG gotcha in root
CLAUDE.md) before checking the panel.

View File

@@ -100,7 +100,7 @@ frontend/
├── generated/ # Code-generated zod + ts types from Go
│ # (DO NOT hand-edit — regenerated by gen:zod)
├── models/ # Thin legacy types still in transit
│ # (DBInbound, Status, AllSetting, reality-targets)
│ # (DBInbound, Status, AllSetting)
├── styles/ # Shared CSS modules
├── test/ # Vitest specs + golden fixtures
│ ├── *.test.ts

View File

@@ -1,6 +1,7 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import globals from 'globals';
export default [
@@ -44,4 +45,12 @@ export default [
'react-hooks/refs': 'off',
},
},
{
files: ['**/*.tsx'],
plugins: { 'jsx-a11y': jsxA11y },
rules: {
...jsxA11y.flatConfigs.recommended.rules,
'jsx-a11y/no-autofocus': 'off',
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -21,16 +21,16 @@
"gen:zod": "cd .. && go run ./tools/openapigen"
},
"dependencies": {
"@ant-design/icons": "^6.2.5",
"@ant-design/icons": "^6.3.2",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-query-devtools": "^5.101.1",
"antd": "^6.4.5",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"antd": "^6.5.0",
"axios": "^1.18.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.2",
"i18next": "^26.3.3",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"qs": "^6.15.3",
@@ -51,7 +51,8 @@
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.5.0",
"eslint": "^10.6.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
@@ -61,6 +62,9 @@
"vitest": "^4.1.9"
},
"overrides": {
"eslint-plugin-jsx-a11y": {
"eslint": "$eslint"
},
"dompurify": "^3.4.11",
"react-copy-to-clipboard": "^5.1.1",
"react-inspector": "^9.0.0",

View File

@@ -84,6 +84,9 @@
"ldapInboundTags": {
"type": "string"
},
"ldapInsecureSkipVerify": {
"type": "boolean"
},
"ldapInvertFlag": {
"type": "boolean"
},
@@ -429,6 +432,7 @@
"ldapFlagField",
"ldapHost",
"ldapInboundTags",
"ldapInsecureSkipVerify",
"ldapInvertFlag",
"ldapPassword",
"ldapPort",
@@ -589,6 +593,9 @@
"ldapInboundTags": {
"type": "string"
},
"ldapInsecureSkipVerify": {
"type": "boolean"
},
"ldapInvertFlag": {
"type": "boolean"
},
@@ -941,6 +948,7 @@
"ldapFlagField",
"ldapHost",
"ldapInboundTags",
"ldapInsecureSkipVerify",
"ldapInvertFlag",
"ldapPassword",
"ldapPort",
@@ -1084,6 +1092,12 @@
"Client": {
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
"properties": {
"allowedIPs": {
"items": {
"type": "string"
},
"type": "array"
},
"auth": {
"description": "Auth password (Hysteria)",
"type": "string"
@@ -1120,6 +1134,9 @@
"description": "Unique client identifier",
"type": "string"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"description": "IP limit for this client",
"type": "integer"
@@ -1128,6 +1145,15 @@
"description": "Client password",
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"description": "Reset period in days",
"type": "integer"
@@ -1201,6 +1227,9 @@
},
"ClientRecord": {
"properties": {
"allowedIPs": {
"type": "string"
},
"auth": {
"type": "string"
},
@@ -1228,12 +1257,24 @@
"id": {
"type": "integer"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"type": "integer"
},
"password": {
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"type": "integer"
},
@@ -1258,6 +1299,7 @@
}
},
"required": [
"allowedIPs",
"auth",
"comment",
"createdAt",
@@ -1267,8 +1309,12 @@
"flow",
"group",
"id",
"keepAlive",
"limitIp",
"password",
"preSharedKey",
"privateKey",
"publicKey",
"reset",
"reverse",
"security",
@@ -1525,7 +1571,8 @@
"type": "string"
},
"vlessRoute": {
"description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
"description": "Single VLESS route value (0-65535) baked into the subscription UUID's 3rd\ngroup (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.",
"example": "443",
"type": "string"
}
},
@@ -1808,6 +1855,15 @@
"tlsFlowCapable": {
"example": true,
"type": "boolean"
},
"wgDns": {
"type": "string"
},
"wgMtu": {
"type": "integer"
},
"wgPublicKey": {
"type": "string"
}
},
"required": [
@@ -2146,6 +2202,104 @@
],
"type": "object"
},
"RealityScanResult": {
"properties": {
"alpn": {
"example": "h2",
"type": "string"
},
"certIssuer": {
"example": "Google Trust Services",
"type": "string"
},
"certSubject": {
"example": "cloudflare.com",
"type": "string"
},
"certValid": {
"example": true,
"type": "boolean"
},
"curveID": {
"example": "X25519",
"type": "string"
},
"feasible": {
"example": true,
"type": "boolean"
},
"h2": {
"example": true,
"type": "boolean"
},
"host": {
"example": "www.cloudflare.com",
"type": "string"
},
"ip": {
"example": "104.16.124.96",
"type": "string"
},
"latencyMs": {
"example": 180,
"type": "integer"
},
"notAfter": {
"example": "2026-08-01T00:00:00Z",
"type": "string"
},
"port": {
"example": 443,
"type": "integer"
},
"reason": {
"type": "string"
},
"serverNames": {
"items": {
"type": "string"
},
"type": "array"
},
"target": {
"example": "www.cloudflare.com:443",
"type": "string"
},
"tls13": {
"example": true,
"type": "boolean"
},
"tlsVersion": {
"example": "1.3",
"type": "string"
},
"x25519": {
"example": true,
"type": "boolean"
}
},
"required": [
"alpn",
"certIssuer",
"certSubject",
"certValid",
"curveID",
"feasible",
"h2",
"host",
"ip",
"latencyMs",
"notAfter",
"port",
"reason",
"serverNames",
"target",
"tls13",
"tlsVersion",
"x25519"
],
"type": "object"
},
"Setting": {
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
"properties": {
@@ -2607,7 +2761,10 @@
"remark": "VLESS-443",
"ssMethod": "",
"tag": "in-443-tcp",
"tlsFlowCapable": true
"tlsFlowCapable": true,
"wgDns": "",
"wgMtu": 0,
"wgPublicKey": ""
}
]
}
@@ -2617,6 +2774,43 @@
}
}
},
"/panel/api/inbounds/allLinks": {
"get": {
"tags": [
"Inbounds"
],
"summary": "Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panels \"Export all inbound links\" action.",
"operationId": "get_panel_api_inbounds_allLinks",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": [
"vless://uuid@host:443?security=reality&...#Germany-alice",
"vmess://eyJ2IjoyLC..."
]
}
}
}
}
}
}
},
"/panel/api/inbounds/get/{id}": {
"get": {
"tags": [
@@ -4637,6 +4831,145 @@
}
}
},
"/panel/api/server/scanRealityTarget": {
"post": {
"tags": [
"Server"
],
"summary": "Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.",
"operationId": "post_panel_api_server_scanRealityTarget",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"$ref": "#/components/schemas/RealityScanResult"
}
}
},
"example": {
"success": true,
"obj": {
"alpn": "h2",
"certIssuer": "Google Trust Services",
"certSubject": "cloudflare.com",
"certValid": true,
"curveID": "X25519",
"feasible": true,
"h2": true,
"host": "www.cloudflare.com",
"ip": "104.16.124.96",
"latencyMs": 180,
"notAfter": "2026-08-01T00:00:00Z",
"port": 443,
"reason": "",
"serverNames": [
""
],
"target": "www.cloudflare.com:443",
"tls13": true,
"tlsVersion": "1.3",
"x25519": true
}
}
}
}
}
}
}
},
"/panel/api/server/scanRealityTargets": {
"post": {
"tags": [
"Server"
],
"summary": "Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.",
"operationId": "post_panel_api_server_scanRealityTargets",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RealityScanResult"
}
}
}
},
"example": {
"success": true,
"obj": [
{
"alpn": "h2",
"certIssuer": "Google Trust Services",
"certSubject": "cloudflare.com",
"certValid": true,
"curveID": "X25519",
"feasible": true,
"h2": true,
"host": "www.cloudflare.com",
"ip": "104.16.124.96",
"latencyMs": 180,
"notAfter": "2026-08-01T00:00:00Z",
"port": 443,
"reason": "",
"serverNames": [
""
],
"target": "www.cloudflare.com:443",
"tls13": true,
"tlsVersion": "1.3",
"x25519": true
}
]
}
}
}
}
}
}
},
"/panel/api/server/clientIps": {
"get": {
"tags": [
@@ -5516,7 +5849,7 @@
"tags": [
"Clients"
],
"summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
"summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
"operationId": "post_panel_api_clients_bulkAdjust",
"requestBody": {
"required": true,
@@ -6345,6 +6678,55 @@
}
}
},
"/panel/api/clients/groups/resetTraffic": {
"post": {
"tags": [
"Clients"
],
"summary": "Reset only the group-level traffic counter shown on the groups page. Snapshots the current up/down sum of the group's members as a baseline so the group total reads zero, while leaving each client's own counters (and their quotas) untouched. No Xray restart is triggered. Creates the client_groups row if the group exists only as a derived label.",
"operationId": "post_panel_api_clients_groups_resetTraffic",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"name": "customer-a"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": {
"name": "customer-a"
}
}
}
}
}
}
}
},
"/panel/api/clients/resetTraffic/{email}": {
"post": {
"tags": [
@@ -7749,7 +8131,7 @@
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": ""
"vlessRoute": "443"
}
]
}
@@ -7841,7 +8223,7 @@
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": ""
"vlessRoute": "443"
}
}
}
@@ -7936,7 +8318,7 @@
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": ""
"vlessRoute": "443"
}
]
}
@@ -8076,7 +8458,7 @@
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": ""
"vlessRoute": "443"
}
}
}
@@ -8188,7 +8570,7 @@
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": ""
"vlessRoute": "443"
}
}
}

View File

@@ -7,6 +7,8 @@ import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
type SettingSavePayload = Partial<AllSetting> & Record<string, unknown>;
async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
@@ -42,19 +44,21 @@ export function useAllSettings() {
}, []);
const saveMut = useMutation({
mutationFn: async (next: AllSetting): Promise<Msg<unknown>> => {
const body = AllSettingSchema.partial().safeParse(next);
mutationFn: async (next: SettingSavePayload): Promise<Msg<unknown>> => {
const payload = { ...next };
const body = AllSettingSchema.partial().safeParse(payload);
if (!body.success) {
console.warn('[zod] setting/update body failed validation', body.error.issues);
}
return HttpUtil.post('/panel/api/setting/update', body.success ? body.data : next);
return HttpUtil.post('/panel/api/setting/update', body.success ? { ...payload, ...body.data } : payload);
},
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
},
});
const saveAll = useCallback(() => saveMut.mutateAsync(draft), [saveMut, draft]);
const saveAll = useCallback(() => saveMut.mutateAsync({ ...draft }), [saveMut, draft]);
const savePayload = useCallback((payload: SettingSavePayload) => saveMut.mutateAsync(payload), [saveMut]);
const saveDisabled = useMemo(() => server.equals(draft), [server, draft]);
return {
@@ -65,5 +69,6 @@ export function useAllSettings() {
setSpinning: setExtraSpinning,
saveDisabled,
saveAll,
savePayload,
};
}

View File

@@ -0,0 +1,40 @@
.config-block {
margin-bottom: 10px;
}
.config-block .ant-collapse-header {
align-items: center !important;
padding: 8px 12px !important;
}
.config-block .ant-collapse-header-text {
flex: 1;
min-width: 0;
}
.config-block .ant-collapse-extra {
display: flex;
align-items: center;
}
.config-block-actions {
display: flex;
align-items: center;
gap: 4px;
}
.config-block .ant-collapse-content-box {
padding: 8px !important;
}
.config-block-text {
display: block;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
white-space: pre-wrap;
padding: 6px 8px;
background: var(--ant-color-fill-tertiary);
border-radius: 4px;
user-select: all;
}

View File

@@ -0,0 +1,81 @@
import type { MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Collapse, Popover, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, DownloadOutlined, QrcodeOutlined } from '@ant-design/icons';
import { ClipboardManager, FileManager } from '@/utils';
import { QrPanel } from '@/pages/inbounds/qr';
import './ConfigBlock.css';
interface ConfigBlockProps {
label: string;
text: string;
fileName: string;
qrRemark?: string;
showQr?: boolean;
tagColor?: string;
defaultOpen?: boolean;
}
export default function ConfigBlock({
label,
text,
fileName,
qrRemark = '',
showQr = true,
tagColor = 'gold',
defaultOpen = false,
}: ConfigBlockProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
async function copy() {
const ok = await ClipboardManager.copyText(text);
if (ok) messageApi.success(t('copied'));
}
const actions = (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
</Tooltip>
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
onClick={() => FileManager.downloadTextFile(text, fileName)}
/>
</Tooltip>
{showQr && (
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
)}
</div>
);
return (
<>
{messageContextHolder}
<Collapse
className="config-block"
defaultActiveKey={defaultOpen ? ['cfg'] : []}
items={[{
key: 'cfg',
label: <Tag color={tagColor} style={{ margin: 0, fontWeight: 600, letterSpacing: '0.3px' }}>{label}</Tag>,
extra: actions,
children: <code className="config-block-text">{text}</code>,
}]}
/>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseCircleFilled } from '@ant-design/icons';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
@@ -53,6 +54,7 @@ export default function DateTimePicker({
placeholder = '',
disabled = false,
}: DateTimePickerProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const { isDark, isUltra } = useTheme();
const jalaliRef = useRef<HTMLDivElement>(null);
@@ -100,7 +102,7 @@ export default function DateTimePicker({
<button
type="button"
className="jdp-clear"
aria-label="clear"
aria-label={t('clear')}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Space } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
@@ -74,6 +75,7 @@ function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, strin
}
export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
const { t } = useTranslation();
// Local state holds rows including blanks. Without it, addRow() would
// append a {name:'', value:''} that rowsToMap immediately filters out
// before reaching the form, so the new row would never reach UI. The
@@ -130,7 +132,7 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit
placeholder="Value"
onChange={(e) => setRow(idx, { value: e.target.value })}
/>
<Button icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
<Button aria-label={t('remove')} icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
</Space.Compact>
))}
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>

View File

@@ -1,4 +1,5 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState, Compartment } from '@codemirror/state';
import { json, jsonParseLinter } from '@codemirror/lang-json';
@@ -92,6 +93,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
const onChangeRef = useRef(onChange);
const valueRef = useRef(value);
const { isDark, isUltra } = useTheme();
const { t } = useTranslation();
useEffect(() => {
onChangeRef.current = onChange;
@@ -173,7 +175,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
});
}, [readOnly]);
return <div ref={hostRef} className="json-editor-host" />;
return <div ref={hostRef} className="json-editor-host" aria-label={t('jsonEditor')} />;
});
export default JsonEditor;

View File

@@ -47,7 +47,7 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
maxLength={maxLength}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
addonAfter={
suffix={
<Popover
content={<RemarkVarPicker onPick={insertToken} />}
trigger="click"
@@ -55,7 +55,7 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
title={t('pages.hosts.remarkVars.title')}
>
<Tooltip title={t('pages.hosts.remarkVars.title')}>
<Button type="text" size="small" icon={<CodeOutlined />} style={{ margin: '0 -7px' }} />
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
</Tooltip>
</Popover>
}

View File

@@ -2,6 +2,7 @@ import { Tag, Tooltip, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
import { activateOnKey } from '@/utils/a11y';
interface RemarkVarPickerProps {
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
@@ -28,7 +29,10 @@ export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
<Tag
role="button"
tabIndex={0}
onClick={() => onPick(v.token)}
onKeyDown={activateOnKey(() => onPick(v.token))}
style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
>
{wrapToken(v.token)}

View File

@@ -1,4 +1,5 @@
import type { CSSProperties, ReactNode } from 'react';
import { activateOnKey } from '@/utils/a11y';
import './InputAddon.css';
interface InputAddonProps {
@@ -6,14 +7,19 @@ interface InputAddonProps {
className?: string;
style?: CSSProperties;
onClick?: () => void;
ariaLabel?: string;
}
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
export default function InputAddon({ children, className = '', style, onClick, ariaLabel }: InputAddonProps) {
return (
<span
className={`input-addon ${className}`.trim()}
style={style}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
aria-label={onClick ? ariaLabel : undefined}
onKeyDown={onClick ? activateOnKey(onClick) : undefined}
>
{children}
</span>

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { cloneElement, Fragment, isValidElement, useId, type ReactElement, type ReactNode } from 'react';
import { Col, Row } from 'antd';
import './SettingListItem.css';
@@ -18,17 +18,22 @@ export default function SettingListItem({
control,
}: SettingListItemProps) {
const padding = paddings === 'small' ? '10px 20px' : '20px';
const titleId = useId();
const node = control ?? children;
const labelledNode = title && isValidElement(node) && node.type !== Fragment
? cloneElement(node as ReactElement<{ 'aria-labelledby'?: string }>, { 'aria-labelledby': titleId })
: node;
return (
<div className="setting-list-item" style={{ padding }}>
<Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}>
<div className="setting-list-meta">
{title && <div className="setting-list-title">{title}</div>}
{title && <div className="setting-list-title" id={titleId}>{title}</div>}
{description && <div className="setting-list-description">{description}</div>}
</div>
</Col>
<Col xs={24} lg={12}>
{control ?? children}
{labelledNode}
</Col>
</Row>
</div>

View File

@@ -12,7 +12,7 @@ export function NotificationCard({ icon, title, extra, children }: Props) {
return (
<Card
size="small"
bordered
variant="outlined"
title={<span>{icon} {title}</span>}
extra={extra}
style={{ borderWidth: 1 }}

View File

@@ -40,7 +40,7 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
/>
}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space orientation="vertical" size={8} style={{ width: '100%' }}>
{config.events.map((event) => (
<NotificationEvent
key={event.key}

View File

@@ -1,4 +1,5 @@
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Tag } from 'antd';
interface Props {
@@ -10,11 +11,12 @@ interface Props {
}
function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) {
const { t } = useTranslation();
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
return <input ref={ref} type="checkbox" aria-label={t('pages.clients.selectAll')} checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
}
export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) {

View File

@@ -181,8 +181,18 @@ export default function Sparkline({
const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
const ariaSummary = useMemo(() => {
if (points.length === 0) return name1 ?? '';
const last = points[points.length - 1];
const parts: string[] = [];
parts.push(name1 ? `${name1}: ${yFormatter(last.value)}` : yFormatter(last.value));
if (hasSeries2 && name2) parts.push(`${name2}: ${yFormatter(last.value2)}`);
if (hasSeries3 && name3) parts.push(`${name3}: ${yFormatter(last.value3)}`);
return parts.join(', ');
}, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]);
return (
<div className="sparkline-container">
<div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
{extremaPoints && (
<div className="sparkline-extrema" aria-hidden="true">
<span className="extrema-item" style={{ color: maxColor }}>

View File

@@ -16,6 +16,7 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInsecureSkipVerify": false,
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
@@ -118,6 +119,7 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInsecureSkipVerify": false,
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
@@ -212,6 +214,9 @@ export const EXAMPLES: Record<string, unknown> = {
"token": "new-token-string"
},
"Client": {
"allowedIPs": [
""
],
"auth": "",
"comment": "",
"created_at": 0,
@@ -221,8 +226,12 @@ export const EXAMPLES: Record<string, unknown> = {
"flow": "",
"group": "",
"id": "",
"keepAlive": 0,
"limitIp": 0,
"password": "",
"preSharedKey": "",
"privateKey": "",
"publicKey": "",
"reset": 0,
"reverse": null,
"security": "",
@@ -238,6 +247,7 @@ export const EXAMPLES: Record<string, unknown> = {
"inboundId": 0
},
"ClientRecord": {
"allowedIPs": "",
"auth": "",
"comment": "",
"createdAt": 0,
@@ -247,8 +257,12 @@ export const EXAMPLES: Record<string, unknown> = {
"flow": "",
"group": "",
"id": 0,
"keepAlive": 0,
"limitIp": 0,
"password": "",
"preSharedKey": "",
"privateKey": "",
"publicKey": "",
"reset": 0,
"reverse": null,
"security": "",
@@ -326,7 +340,7 @@ export const EXAMPLES: Record<string, unknown> = {
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": ""
"vlessRoute": "443"
},
"Inbound": {
"clientStats": [
@@ -392,7 +406,10 @@ export const EXAMPLES: Record<string, unknown> = {
"remark": "VLESS-443",
"ssMethod": "",
"tag": "in-443-tcp",
"tlsFlowCapable": true
"tlsFlowCapable": true,
"wgDns": "",
"wgMtu": 0,
"wgPublicKey": ""
},
"Msg": {
"msg": "",
@@ -463,6 +480,28 @@ export const EXAMPLES: Record<string, unknown> = {
"xrayState": "",
"xrayVersion": "25.10.31"
},
"RealityScanResult": {
"alpn": "h2",
"certIssuer": "Google Trust Services",
"certSubject": "cloudflare.com",
"certValid": true,
"curveID": "X25519",
"feasible": true,
"h2": true,
"host": "www.cloudflare.com",
"ip": "104.16.124.96",
"latencyMs": 180,
"notAfter": "2026-08-01T00:00:00Z",
"port": 443,
"reason": "",
"serverNames": [
""
],
"target": "www.cloudflare.com:443",
"tls13": true,
"tlsVersion": "1.3",
"x25519": true
},
"Setting": {
"id": 0,
"key": "",

View File

@@ -58,6 +58,9 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapInboundTags": {
"type": "string"
},
"ldapInsecureSkipVerify": {
"type": "boolean"
},
"ldapInvertFlag": {
"type": "boolean"
},
@@ -403,6 +406,7 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapFlagField",
"ldapHost",
"ldapInboundTags",
"ldapInsecureSkipVerify",
"ldapInvertFlag",
"ldapPassword",
"ldapPort",
@@ -563,6 +567,9 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapInboundTags": {
"type": "string"
},
"ldapInsecureSkipVerify": {
"type": "boolean"
},
"ldapInvertFlag": {
"type": "boolean"
},
@@ -915,6 +922,7 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapFlagField",
"ldapHost",
"ldapInboundTags",
"ldapInsecureSkipVerify",
"ldapInvertFlag",
"ldapPassword",
"ldapPort",
@@ -1058,6 +1066,12 @@ export const SCHEMAS: Record<string, unknown> = {
"Client": {
"description": "Client represents a client configuration for Xray inbounds with traffic limits and settings.",
"properties": {
"allowedIPs": {
"items": {
"type": "string"
},
"type": "array"
},
"auth": {
"description": "Auth password (Hysteria)",
"type": "string"
@@ -1094,6 +1108,9 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Unique client identifier",
"type": "string"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"description": "IP limit for this client",
"type": "integer"
@@ -1102,6 +1119,15 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Client password",
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"description": "Reset period in days",
"type": "integer"
@@ -1175,6 +1201,9 @@ export const SCHEMAS: Record<string, unknown> = {
},
"ClientRecord": {
"properties": {
"allowedIPs": {
"type": "string"
},
"auth": {
"type": "string"
},
@@ -1202,12 +1231,24 @@ export const SCHEMAS: Record<string, unknown> = {
"id": {
"type": "integer"
},
"keepAlive": {
"type": "integer"
},
"limitIp": {
"type": "integer"
},
"password": {
"type": "string"
},
"preSharedKey": {
"type": "string"
},
"privateKey": {
"type": "string"
},
"publicKey": {
"type": "string"
},
"reset": {
"type": "integer"
},
@@ -1232,6 +1273,7 @@ export const SCHEMAS: Record<string, unknown> = {
}
},
"required": [
"allowedIPs",
"auth",
"comment",
"createdAt",
@@ -1241,8 +1283,12 @@ export const SCHEMAS: Record<string, unknown> = {
"flow",
"group",
"id",
"keepAlive",
"limitIp",
"password",
"preSharedKey",
"privateKey",
"publicKey",
"reset",
"reverse",
"security",
@@ -1499,7 +1545,8 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string"
},
"vlessRoute": {
"description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
"description": "Single VLESS route value (0-65535) baked into the subscription UUID's 3rd\ngroup (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.",
"example": "443",
"type": "string"
}
},
@@ -1782,6 +1829,15 @@ export const SCHEMAS: Record<string, unknown> = {
"tlsFlowCapable": {
"example": true,
"type": "boolean"
},
"wgDns": {
"type": "string"
},
"wgMtu": {
"type": "integer"
},
"wgPublicKey": {
"type": "string"
}
},
"required": [
@@ -2120,6 +2176,104 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"RealityScanResult": {
"properties": {
"alpn": {
"example": "h2",
"type": "string"
},
"certIssuer": {
"example": "Google Trust Services",
"type": "string"
},
"certSubject": {
"example": "cloudflare.com",
"type": "string"
},
"certValid": {
"example": true,
"type": "boolean"
},
"curveID": {
"example": "X25519",
"type": "string"
},
"feasible": {
"example": true,
"type": "boolean"
},
"h2": {
"example": true,
"type": "boolean"
},
"host": {
"example": "www.cloudflare.com",
"type": "string"
},
"ip": {
"example": "104.16.124.96",
"type": "string"
},
"latencyMs": {
"example": 180,
"type": "integer"
},
"notAfter": {
"example": "2026-08-01T00:00:00Z",
"type": "string"
},
"port": {
"example": 443,
"type": "integer"
},
"reason": {
"type": "string"
},
"serverNames": {
"items": {
"type": "string"
},
"type": "array"
},
"target": {
"example": "www.cloudflare.com:443",
"type": "string"
},
"tls13": {
"example": true,
"type": "boolean"
},
"tlsVersion": {
"example": "1.3",
"type": "string"
},
"x25519": {
"example": true,
"type": "boolean"
}
},
"required": [
"alpn",
"certIssuer",
"certSubject",
"certValid",
"curveID",
"feasible",
"h2",
"host",
"ip",
"latencyMs",
"notAfter",
"port",
"reason",
"serverNames",
"target",
"tls13",
"tlsVersion",
"x25519"
],
"type": "object"
},
"Setting": {
"description": "Setting stores key-value configuration settings for the 3x-ui panel.",
"properties": {

View File

@@ -22,6 +22,7 @@ export interface AllSetting {
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInsecureSkipVerify: boolean;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
@@ -125,6 +126,7 @@ export interface AllSettingView {
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInsecureSkipVerify: boolean;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
@@ -222,6 +224,7 @@ export interface ApiTokenView {
}
export interface Client {
allowedIPs?: string[];
auth?: string;
comment: string;
created_at?: number;
@@ -231,8 +234,12 @@ export interface Client {
flow?: string;
group?: string;
id?: string;
keepAlive?: number;
limitIp: number;
password?: string;
preSharedKey?: string;
privateKey?: string;
publicKey?: string;
reset: number;
reverse?: ClientReverse | null;
security: string;
@@ -250,6 +257,7 @@ export interface ClientInbound {
}
export interface ClientRecord {
allowedIPs: string;
auth: string;
comment: string;
createdAt: number;
@@ -259,8 +267,12 @@ export interface ClientRecord {
flow: string;
group: string;
id: number;
keepAlive: number;
limitIp: number;
password: string;
preSharedKey: string;
privateKey: string;
publicKey: string;
reset: number;
reverse: unknown;
security: string;
@@ -389,6 +401,9 @@ export interface InboundOption {
ssMethod: string;
tag: string;
tlsFlowCapable: boolean;
wgDns?: string;
wgMtu?: number;
wgPublicKey?: string;
}
export interface Msg {
@@ -462,6 +477,27 @@ export interface ProbeResultUI {
xrayVersion: string;
}
export interface RealityScanResult {
alpn: string;
certIssuer: string;
certSubject: string;
certValid: boolean;
curveID: string;
feasible: boolean;
h2: boolean;
host: string;
ip: string;
latencyMs: number;
notAfter: string;
port: number;
reason: string;
serverNames: string[];
target: string;
tls13: boolean;
tlsVersion: string;
x25519: boolean;
}
export interface Setting {
id: number;
key: string;

View File

@@ -34,6 +34,7 @@ export const AllSettingSchema = z.object({
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInsecureSkipVerify: z.boolean(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
@@ -138,6 +139,7 @@ export const AllSettingViewSchema = z.object({
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInsecureSkipVerify: z.boolean(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
@@ -238,6 +240,7 @@ export const ApiTokenViewSchema = z.object({
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
export const ClientSchema = z.object({
allowedIPs: z.array(z.string()).optional(),
auth: z.string().optional(),
comment: z.string(),
created_at: z.number().int().optional(),
@@ -247,8 +250,12 @@ export const ClientSchema = z.object({
flow: z.string().optional(),
group: z.string().optional(),
id: z.string().optional(),
keepAlive: z.number().int().optional(),
limitIp: z.number().int(),
password: z.string().optional(),
preSharedKey: z.string().optional(),
privateKey: z.string().optional(),
publicKey: z.string().optional(),
reset: z.number().int(),
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
security: z.string(),
@@ -268,6 +275,7 @@ export const ClientInboundSchema = z.object({
export type ClientInbound = z.infer<typeof ClientInboundSchema>;
export const ClientRecordSchema = z.object({
allowedIPs: z.string(),
auth: z.string(),
comment: z.string(),
createdAt: z.number().int(),
@@ -277,8 +285,12 @@ export const ClientRecordSchema = z.object({
flow: z.string(),
group: z.string(),
id: z.number().int(),
keepAlive: z.number().int(),
limitIp: z.number().int(),
password: z.string(),
preSharedKey: z.string(),
privateKey: z.string(),
publicKey: z.string(),
reset: z.number().int(),
reverse: z.unknown(),
security: z.string(),
@@ -416,6 +428,9 @@ export const InboundOptionSchema = z.object({
ssMethod: z.string(),
tag: z.string(),
tlsFlowCapable: z.boolean(),
wgDns: z.string().optional(),
wgMtu: z.number().int().optional(),
wgPublicKey: z.string().optional(),
});
export type InboundOption = z.infer<typeof InboundOptionSchema>;
@@ -494,6 +509,28 @@ export const ProbeResultUISchema = z.object({
});
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
export const RealityScanResultSchema = z.object({
alpn: z.string(),
certIssuer: z.string(),
certSubject: z.string(),
certValid: z.boolean(),
curveID: z.string(),
feasible: z.boolean(),
h2: z.boolean(),
host: z.string(),
ip: z.string(),
latencyMs: z.number().int(),
notAfter: z.string(),
port: z.number().int(),
reason: z.string(),
serverNames: z.array(z.string()),
target: z.string(),
tls13: z.boolean(),
tlsVersion: z.string(),
x25519: z.boolean(),
});
export type RealityScanResult = z.infer<typeof RealityScanResultSchema>;
export const SettingSchema = z.object({
id: z.number().int(),
key: z.string(),

View File

@@ -47,6 +47,7 @@ interface SubSettings {
subJsonEnable: boolean;
subClashURI: string;
subClashEnable: boolean;
publicHost: string;
}
export interface ClientQueryParams {
@@ -240,6 +241,7 @@ export function useClients() {
subJsonEnable: !!defaults.subJsonEnable,
subClashURI: (defaults.subClashURI as string) || '',
subClashEnable: !!defaults.subClashEnable,
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
}), [
defaults.subEnable,
defaults.subURI,
@@ -247,6 +249,8 @@ export function useClients() {
defaults.subJsonEnable,
defaults.subClashURI,
defaults.subClashEnable,
defaults.subDomain,
defaults.webDomain,
]);
const ipLimitEnable = !!defaults.ipLimitEnable;

View File

@@ -75,6 +75,33 @@
font-size: 16px;
}
.sidebar-docs {
background: transparent;
border: none;
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--ant-color-text-secondary);
text-decoration: none;
flex-shrink: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.sidebar-docs:hover,
.sidebar-docs:focus-visible {
background-color: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
color: var(--ant-color-primary);
transform: scale(1.08);
outline: none;
}
.sidebar-docs .anticon {
font-size: 16px;
}
.sidebar-theme-cycle {
background: transparent;
border: none;

View File

@@ -23,6 +23,7 @@ import {
MessageOutlined,
MoonFilled,
MoonOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
SunOutlined,
@@ -40,6 +41,7 @@ import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
@@ -83,6 +85,21 @@ function DonateButton({ ariaLabel }: { ariaLabel: string }) {
);
}
function DocsButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="sidebar-docs"
aria-label={ariaLabel}
title={ariaLabel}
>
<ReadOutlined />
</a>
);
}
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
if (!version) return null;
const label = formatPanelVersion(version);
@@ -254,6 +271,7 @@ export default function AppSidebar() {
</div>
{!collapsed && (
<div className="brand-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle"
@@ -306,6 +324,7 @@ export default function AppSidebar() {
<span className="drawer-brand">3X-UI</span>
</div>
<div className="drawer-header-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle-drawer"
@@ -351,7 +370,7 @@ export default function AppSidebar() {
<button
className="drawer-handle"
type="button"
aria-label={t('menu.dashboard')}
aria-label={t('menu.openMenu')}
onClick={() => setDrawerOpen(true)}
>
<MenuOutlined />

View File

@@ -17,6 +17,7 @@ export type HostLinkInput = Pick<
| 'echConfigList'
| 'overrideSniFromAddress'
| 'keepSniBlank'
| 'vlessRoute'
>;
// hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
@@ -48,5 +49,6 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
verifyPeerCertByName: host.verifyPeerCertByName || undefined,
echConfigList: host.echConfigList || undefined,
vlessRoute: host.vlessRoute || undefined,
};
}

View File

@@ -34,7 +34,12 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
{enabled && (
<>
<Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}>
<Select mode="multiple" className="sniffing-options" options={DEST_OPTIONS} />
<Select
mode="multiple"
className="sniffing-options"
aria-label={t('pages.inbounds.sniffingDestOverride')}
options={DEST_OPTIONS}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingMetadataOnly')}

View File

@@ -3,6 +3,8 @@ import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import type { NamePath } from 'antd/es/form/interface';
import { activateOnKey } from '@/utils/a11y';
// Editor for sockopt.customSockopt — a list of raw setsockopt() options. Each
// entry is rendered as a titled group of labeled fields (system / level / opt /
// type / value) instead of one cramped inline row, so it reads like the rest of
@@ -49,7 +51,11 @@ export default function CustomSockoptList({
<DeleteOutlined
className="danger-icon"
style={{ marginInlineStart: 8 }}
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
</Divider>
<Form.Item label="System" name={[field.name, 'system']}>

View File

@@ -1,10 +1,12 @@
import { useEffect, useRef } from 'react';
import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import type { FormInstance } from 'antd/es/form';
import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value }));
@@ -224,6 +226,7 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
}
function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) {
const { t } = useTranslation();
return (
<Form.List name={[...base, 'tcp']}>
{(fields, { add, remove }) => (
@@ -233,6 +236,7 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })}
/>
</Form.Item>
@@ -265,12 +269,20 @@ function TcpMaskItem({
// type change). All Form.Item `name=` use RELATIVE paths within the
// outer Form.List context.
const absolutePath = [...listPath, fieldName];
const { t } = useTranslation();
return (
<div>
<Divider style={{ margin: 0 }}>
TCP Mask {displayIndex}
<DeleteOutlined className="danger-icon" onClick={onRemove} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={onRemove}
onKeyDown={activateOnKey(onRemove)}
/>
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
@@ -415,12 +427,13 @@ function FragmentRangeList({
validator?: (rule: unknown, value: unknown) => Promise<void>;
minItems?: number;
}) {
const { t } = useTranslation();
return (
<Form.List name={listName}>
{(fields, { add, remove }) => (
<>
<Form.Item label={label}>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => add('')} />
<Button type="primary" size="small" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
</Form.Item>
{fields.map((field, idx) => (
<Form.Item
@@ -431,8 +444,17 @@ function FragmentRangeList({
>
<Input
placeholder={placeholder}
addonAfter={fields.length > minItems
? <DeleteOutlined className="danger-icon" onClick={() => remove(field.name)} />
suffix={fields.length > minItems
? (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
)
: null}
/>
</Form.Item>
@@ -475,6 +497,7 @@ function HeaderCustomGroups({
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
return (
<>
{(['clients', 'servers'] as const).map((groupKey) => (
@@ -486,6 +509,7 @@ function HeaderCustomGroups({
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addGroup([defaultClientServerItem()])}
/>
</Form.Item>
@@ -493,7 +517,14 @@ function HeaderCustomGroups({
<div key={group.key}>
<Divider style={{ margin: 0 }}>
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
<DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeGroup(group.name)}
onKeyDown={activateOnKey(() => removeGroup(group.name))}
/>
</Divider>
<Form.List name={[group.name]}>
{(items, { add: addItem, remove: removeItem }) => (
@@ -502,6 +533,7 @@ function HeaderCustomGroups({
<Button
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addItem(defaultClientServerItem())}
/>
</Form.Item>
@@ -531,6 +563,7 @@ function HeaderCustomGroups({
function UdpMasksList({
base, form, isHysteria, isWireguard, network,
}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) {
const { t } = useTranslation();
return (
<Form.List name={[...base, 'udp']}>
{(fields, { add, remove }) => (
@@ -540,6 +573,7 @@ function UdpMasksList({
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => {
const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy';
add({ type: def, settings: defaultUdpMaskSettings(def) });
@@ -578,6 +612,7 @@ function UdpMaskItem({
onRemove: () => void;
}) {
const absolutePath = [...listPath, fieldName];
const { t } = useTranslation();
const onTypeChange = (v: string) => {
form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v));
@@ -605,7 +640,14 @@ function UdpMaskItem({
<div>
<Divider style={{ margin: 0 }}>
UDP Mask {displayIndex}
<DeleteOutlined className="danger-icon" onClick={onRemove} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={onRemove}
onKeyDown={activateOnKey(onRemove)}
/>
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
@@ -735,6 +777,7 @@ function SalamanderUdpMaskSettings({
form: FormInstance;
absolutePath: (string | number)[];
}) {
const { t } = useTranslation();
const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
@@ -776,6 +819,7 @@ function SalamanderUdpMaskSettings({
</Form.Item>
<Button
icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16),
@@ -810,7 +854,7 @@ function GeckoPacketSizeInput({
return (
<Space.Compact block>
<InputNumber
addonBefore="Min"
prefix="Min"
min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE}
precision={0}
@@ -820,7 +864,7 @@ function GeckoPacketSizeInput({
style={{ width: '50%' }}
/>
<InputNumber
addonBefore="Max"
prefix="Max"
min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE}
precision={0}
@@ -840,6 +884,7 @@ function UdpHeaderCustom({
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
return (
<>
{(['client', 'server'] as const).map((groupKey) => (
@@ -851,6 +896,7 @@ function UdpHeaderCustom({
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultUdpClientServerItem())}
/>
</Form.Item>
@@ -858,7 +904,14 @@ function UdpHeaderCustom({
<div key={item.key}>
<Divider style={{ margin: 0 }}>
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(item.name)}
onKeyDown={activateOnKey(() => remove(item.name))}
/>
</Divider>
<ItemEditor
fieldName={item.name}
@@ -883,6 +936,7 @@ function NoiseItems({
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
return (
<>
<Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
@@ -896,6 +950,7 @@ function NoiseItems({
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultNoiseItem())}
/>
</Form.Item>
@@ -903,7 +958,14 @@ function NoiseItems({
<div key={item.key}>
<Divider style={{ margin: 0 }}>
Noise {ni + 1}
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(item.name)}
onKeyDown={activateOnKey(() => remove(item.name))}
/>
</Divider>
<ItemEditor
fieldName={item.name}
@@ -930,6 +992,7 @@ function ItemEditor({
delayMode?: 'number' | 'string';
onRemove?: () => void;
}) {
const { t } = useTranslation();
const onTypeChange = (v: string) => {
if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
@@ -1005,6 +1068,7 @@ function ItemEditor({
</Form.Item>
<Button
icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
/>
</Space.Compact>

View File

@@ -263,24 +263,20 @@ export interface WireguardInboundSeed {
mtu?: number;
secretKey?: string;
noKernelTun?: boolean;
peerPrivateKey?: string;
}
// WireGuard is multi-client now: a new inbound holds only the server identity
// (secretKey/mtu) and starts with no clients. Clients (peers) are added later
// through the client modal, which generates each one's keypair and a unique
// tunnel address. peers stays empty for backward-compatible parsing.
export function createDefaultWireguardInboundSettings(
seed: WireguardInboundSeed = {},
): WireguardInboundSettings {
const peerKp = seed.peerPrivateKey
? { privateKey: seed.peerPrivateKey, publicKey: Wireguard.generateKeypair(seed.peerPrivateKey).publicKey }
: Wireguard.generateKeypair();
return {
mtu: seed.mtu ?? 1420,
secretKey: seed.secretKey ?? Wireguard.generateKeypair().privateKey,
peers: [{
privateKey: peerKp.privateKey,
publicKey: peerKp.publicKey,
allowedIPs: ['10.0.0.2/32'],
keepAlive: 0,
}],
peers: [],
clients: [],
noKernelTun: seed.noKernelTun ?? false,
};
}

View File

@@ -6,6 +6,7 @@ import {
TrojanClientSchema,
VlessClientSchema,
VmessClientSchema,
WireguardClientSchema,
} from '@/schemas/protocols/inbound';
import type { StreamSettings } from '@/schemas/api/inbound';
import type { Sniffing } from '@/schemas/primitives';
@@ -234,6 +235,7 @@ function clientSchemaForProtocol(protocol: string): z.ZodType | null {
case 'trojan': return TrojanClientSchema;
case 'shadowsocks': return ShadowsocksClientSchema;
case 'hysteria': return HysteriaClientSchema;
case 'wireguard': return WireguardClientSchema;
default: return null;
}
}

View File

@@ -326,6 +326,18 @@ export interface GenVlessLinkInput {
externalProxy?: ExternalProxyEntry | null;
}
// Mirror of the Go applyVlessRoute: bake a single 0-65535 value into the UUID's
// 3rd group (bytes 6-7), which xray reads as the vless route. Empty/invalid/non-
// UUID input is returned unchanged.
export function applyVlessRoute(id: string, route: string | undefined): string {
const r = (route ?? '').trim();
if (r === '' || !/^\d{1,5}$/.test(r)) return id;
const n = Number(r);
if (n > 65535) return id;
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) return id;
return id.slice(0, 14) + n.toString(16).padStart(4, '0') + id.slice(18);
}
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
// query carries network type, encryption, network-specific knobs, and
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
@@ -437,7 +449,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
params.set('flow', flow);
}
const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
const url = new URL(`vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
@@ -828,7 +840,7 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
let txt = `[Interface]\n`;
txt += `PrivateKey = ${peer.privateKey ?? ''}\n`;
txt += `Address = ${peer.allowedIPs[0] ?? ''}\n`;
txt += `DNS = 1.1.1.1, 1.0.0.1\n`;
txt += `DNS = ${settings.dns || '1.1.1.1, 1.0.0.1'}\n`;
if (typeof settings.mtu === 'number' && settings.mtu > 0) {
txt += `MTU = ${settings.mtu}\n`;
}
@@ -846,6 +858,66 @@ export function genWireguardConfig(input: GenWireguardLinkInput): string {
return txt;
}
export function wireguardConfigFromLink(link: string, fallbackRemark = ''): string {
let url: URL;
try {
url = new URL(link);
} catch {
return '';
}
const scheme = url.protocol.replace(/:$/, '');
if (scheme !== 'wireguard' && scheme !== 'wg') return '';
const params = url.searchParams;
const pick = (...keys: string[]): string => {
for (const k of keys) {
const v = params.get(k);
if (v) return v;
}
return '';
};
let privateKey: string;
try {
privateKey = decodeURIComponent(url.username);
} catch {
privateKey = url.username;
}
const host = url.hostname;
const endpoint = host ? (url.port ? `${host}:${url.port}` : host) : '';
const address = pick('address', 'ip') || '10.0.0.2/32';
const publicKey = pick('publickey', 'publicKey', 'public_key', 'peerPublicKey');
const dns = pick('dns') || '1.1.1.1, 1.0.0.1';
const mtu = pick('mtu');
const psk = pick('presharedkey', 'preshared_key', 'pre-shared-key', 'psk');
const keepAlive = pick('keepalive', 'persistentkeepalive', 'persistent_keepalive');
const allowedIPs = pick('allowedips', 'allowed_ips') || '0.0.0.0/0, ::/0';
let remark = fallbackRemark;
try {
const decoded = decodeURIComponent(url.hash.replace(/^#/, ''));
if (decoded) remark = decoded;
} catch {
const raw = url.hash.replace(/^#/, '');
if (raw) remark = raw;
}
const lines = [
'[Interface]',
`PrivateKey = ${privateKey}`,
`Address = ${address}`,
`DNS = ${dns}`,
];
if (mtu && Number(mtu) > 0) lines.push(`MTU = ${mtu}`);
lines.push('');
if (remark) lines.push(`# ${remark}`);
lines.push('[Peer]', `PublicKey = ${publicKey}`);
if (psk) lines.push(`PresharedKey = ${psk}`);
lines.push(`AllowedIPs = ${allowedIPs}`, `Endpoint = ${endpoint}`);
if (keepAlive && Number(keepAlive) > 0) lines.push(`PersistentKeepalive = ${keepAlive}`);
return lines.join('\n');
}
export type { WireguardInboundPeer };
function isUnixSocketListen(listen: string): boolean {
@@ -1126,14 +1198,30 @@ export interface GenWireguardFanoutInput {
fallbackHostname: string;
}
// WireGuard is multi-client: each client is one accepted peer. The canonical
// store is settings.clients; legacy single-config inbounds (pre-migration) are
// still rendered from settings.peers. Both carry the privateKey/allowedIPs/
// preSharedKey/keepAlive the link and .conf need, so they project to the same
// peer shape and reuse genWireguardLink/genWireguardConfig unchanged.
function wgRenderPeers(settings: WireguardInboundSettings): WireguardInboundPeer[] {
const clients = settings.clients ?? [];
if (clients.length > 0) {
return clients.map((c) => ({ ...c, publicKey: c.publicKey ?? '' }));
}
return settings.peers;
}
export function genWireguardLinks(input: GenWireguardFanoutInput): string {
const { inbound, remark = '', hostOverride = '', fallbackHostname } = input;
if (inbound.protocol !== 'wireguard') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = '-';
return inbound.settings.peers
const baseSettings = inbound.settings as WireguardInboundSettings;
const peers = wgRenderPeers(baseSettings);
const settings: WireguardInboundSettings = { ...baseSettings, peers };
return peers
.map((p, i) => genWireguardLink({
settings: inbound.settings as WireguardInboundSettings,
settings,
address: addr,
port: inbound.port,
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,
@@ -1147,9 +1235,12 @@ export function genWireguardConfigs(input: GenWireguardFanoutInput): string {
if (inbound.protocol !== 'wireguard') return '';
const addr = resolveAddr(inbound, hostOverride, fallbackHostname);
const sep = '-';
return inbound.settings.peers
const baseSettings = inbound.settings as WireguardInboundSettings;
const peers = wgRenderPeers(baseSettings);
const settings: WireguardInboundSettings = { ...baseSettings, peers };
return peers
.map((p, i) => genWireguardConfig({
settings: inbound.settings as WireguardInboundSettings,
settings,
address: addr,
port: inbound.port,
remark: `${remark}${sep}${i + 1}${wgPeerCommentSuffix(p)}`,

View File

@@ -1,23 +0,0 @@
export interface RealityTarget {
target: string;
sni: string;
}
export const REALITY_TARGETS: readonly RealityTarget[] = [
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
{ target: 'www.sony.com:443', sni: 'www.sony.com' },
];
export function getRandomRealityTarget(): RealityTarget {
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
const selected = REALITY_TARGETS[randomIndex];
return {
target: selected.target,
sni: selected.sni,
};
}

View File

@@ -68,6 +68,7 @@ export class AllSetting {
ldapHost = '';
ldapPort = 389;
ldapUseTLS = false;
ldapInsecureSkipVerify = false;
ldapBindDN = '';
ldapPassword = '';
ldapBaseDN = '';

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Layout } from 'antd';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
@@ -12,6 +13,7 @@ const openApiUrl = `${basePath}panel/api/openapi.json`;
export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { t } = useTranslation();
const pageClass = useMemo(() => {
const classes = ['api-docs-page'];
@@ -27,7 +29,7 @@ export default function ApiDocsPage() {
<Layout className="content-shell">
<Layout.Content className="content-area">
<div className="docs-wrapper">
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<SwaggerUI
url={openApiUrl}
docExpansion="list"

View File

@@ -126,6 +126,14 @@ export const sections: readonly Section[] = [
responseSchema: 'InboundOption',
responseSchemaArray: true,
},
{
method: 'GET',
path: '/panel/api/inbounds/allLinks',
summary:
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panels "Export all inbound links" action.',
response:
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#Germany-alice",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/inbounds/get/:id',
@@ -489,6 +497,27 @@ export const sections: readonly Section[] = [
body: 'server=cloudflare-dns.com',
response: '{\n "success": true,\n "obj": [\n "e8e2d3..."\n ]\n}',
},
{
method: 'POST',
path: '/panel/api/server/scanRealityTarget',
summary: 'Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.',
params: [
{ name: 'target', in: 'body (form)', type: 'string', desc: 'Candidate target as host or host:port (default port 443), e.g. www.cloudflare.com:443.' },
],
body: 'target=www.cloudflare.com:443',
responseSchema: 'RealityScanResult',
},
{
method: 'POST',
path: '/panel/api/server/scanRealityTargets',
summary: 'Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.',
params: [
{ name: 'targets', in: 'body (form)', type: 'string', optional: true, desc: 'Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, a built-in seed list is probed.' },
],
body: 'targets=104.16.0.0/24,www.apple.com:443',
responseSchema: 'RealityScanResult',
responseSchemaArray: true,
},
{
method: 'GET',
path: '/panel/api/server/clientIps',
@@ -644,7 +673,7 @@ export const sections: readonly Section[] = [
{
method: 'POST',
path: '/panel/api/clients/bulkAdjust',
summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200,\n "flow": "xtls-rprx-vision"\n}',
response: '{\n "success": true,\n "obj": {\n "adjusted": 2,\n "skipped": [\n { "email": "carol", "reason": "unlimited expiry" }\n ]\n }\n}',
},
@@ -755,6 +784,13 @@ export const sections: readonly Section[] = [
body: '{\n "name": "customer-a"\n}',
response: '{\n "success": true,\n "obj": {\n "affected": 5\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/groups/resetTraffic',
summary: 'Reset only the group-level traffic counter shown on the groups page. Snapshots the current up/down sum of the group\'s members as a baseline so the group total reads zero, while leaving each client\'s own counters (and their quotas) untouched. No Xray restart is triggered. Creates the client_groups row if the group exists only as a derived label.',
body: '{\n "name": "customer-a"\n}',
response: '{\n "success": true,\n "obj": {\n "name": "customer-a"\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/resetTraffic/:email',

View File

@@ -81,7 +81,7 @@ export default function BulkAttachInboundsModal({
{t('pages.clients.attachToInboundsDesc', { count })}
</Typography.Paragraph>
{targetOptions.length === 0 ? (
<Alert type="info" showIcon message={t('pages.clients.attachToInboundsNoTargets')} />
<Alert type="info" showIcon title={t('pages.clients.attachToInboundsNoTargets')} />
) : (
<>
<SelectAllClearButtons

View File

@@ -81,7 +81,7 @@ export default function BulkDetachInboundsModal({
{t('pages.clients.detachFromInboundsDesc', { count })}
</Typography.Paragraph>
{targetOptions.length === 0 ? (
<Alert type="info" showIcon message={t('pages.clients.detachFromInboundsNoTargets')} />
<Alert type="info" showIcon title={t('pages.clients.detachFromInboundsNoTargets')} />
) : (
<>
<SelectAllClearButtons

View File

@@ -16,7 +16,7 @@ import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
]);
interface ClientBulkAddModalProps {
@@ -280,6 +280,7 @@ export default function ClientBulkAddModal({
style={{ flex: 1 }}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
/>

View File

@@ -22,7 +22,7 @@ import {
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { HttpUtil, RandomUtil } from '@/utils';
import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
@@ -35,7 +35,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
const MULTI_CLIENT_PROTOCOLS = new Set([
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria',
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -113,6 +113,10 @@ interface FormState {
enable: boolean;
inboundIds: number[];
externalLinks: ExternalLinkRow[];
wgPrivateKey: string;
wgPublicKey: string;
wgPreSharedKey: string;
wgAllowedIPs: string;
}
function emptyForm(): FormState {
@@ -137,6 +141,10 @@ function emptyForm(): FormState {
enable: true,
inboundIds: [],
externalLinks: [],
wgPrivateKey: '',
wgPublicKey: '',
wgPreSharedKey: '',
wgAllowedIPs: '',
};
}
@@ -237,6 +245,10 @@ export default function ClientFormModal({
enable: !!client.enable,
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
externalLinks: toExternalLinkRows(attachedExternalLinks),
wgPrivateKey: client.privateKey || '',
wgPublicKey: client.publicKey || '',
wgPreSharedKey: client.preSharedKey || '',
wgAllowedIPs: client.allowedIPs || '',
};
if (et < 0) {
next.delayedStart = true;
@@ -250,6 +262,7 @@ export default function ClientFormModal({
setForm(next);
void loadIps();
} else {
const wgKeypair = Wireguard.generateKeypair();
setForm({
...emptyForm(),
email: RandomUtil.randomLowerAndNum(10),
@@ -257,6 +270,8 @@ export default function ClientFormModal({
subId: RandomUtil.randomLowerAndNum(16),
password: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
wgPrivateKey: wgKeypair.privateKey,
wgPublicKey: wgKeypair.publicKey,
});
}
@@ -287,6 +302,14 @@ export default function ClientFormModal({
return ids;
}, [inbounds]);
const wireguardIds = useMemo(() => {
const ids = new Set<number>();
for (const row of inbounds || []) {
if (row && row.protocol === 'wireguard') ids.add(row.id);
}
return ids;
}, [inbounds]);
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
@@ -317,6 +340,16 @@ export default function ClientFormModal({
[form.inboundIds, vmessIds],
);
const showWireguard = useMemo(
() => (form.inboundIds || []).some((id) => wireguardIds.has(id)),
[form.inboundIds, wireguardIds],
);
function regenerateWireguardKeys() {
const kp = Wireguard.generateKeypair();
setForm((prev) => ({ ...prev, wgPrivateKey: kp.privateKey, wgPublicKey: kp.publicKey }));
}
useEffect(() => {
if (!showFlow && form.flow) {
@@ -453,6 +486,14 @@ export default function ClientFormModal({
clientPayload.reverse = { tag: reverseTag };
}
if (showWireguard) {
clientPayload.privateKey = form.wgPrivateKey;
clientPayload.publicKey = form.wgPublicKey;
if (form.wgPreSharedKey) {
clientPayload.preSharedKey = form.wgPreSharedKey;
}
}
const externalLinks: ExternalLinkInput[] = form.externalLinks
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
.filter((r) => r.value !== '');
@@ -541,7 +582,7 @@ export default function ClientFormModal({
onChange={(e) => update('email', e.target.value)}
/>
{!isEdit && (
<Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
)}
</Space.Compact>
</Form.Item>
@@ -562,7 +603,7 @@ export default function ClientFormModal({
onChange={(v) => update('limitIp', Number(v) || 0)} />
{isEdit && (
<Tooltip title={t('pages.clients.ipLog')}>
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
<Button aria-label={t('pages.clients.ipLog')} icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</Tooltip>
@@ -676,7 +717,7 @@ export default function ClientFormModal({
</Form.Item>
<Form.Item>
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
<Switch aria-label={t('enable')} checked={form.enable} onChange={(v) => update('enable', v)} />
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
</Form.Item>
</>
@@ -690,28 +731,28 @@ export default function ClientFormModal({
<Form.Item label={t('pages.clients.uuid')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regeneratePassword} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.hysteriaAuth')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
@@ -736,6 +777,38 @@ export default function ClientFormModal({
/>
</Form.Item>
)}
{showWireguard && (
<>
<Form.Item label={t('pages.clients.wireguardPrivateKey')}>
<Space.Compact style={{ display: 'flex' }}>
<Input
value={form.wgPrivateKey}
style={{ flex: 1 }}
onChange={(e) => {
const priv = e.target.value;
update('wgPrivateKey', priv);
update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
}}
/>
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.wireguardPublicKey')}>
<Input value={form.wgPublicKey} disabled />
</Form.Item>
<Form.Item label={t('pages.clients.wireguardPreSharedKey')}>
<Input
value={form.wgPreSharedKey}
onChange={(e) => update('wgPreSharedKey', e.target.value)}
/>
</Form.Item>
{isEdit && form.wgAllowedIPs && (
<Form.Item label={t('pages.clients.wireguardAllowedIPs')}>
<Input value={form.wgAllowedIPs} disabled />
</Form.Item>
)}
</>
)}
</>
),
},
@@ -758,11 +831,12 @@ export default function ClientFormModal({
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
/>
<Tooltip title={t('delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
</Tooltip>
</div>
))}
@@ -778,11 +852,12 @@ export default function ClientFormModal({
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
aria-label="https://provider.example/sub/…"
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="https://provider.example/sub/…"
/>
<Tooltip title={t('delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
</Tooltip>
</div>
))}

View File

@@ -11,6 +11,8 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
import { QrPanel } from '@/pages/inbounds/qr';
import ConfigBlock from '@/components/clients/ConfigBlock';
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
import './ClientInfoModal.css';
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -35,6 +37,7 @@ interface SubSettings {
subJsonEnable: boolean;
subClashURI: string;
subClashEnable: boolean;
publicHost?: string;
}
interface ClientInfoModalProps {
@@ -58,6 +61,7 @@ const DEFAULT_SUB: SubSettings = {
subJsonEnable: false,
subClashURI: '',
subClashEnable: false,
publicHost: '',
};
export default function ClientInfoModal({
@@ -132,6 +136,11 @@ export default function ClientInfoModal({
}, [client?.subId, subSettings?.subClashEnable, subSettings?.subClashURI]);
const showSubscription = !!(subSettings?.enable && client?.subId);
const wgInbound = useMemo(() => findWireguardInbound(client, inboundsById), [client, inboundsById]);
const wgConfigText = useMemo(() => {
if (!client || !wgInbound || !isWireguardClient(client)) return '';
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
}, [client, wgInbound, subSettings?.publicHost]);
async function copyValue(text: string) {
if (!text) return;
@@ -211,7 +220,7 @@ export default function ClientInfoModal({
<td>
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
{client.subId && (
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.subId!)} />
)}
</td>
</tr>
@@ -220,7 +229,7 @@ export default function ClientInfoModal({
<td>{t('pages.clients.uuid')}</td>
<td>
<Tag className="info-large-tag">{client.uuid}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.uuid!)} />
</td>
</tr>
)}
@@ -229,7 +238,7 @@ export default function ClientInfoModal({
<td>{t('password')}</td>
<td>
<Tag className="info-large-tag">{client.password}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.password!)} />
</td>
</tr>
)}
@@ -238,7 +247,7 @@ export default function ClientInfoModal({
<td>{t('pages.clients.auth')}</td>
<td>
<Tag className="info-large-tag">{client.auth}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.auth!)} />
</td>
</tr>
)}
@@ -286,7 +295,7 @@ export default function ClientInfoModal({
<tr>
<td>{t('pages.inbounds.IPLimitlog')}</td>
<td>
<Button size="small" icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
<Button size="small" icon={<EyeOutlined />} aria-label={t('pages.clients.ipLog')} loading={ipsLoading} onClick={openIpsModal}>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</td>
@@ -350,44 +359,6 @@ export default function ClientInfoModal({
</tbody>
</table>
{links.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{links.map((link, idx) => {
const parts = parseLinkParts(link);
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
const rowTitle = (parts && linkMetaText(parts)) || fallback;
const qrRemark = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link);
return (
<div key={idx} className="link-row">
{parts
? <LinkTags parts={parts} />
: <Tag className="link-row-tag">LINK</Tag>}
<span className="link-row-title" title={rowTitle}>{rowTitle}</span>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
</Tooltip>
{canQr && (
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={<QrPanel value={link} remark={qrRemark} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} />
</Tooltip>
</Popover>
)}
</div>
</div>
);
})}
</>
)}
{showSubscription && subLink && (
<>
<Divider>{t('subscription.title')}</Divider>
@@ -404,7 +375,7 @@ export default function ClientInfoModal({
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
</Tooltip>
<Popover
trigger="click"
@@ -413,7 +384,7 @@ export default function ClientInfoModal({
content={<QrPanel value={subLink} remark={`${client.email}${t('subscription.title')}`} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} />
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
</div>
@@ -432,7 +403,7 @@ export default function ClientInfoModal({
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
</Tooltip>
<Popover
trigger="click"
@@ -441,7 +412,7 @@ export default function ClientInfoModal({
content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} />
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
</div>
@@ -463,7 +434,7 @@ export default function ClientInfoModal({
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subClashLink)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
</Tooltip>
<Popover
trigger="click"
@@ -472,7 +443,7 @@ export default function ClientInfoModal({
content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} />
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
</div>
@@ -480,6 +451,56 @@ export default function ClientInfoModal({
)}
</>
)}
{links.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{links.map((link, idx) => {
const parts = parseLinkParts(link);
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
const rowTitle = (parts && linkMetaText(parts)) || fallback;
const qrRemark = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link);
return (
<div key={idx} className="link-row">
{parts
? <LinkTags parts={parts} />
: <Tag className="link-row-tag">LINK</Tag>}
<span className="link-row-title" title={rowTitle}>{rowTitle}</span>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(link)} />
</Tooltip>
{canQr && (
<Popover
trigger="click"
placement="left"
destroyOnHidden
content={<QrPanel value={link} remark={qrRemark} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
)}
</div>
</div>
);
})}
</>
)}
{wgConfigText && client && (
<>
<Divider>{t('pages.clients.wireguardConfig')}</Divider>
<ConfigBlock
label={t('pages.clients.config')}
text={wgConfigText}
fileName={`${client.email}.conf`}
qrRemark={client.email || 'peer'}
/>
</>
)}
</>
)}
</Modal>

View File

@@ -1,22 +1,25 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Modal, Spin } from 'antd';
import { Collapse, Modal, Spin, Tag } from 'antd';
import { HttpUtil } from '@/utils';
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
import { QrPanel } from '@/pages/inbounds/qr';
import type { ClientRecord } from '@/hooks/useClients';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import { buildWireguardClientConfig, findWireguardInbound, isWireguardClient } from './wireguardConfig';
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
publicHost?: string;
}
interface ClientQrModalProps {
open: boolean;
client: ClientRecord | null;
inboundsById: Record<number, InboundOption>;
subSettings?: SubSettings;
onOpenChange: (open: boolean) => void;
}
@@ -26,11 +29,12 @@ interface ApiMsg<T = unknown> {
obj?: T;
}
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false };
const DEFAULT_SUB: SubSettings = { enable: false, subURI: '', subJsonURI: '', subJsonEnable: false, publicHost: '' };
export default function ClientQrModal({
open,
client,
inboundsById,
subSettings = DEFAULT_SUB,
onOpenChange,
}: ClientQrModalProps) {
@@ -49,7 +53,13 @@ export default function ClientQrModal({
return subSettings.subJsonURI + client.subId;
}, [client?.subId, subSettings?.enable, subSettings?.subJsonEnable, subSettings?.subJsonURI]);
const hasAnything = !!subLink || !!subJsonLink || links.length > 0;
const wgInbound = useMemo(() => findWireguardInbound(client, inboundsById), [client, inboundsById]);
const wgConfigText = useMemo(() => {
if (!client || !wgInbound || !isWireguardClient(client)) return '';
return buildWireguardClientConfig(client, wgInbound, window.location.hostname, subSettings?.publicHost ?? '');
}, [client, wgInbound, subSettings?.publicHost]);
const hasAnything = !!subLink || !!subJsonLink || !!wgConfigText || links.length > 0;
useEffect(() => {
if (!open || !client?.subId) {
@@ -112,8 +122,21 @@ export default function ClientQrModal({
),
});
});
if (wgConfigText) {
out.push({
key: 'wg-config',
label: <Tag color="cyan" style={{ margin: 0 }}>{t('pages.clients.wireguardConfig')}</Tag>,
children: (
<QrPanel
value={wgConfigText}
remark={client?.email || 'peer'}
downloadName={`${client?.email || 'peer'}.conf`}
/>
),
});
}
return out;
}, [subLink, subJsonLink, links, client?.email, t]);
}, [subLink, subJsonLink, wgConfigText, links, client?.email, t]);
useEffect(() => {
if (!open) {

View File

@@ -50,6 +50,7 @@ import {
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
import { activateOnKey } from '@/utils/a11y';
import { useTheme } from '@/hooks/useTheme';
import { formatInboundLabel } from '@/lib/inbounds/label';
@@ -752,19 +753,19 @@ export default function ClientsPage() {
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
@@ -1039,7 +1040,7 @@ export default function ClientsPage() {
title={
<div className="card-toolbar">
{selectedRowKeys.length === 0 ? (
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd} aria-label={t('pages.clients.addClients')}>
{!isMobile && t('pages.clients.addClients')}
</Button>
) : (
@@ -1154,7 +1155,7 @@ export default function ClientsPage() {
],
}}
>
<Button icon={<MoreOutlined />}>
<Button icon={<MoreOutlined />} aria-label={t('more')}>
{!isMobile && t('more')}
</Button>
</Dropdown>
@@ -1164,6 +1165,7 @@ export default function ClientsPage() {
icon={<DeleteOutlined />}
onClick={onBulkDelete}
style={{ marginInlineStart: 'auto' }}
aria-label={t('delete')}
>
{!isMobile && t('delete')}
</Button>
@@ -1180,6 +1182,7 @@ export default function ClientsPage() {
prefix={<SearchOutlined />}
size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 320 }}
aria-label={t('search')}
/>
<Badge count={activeCount} size="small" offset={[-4, 4]}>
<Button
@@ -1187,12 +1190,14 @@ export default function ClientsPage() {
size={isMobile ? 'small' : 'middle'}
onClick={() => setFilterDrawerOpen(true)}
type={activeCount > 0 ? 'primary' : 'default'}
aria-label={t('filter')}
>
{!isMobile && t('filter')}
</Button>
</Badge>
<Select
value={sortValueFor(sortColumn, sortOrder)}
aria-label={t('sort')}
size={isMobile ? 'small' : 'middle'}
suffixIcon={<SortAscendingOutlined />}
style={{ minWidth: isMobile ? 130 : 200 }}
@@ -1365,9 +1370,16 @@ export default function ClientsPage() {
<span className="tag-name">{row.email}</span>
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<div className="card-actions">
<Tooltip title={t('pages.clients.clientInfo')}>
<InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(row)}
onKeyDown={activateOnKey(() => onShowInfo(row))}
/>
</Tooltip>
<Switch
checked={!!row.enable}
@@ -1404,7 +1416,7 @@ export default function ClientsPage() {
],
}}
>
<MoreOutlined className="row-action-trigger" />
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
</div>
@@ -1459,6 +1471,7 @@ export default function ClientsPage() {
<ClientQrModal
open={qrOpen}
client={qrClient}
inboundsById={inboundsById}
subSettings={subSettings}
onOpenChange={setQrOpen}
/>

View File

@@ -111,7 +111,7 @@ export default function SubLinksModal({
key: 'actions',
width: 64,
render: (_v, row) => (
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
<Button size="small" type="text" aria-label={t('copy')} icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
),
},
];

View File

@@ -0,0 +1,44 @@
import { formatInboundLabel } from '@/lib/inbounds/label';
import { preferPublicHost } from '@/lib/xray/inbound-link';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
export function isWireguardClient(client: ClientRecord | null | undefined): boolean {
if (!client) return false;
return !!(client.privateKey || client.publicKey || client.allowedIPs || client.preSharedKey || client.keepAlive);
}
export function findWireguardInbound(
client: ClientRecord | null | undefined,
inboundsById: Record<number, InboundOption>,
): InboundOption | undefined {
return (client?.inboundIds || [])
.map((id) => inboundsById[id])
.find((ib) => ib?.protocol === 'wireguard');
}
export function buildWireguardClientConfig(
client: ClientRecord,
inbound: InboundOption | undefined,
host = window.location.hostname,
publicHost = '',
): string {
const endpointHost = preferPublicHost(host, publicHost);
const address = client.allowedIPs || '10.0.0.2/32';
const endpoint = `${endpointHost}:${inbound?.port || ''}`;
const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
const lines = [
'[Interface]',
`PrivateKey = ${client.privateKey || client.password || ''}`,
`Address = ${address}`,
`DNS = ${inbound?.wgDns || '1.1.1.1, 1.0.0.1'}`,
];
if (inbound?.wgMtu && inbound.wgMtu > 0) lines.push(`MTU = ${inbound.wgMtu}`);
lines.push('');
if (remark) lines.push(`# ${remark}`);
lines.push('[Peer]', `PublicKey = ${inbound?.wgPublicKey || ''}`);
if (client.preSharedKey) lines.push(`PresharedKey = ${client.preSharedKey}`);
lines.push('AllowedIPs = 0.0.0.0/0, ::/0', `Endpoint = ${endpoint}`);
if (client.keepAlive && client.keepAlive > 0) lines.push(`PersistentKeepalive = ${client.keepAlive}`);
return lines.join('\n');
}

View File

@@ -139,7 +139,7 @@ export default function GroupAddClientsModal({
</Typography.Text>
</Space>
{rows.length === 0 ? (
<Alert type="info" showIcon message={t('pages.groups.addToGroupEmpty')} />
<Alert type="info" showIcon title={t('pages.groups.addToGroupEmpty')} />
) : (
<Table<ClientRow>
size="small"

View File

@@ -126,9 +126,9 @@ export default function GroupsPage() {
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
const bulkResetMut = useMutation({
mutationFn: (body: { emails: string[] }) =>
HttpUtil.post('/panel/api/clients/bulkResetTraffic', body, JSON_HEADERS),
const groupResetMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
});
@@ -321,17 +321,14 @@ export default function GroupsPage() {
}
modal.confirm({
title: t('pages.groups.resetConfirmTitle', { name: g.name }),
content: t('pages.groups.resetConfirmContent', { count: g.clientCount }),
content: t('pages.groups.resetConfirmContent'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) return;
const msg = await bulkResetMut.mutateAsync({ emails });
const msg = await groupResetMut.mutateAsync({ name: g.name });
if (msg?.success) {
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
messageApi.success(t('pages.groups.resetSuccess', { count: affected }));
messageApi.success(t('pages.groups.resetSuccess', { name: g.name }));
}
},
});
@@ -407,10 +404,10 @@ export default function GroupsPage() {
render: (_v, row) => (
<Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
<Button aria-label={t('more')} size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
</Dropdown>
<Tooltip title={t('pages.groups.rename')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
<Button aria-label={t('pages.groups.rename')} size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
</Tooltip>
</Space>
),
@@ -525,7 +522,7 @@ export default function GroupsPage() {
hoverable
title={
<div className="card-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
<Button aria-label={t('pages.groups.addGroup')} type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{!isMobile && t('pages.groups.addGroup')}
</Button>
</div>

View File

@@ -260,7 +260,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
<Input />
</Form.Item>
<Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
<Input placeholder="53,443,1000-2000" />
<Input placeholder="443" />
</Form.Item>
<Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
<Select

View File

@@ -84,16 +84,16 @@ export default function HostList(props: HostListProps) {
return (
<Space size={2}>
<Tooltip title={t('pages.hosts.moveUp')}>
<Button size="small" type="text" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
<Button size="small" type="text" icon={<ArrowUpOutlined />} aria-label={t('pages.hosts.moveUp')} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
</Tooltip>
<Tooltip title={t('pages.hosts.moveDown')}>
<Button size="small" type="text" icon={<ArrowDownOutlined />} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
<Button size="small" type="text" icon={<ArrowDownOutlined />} aria-label={t('pages.hosts.moveDown')} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(h)} />
<Button size="small" type="text" icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(h)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(h)} />
<Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(h)} />
</Tooltip>
</Space>
);

View File

@@ -292,21 +292,10 @@ export default function InboundsPage() {
}, [subSettings, openText, t]);
const exportAllLinks = useCallback(async () => {
const hydrated = await Promise.all(
dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)),
);
const out: string[] = [];
for (const ib of hydrated) {
const projected = checkFallback(ib);
out.push(genInboundLinks({
inbound: inboundFromDb(projected),
remark: projected.remark,
hostOverride: hostOverrideFor(ib),
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
}));
}
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
}, [dbInbounds, hydrateInbound, checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
const msg = await HttpUtil.get('/panel/api/inbounds/allLinks');
const links = msg?.success && Array.isArray(msg.obj) ? (msg.obj as string[]) : [];
openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: links.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') });
}, [openText, t]);
const exportAllSubs = useCallback(async () => {
const hydrated = await Promise.all(

View File

@@ -164,6 +164,7 @@ export default function AttachClientsModal({
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
aria-label={t('search')}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
@@ -192,10 +193,11 @@ export default function AttachClientsModal({
</Space>
{targetOptions.length === 0 ? (
<Alert type="info" showIcon message={t('pages.inbounds.attachClientsNoTargets')} />
<Alert type="info" showIcon title={t('pages.inbounds.attachClientsNoTargets')} />
) : (
<Select
mode="multiple"
aria-label={t('pages.inbounds.attachClientsTargets')}
style={{ width: '100%' }}
value={targetIds}
onChange={setTargetIds}

View File

@@ -180,7 +180,7 @@ export default function AttachExistingClientsModal({
</Typography.Paragraph>
{noClients ? (
<Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
<Alert type="info" showIcon title={t('pages.inbounds.attachExistingNoClients')} />
) : (
<Spin spinning={loading}>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
@@ -188,6 +188,7 @@ export default function AttachExistingClientsModal({
<Space wrap>
<Input.Search
allowClear
aria-label={t('search')}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
@@ -196,6 +197,7 @@ export default function AttachExistingClientsModal({
{groupOptions.length > 0 && (
<Select
allowClear
aria-label={t('pages.clients.group')}
value={groupFilter}
onChange={(v) => setGroupFilter(v)}
options={groupOptions}

View File

@@ -152,6 +152,7 @@ export default function DetachClientsModal({
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
aria-label={t('search')}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}

View File

@@ -66,6 +66,7 @@ export default function FallbacksCard({
>
<Space.Compact block style={{ marginBottom: 8 }}>
<Select
aria-label={t('pages.inbounds.fallbacks.pickInbound')}
value={record.childId}
options={fallbackChildOptions}
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
@@ -78,23 +79,25 @@ export default function FallbacksCard({
onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
/>
<Button
aria-label={t('pages.inbounds.form.moveUp')}
disabled={idx === 0}
onClick={() => moveFallback(idx, -1)}
title={t('pages.inbounds.form.moveUp')}
icon={<ArrowUpOutlined />}
/>
<Button
aria-label={t('pages.inbounds.form.moveDown')}
disabled={idx === fallbacks.length - 1}
onClick={() => moveFallback(idx, 1)}
title={t('pages.inbounds.form.moveDown')}
icon={<ArrowDownOutlined />}
/>
<Button danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
<Button aria-label={t('delete')} danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
</Space.Compact>
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}>
<Input
addonBefore="SNI"
prefix="SNI"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.name}
onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
@@ -102,7 +105,7 @@ export default function FallbacksCard({
</Col>
<Col xs={24} sm={12}>
<Input
addonBefore="ALPN"
prefix="ALPN"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.alpn}
onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
@@ -110,7 +113,7 @@ export default function FallbacksCard({
</Col>
<Col xs={24} sm={12}>
<Input
addonBefore="Path"
prefix="Path"
placeholder="/"
value={record.path}
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
@@ -118,7 +121,7 @@ export default function FallbacksCard({
</Col>
<Col xs={24} sm={12}>
<Input
addonBefore="Dest"
prefix="Dest"
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
value={record.dest}
onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
@@ -126,7 +129,7 @@ export default function FallbacksCard({
</Col>
<Col xs={24} sm={12}>
<InputNumber
addonBefore="xver"
prefix="xver"
min={0}
max={2}
style={{ width: '100%' }}

View File

@@ -17,6 +17,7 @@ import {
} from 'antd';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
import type { RealityScanResult } from '@/generated/types';
import {
rawInboundToFormValues,
formValuesToWirePayload,
@@ -174,6 +175,8 @@ export default function InboundFormModal({
const [messageApi, messageContextHolder] = message.useMessage();
const [form] = Form.useForm<InboundFormValues>();
const [saving, setSaving] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
const {
fallbacks,
fallbackChildOptions,
@@ -241,7 +244,9 @@ export default function InboundFormModal({
clearRealityKeypair,
genMldsa65,
clearMldsa65,
randomizeRealityTarget,
scanRealityTarget,
scanRealityCandidates,
applyRealityScanResult,
randomizeShortIds,
getNewEchCert,
clearEchCert,
@@ -250,7 +255,7 @@ export default function InboundFormModal({
setCertFromPanel,
clearCertFiles,
onSecurityChange,
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
const toggleSockopt = (on: boolean) => {
@@ -273,12 +278,6 @@ export default function InboundFormModal({
form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
};
const regenWgPeerKeypair = (peerName: number) => {
const kp = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'peers', peerName, 'privateKey'], kp.privateKey);
form.setFieldValue(['settings', 'peers', peerName, 'publicKey'], kp.publicKey);
};
const matchesVlessAuth = (
block: { id?: string; label?: string } | undefined | null,
authId: string,
@@ -347,6 +346,7 @@ export default function InboundFormModal({
: buildAddModeValues();
form.resetFields();
form.setFieldsValue(initial);
setScanResult(null);
const initialTag = (initial.tag ?? '') as string;
autoTagRef.current = isAutoInboundTag(initialTag, {
port: initial.port ?? 0,
@@ -689,7 +689,7 @@ export default function InboundFormModal({
const protocolTab = (
<>
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} regenWgPeerKeypair={regenWgPeerKeypair} />}
{protocol === Protocols.WIREGUARD && <WireguardFields wgPubKey={wgPubKey} regenInboundWg={regenInboundWg} />}
{protocol === Protocols.TUN && <TunFields />}
@@ -890,7 +890,11 @@ export default function InboundFormModal({
{security === 'reality' && (
<RealityForm
saving={saving}
randomizeRealityTarget={randomizeRealityTarget}
scanning={scanning}
scanResult={scanResult}
scanRealityTarget={scanRealityTarget}
scanRealityCandidates={scanRealityCandidates}
applyRealityScanResult={applyRealityScanResult}
randomizeShortIds={randomizeShortIds}
genRealityKeypair={genRealityKeypair}
clearRealityKeypair={clearRealityKeypair}

View File

@@ -33,7 +33,7 @@ export default function AccountsList() {
<Form.Item name={[field.name, 'pass']} noStyle>
<Input placeholder={t('password')} />
</Form.Item>
<Button onClick={() => remove(field.name)}>
<Button aria-label={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>

View File

@@ -27,6 +27,7 @@ export default function MtprotoFields() {
<Input readOnly style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => {
const domain = form.getFieldValue(['settings', 'fakeTlsDomain']);

View File

@@ -33,6 +33,7 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
<Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => {
const method = form.getFieldValue(['settings', 'method']);

View File

@@ -15,7 +15,7 @@ export default function TunFields() {
<Form.List name={['settings', 'gateway']}>
{(fields, { add, remove }) => (
<Form.Item label={t('pages.inbounds.info.gateway')}>
<Button size="small" onClick={() => add('')}>
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
@@ -23,7 +23,7 @@ export default function TunFields() {
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} />
</Form.Item>
<Button size="small" onClick={() => remove(field.name)}>
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
@@ -34,7 +34,7 @@ export default function TunFields() {
<Form.List name={['settings', 'dns']}>
{(fields, { add, remove }) => (
<Form.Item label="DNS">
<Button size="small" onClick={() => add('')}>
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
@@ -42,7 +42,7 @@ export default function TunFields() {
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
</Form.Item>
<Button size="small" onClick={() => remove(field.name)}>
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
@@ -62,7 +62,7 @@ export default function TunFields() {
</Tooltip>
}
>
<Button size="small" onClick={() => add('')}>
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
@@ -70,7 +70,7 @@ export default function TunFields() {
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
</Form.Item>
<Button size="small" onClick={() => remove(field.name)}>
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>

View File

@@ -1,44 +1,14 @@
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { Wireguard } from '@/utils';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
interface WireguardFieldsProps {
wgPubKey: string;
regenInboundWg: () => void;
regenWgPeerKeypair: (name: number) => void;
}
function nextWgPeerAllowedIP(peers: Array<{ allowedIPs?: string[] }> | undefined): string {
const fallback = '10.0.0.2/32';
let maxInt = -1;
let prefix = 32;
for (const peer of peers ?? []) {
for (const ip of peer?.allowedIPs ?? []) {
const m = /^\s*(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?\s*$/.exec(String(ip));
if (!m) continue;
const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
if (octets.some((o) => o > 255)) continue;
const asInt = octets[0] * 16777216 + octets[1] * 65536 + octets[2] * 256 + octets[3];
if (asInt > maxInt) {
maxInt = asInt;
prefix = m[5] !== undefined ? Math.min(Number(m[5]), 32) : 32;
}
}
}
if (maxInt < 0) return fallback;
const next = maxInt + 1;
const a = Math.floor(next / 16777216) % 256;
const b = Math.floor(next / 65536) % 256;
const c = Math.floor(next / 256) % 256;
const d = next % 256;
return `${a}.${b}.${c}.${d}/${prefix}`;
}
export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerKeypair }: WireguardFieldsProps) {
export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardFieldsProps) {
const { t } = useTranslation();
const form = Form.useFormInstance();
return (
<>
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
@@ -46,7 +16,7 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
<Form.Item name={['settings', 'secretKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={regenInboundWg} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.publicKey')}>
@@ -55,6 +25,9 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
<Form.Item name={['settings', 'mtu']} label="MTU">
<InputNumber />
</Form.Item>
<Form.Item name={['settings', 'dns']} label={t('pages.inbounds.info.dns')}>
<Input placeholder="1.1.1.1, 1.0.0.1" />
</Form.Item>
<Form.Item
name={['settings', 'noKernelTun']}
label={t('pages.inbounds.info.noKernelTun')}
@@ -74,96 +47,6 @@ export default function WireguardFields({ wgPubKey, regenInboundWg, regenWgPeerK
]}
/>
</Form.Item>
<Form.List name={['settings', 'peers']}>
{(fields, { add, remove }) => (
<>
<Form.Item label={t('pages.inbounds.form.peers')}>
<Button
size="small"
onClick={() => {
const kp = Wireguard.generateKeypair();
const peers = form.getFieldValue(['settings', 'peers']) as Array<{ allowedIPs?: string[] }> | undefined;
add({
privateKey: kp.privateKey,
publicKey: kp.publicKey,
allowedIPs: [nextWgPeerAllowedIP(peers)],
keepAlive: 0,
});
}}
>
<PlusOutlined /> {t('pages.inbounds.form.addPeer')}
</Button>
</Form.Item>
{fields.map((field, idx) => (
<div key={field.key} className="wg-peer">
<Divider titlePlacement="center">
<Space>
<span>{t('pages.inbounds.info.peerNumber', { n: idx + 1 })}</span>
<Form.Item noStyle shouldUpdate>
{() => {
const comment = form.getFieldValue(['settings', 'peers', field.name, 'comment']) as string | undefined;
return comment ? <span style={{ opacity: 0.65 }}> {comment}</span> : null;
}}
</Form.Item>
{fields.length > 1 && (
<Button
size="small"
danger
icon={<MinusOutlined />}
onClick={() => remove(field.name)}
/>
)}
</Space>
</Divider>
<Form.Item name={[field.name, 'comment']} label={t('comment')}>
<Input placeholder="e.g. Alice's laptop" />
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
<Space.Compact block>
<Form.Item name={[field.name, 'privateKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => regenWgPeerKeypair(field.name)}
/>
</Space.Compact>
</Form.Item>
<Form.Item name={[field.name, 'publicKey']} label={t('pages.xray.wireguard.publicKey')}>
<Input />
</Form.Item>
<Form.Item name={[field.name, 'preSharedKey']} label="PSK">
<Input />
</Form.Item>
<Form.List name={[field.name, 'allowedIPs']}>
{(ipFields, { add: addIp, remove: removeIp }) => (
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
<Button size="small" onClick={() => addIp('')}>
<PlusOutlined />
</Button>
{ipFields.map((ipField) => (
<Space.Compact key={ipField.key} block className="mt-4">
<Form.Item name={ipField.name} noStyle>
<Input />
</Form.Item>
{ipFields.length > 1 && (
<Button size="small" onClick={() => removeIp(ipField.name)}>
<MinusOutlined />
</Button>
)}
</Space.Compact>
))}
</Form.Item>
)}
</Form.List>
<Form.Item name={[field.name, 'keepAlive']} label={t('pages.inbounds.form.keepAlive')}>
<InputNumber min={0} />
</Form.Item>
</div>
))}
</>
)}
</Form.List>
</>
);
}

View File

@@ -0,0 +1,174 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Modal, Space, Table, Tag, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { RealityScanResult } from '@/generated/types';
interface RealityTargetScannerModalProps {
open: boolean;
onClose: () => void;
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
onPick: (result: RealityScanResult) => void;
}
export default function RealityTargetScannerModal({
open,
onClose,
scanRealityCandidates,
onPick,
}: RealityTargetScannerModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<RealityScanResult[]>([]);
const scanRef = useRef(scanRealityCandidates);
scanRef.current = scanRealityCandidates;
const runScan = useCallback(async (targets?: string) => {
setLoading(true);
try {
setResults(await scanRef.current(targets));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
setResults([]);
runScan();
}, [open, runScan]);
const columns: ColumnsType<RealityScanResult> = [
{
title: t('pages.inbounds.form.target'),
dataIndex: 'target',
key: 'target',
width: 200,
render: (target: string, row) => (
<Tooltip title={row.ip ? `${target}${row.ip}` : target}>
<div style={{ lineHeight: 1.25 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{target}</div>
{row.ip ? <div style={{ color: '#999', fontSize: 12 }}>{row.ip}</div> : null}
</div>
</Tooltip>
),
},
{
title: t('pages.inbounds.form.scanStatus'),
dataIndex: 'feasible',
key: 'feasible',
width: 95,
render: (feasible: boolean, row) =>
feasible ? (
<Tag color="success">{t('pages.inbounds.form.scanFeasible')}</Tag>
) : (
<Tooltip title={row.reason}>
<Tag color="warning">{t('pages.inbounds.form.scanNotFeasible')}</Tag>
</Tooltip>
),
},
{
title: 'TLS',
dataIndex: 'tlsVersion',
key: 'tlsVersion',
width: 60,
render: (v: string) => v || '—',
},
{
title: 'ALPN',
dataIndex: 'alpn',
key: 'alpn',
width: 75,
render: (v: string) => v || '—',
},
{
title: t('pages.inbounds.form.scanCurve'),
dataIndex: 'curveID',
key: 'curveID',
width: 130,
render: (v: string) => v || '—',
},
{
title: t('pages.inbounds.form.scanCert'),
dataIndex: 'certSubject',
key: 'certSubject',
width: 160,
ellipsis: true,
render: (_: string, row) =>
row.certValid ? (
<Tooltip title={`${row.certSubject} (${row.certIssuer})`}>
<span>{row.certSubject || '—'}</span>
</Tooltip>
) : (
<Tag>{t('pages.inbounds.form.scanCertInvalid')}</Tag>
),
},
{
title: t('pages.inbounds.form.scanLatency'),
dataIndex: 'latencyMs',
key: 'latencyMs',
width: 85,
render: (v: number) => (v > 0 ? `${v} ms` : '—'),
},
{
title: '',
key: 'action',
width: 64,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={() => {
onPick(row);
onClose();
}}
>
{t('pages.inbounds.form.scanUse')}
</Button>
),
},
];
return (
<Modal
open={open}
onCancel={onClose}
footer={[
<Button key="rescan" onClick={() => runScan(query.trim() || undefined)} loading={loading}>
{t('pages.inbounds.form.scanRescan')}
</Button>,
<Button key="close" type="primary" onClick={onClose}>
{t('close')}
</Button>,
]}
title={t('pages.inbounds.form.scanModalTitle')}
width={960}
>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
{t('pages.inbounds.form.scanModalDesc')}
</Typography.Paragraph>
<Input.Search
allowClear
enterButton={t('pages.inbounds.form.scan')}
loading={loading}
value={query}
onChange={(e) => setQuery(e.target.value)}
onSearch={() => runScan(query.trim() || undefined)}
placeholder={t('pages.inbounds.form.scanDiscoverPlaceholder')}
/>
<Table<RealityScanResult>
size="small"
rowKey="target"
loading={loading}
columns={columns}
dataSource={results}
pagination={false}
scroll={{ y: 360 }}
/>
</Space>
</Modal>
);
}

View File

@@ -1,13 +1,20 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Collapse, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
import type { RealityScanResult } from '@/generated/types';
import RealityTargetScannerModal from './RealityTargetScannerModal';
interface RealityFormProps {
saving: boolean;
randomizeRealityTarget: () => void;
scanning: boolean;
scanResult: RealityScanResult | null;
scanRealityTarget: () => void;
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
applyRealityScanResult: (result: RealityScanResult) => void;
randomizeShortIds: () => void;
genRealityKeypair: () => void;
clearRealityKeypair: () => void;
@@ -17,7 +24,11 @@ interface RealityFormProps {
export default function RealityForm({
saving,
randomizeRealityTarget,
scanning,
scanResult,
scanRealityTarget,
scanRealityCandidates,
applyRealityScanResult,
randomizeShortIds,
genRealityKeypair,
clearRealityKeypair,
@@ -25,6 +36,7 @@ export default function RealityForm({
clearMldsa65,
}: RealityFormProps) {
const { t } = useTranslation();
const [scannerOpen, setScannerOpen] = useState(false);
return (
<>
<Form.Item
@@ -49,7 +61,7 @@ export default function RealityForm({
label={t('pages.inbounds.form.target')}
tooltip={t('pages.inbounds.form.realityTargetHint')}
>
<Space.Compact block>
<Space.Compact block style={{ display: 'flex' }}>
<Form.Item
name={['streamSettings', 'realitySettings', 'target']}
noStyle
@@ -62,21 +74,48 @@ export default function RealityForm({
},
]}
>
<Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
<Input style={{ flex: 1 }} placeholder="example.com:443" />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
<Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
{t('pages.inbounds.form.scan')}
</Button>
<Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
{t('pages.inbounds.form.findTargets')}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item label="SNI">
<Space.Compact block style={{ display: 'flex' }}>
<Form.Item
name={['streamSettings', 'realitySettings', 'serverNames']}
noStyle
>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
</Space.Compact>
{scanResult && (
<Form.Item label=" " colon={false}>
<Alert
type={scanResult.feasible ? 'success' : 'warning'}
showIcon
title={
scanResult.feasible
? t('pages.inbounds.form.scanFeasible')
: scanResult.reason || t('pages.inbounds.form.scanNotFeasible')
}
description={
<Descriptions size="small" column={1}>
<Descriptions.Item label="TLS">{scanResult.tlsVersion || '—'}</Descriptions.Item>
<Descriptions.Item label="ALPN">{scanResult.alpn || '—'}</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanCurve')}>
{scanResult.curveID || '—'}
</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanCert')}>
{scanResult.certValid
? `${scanResult.certSubject} (${scanResult.certIssuer})`
: t('pages.inbounds.form.scanCertInvalid')}
</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
{scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
</Descriptions.Item>
</Descriptions>
}
/>
</Form.Item>
)}
<Form.Item label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'maxTimediff']}
@@ -104,7 +143,7 @@ export default function RealityForm({
>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeShortIds} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} />
</Space.Compact>
</Form.Item>
<Form.Item
@@ -201,6 +240,12 @@ export default function RealityForm({
},
]}
/>
<RealityTargetScannerModal
open={scannerOpen}
onClose={() => setScannerOpen(false)}
scanRealityCandidates={scanRealityCandidates}
onPick={applyRealityScanResult}
/>
</>
);
}

View File

@@ -124,6 +124,7 @@ export default function TlsForm({
<>
<Form.Item label={t('certificate')}>
<Button
aria-label={t('add')}
type="primary"
size="small"
onClick={() => add({
@@ -242,7 +243,7 @@ export default function TlsForm({
name={[certField.name, 'ocspStapling']}
label="OCSP Stapling"
>
<InputNumber min={0} addonAfter="s" style={{ width: '50%' }} />
<InputNumber min={0} suffix="s" style={{ width: '50%' }} />
</Form.Item>
<Form.Item
name={[certField.name, 'oneTimeLoading']}

View File

@@ -83,7 +83,7 @@ export default function SockoptForm({
return (
<>
<Form.Item label="Sockopt">
<Switch checked={on} onChange={toggleSockopt} />
<Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
</Form.Item>
{on && (
<>

View File

@@ -4,10 +4,10 @@ import type { FormInstance } from 'antd';
import type { MessageInstance } from 'antd/es/message/interface';
import { HttpUtil, RandomUtil } from '@/utils';
import { getRandomRealityTarget } from '@/models/reality-targets';
import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
import type { RealityScanResult } from '@/generated/types';
interface UseSecurityActionsArgs {
form: FormInstance<InboundFormValues>;
@@ -17,13 +17,15 @@ interface UseSecurityActionsArgs {
// Panel" must read the node's own cert paths for a node-assigned inbound —
// the central panel's paths don't exist on the node. See issue #4854.
nodeId: number | null;
setScanResult: Dispatch<SetStateAction<RealityScanResult | null>>;
setScanning: Dispatch<SetStateAction<boolean>>;
}
// Server-side TLS / Reality key + certificate generation handlers for the
// inbound modal's security tab. Each talks to a /panel server endpoint and
// writes the result back into the form. Lifted out of InboundFormModal so
// the modal body stays focused on orchestration.
export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseSecurityActionsArgs) {
export function useSecurityActions({ form, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
const { t } = useTranslation();
const genRealityKeypair = async () => {
@@ -64,13 +66,55 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
};
const randomizeRealityTarget = () => {
const tgt = getRandomRealityTarget() as { target: string; sni: string };
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], tgt.target);
form.setFieldValue(
['streamSettings', 'realitySettings', 'serverNames'],
tgt.sni.split(',').map((s) => s.trim()).filter(Boolean),
const applyRealityScanResult = (r: RealityScanResult) => {
setScanResult(r);
form.setFieldValue(['streamSettings', 'realitySettings', 'target'], r.target);
if (r.serverNames?.length) {
form.setFieldValue(['streamSettings', 'realitySettings', 'serverNames'], r.serverNames);
}
};
const scanRealityTarget = async () => {
const target = ((form.getFieldValue(['streamSettings', 'realitySettings', 'target']) as string | undefined) ?? '').trim();
if (!target) {
messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
return;
}
setScanning(true);
try {
const msg = await HttpUtil.post<RealityScanResult>(
'/panel/api/server/scanRealityTarget',
{ target },
{ silent: true },
);
if (!msg?.success || !msg.obj) {
setScanResult(null);
messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError'));
return;
}
const r = msg.obj;
applyRealityScanResult(r);
if (r.feasible) {
messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible'));
} else {
messageApi.warning(r.reason || t('pages.inbounds.toasts.scanRealityTargetNotFeasible'));
}
} finally {
setScanning(false);
}
};
const scanRealityCandidates = async (targets?: string): Promise<RealityScanResult[]> => {
const msg = await HttpUtil.post<RealityScanResult[]>(
'/panel/api/server/scanRealityTargets',
targets ? { targets } : {},
{ silent: true },
);
if (!msg?.success || !Array.isArray(msg.obj)) {
messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError'));
return [];
}
return msg.obj;
};
const randomizeShortIds = () => {
@@ -209,6 +253,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
};
const onSecurityChange = async (next: string) => {
setScanResult(null);
const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...current, security: next };
delete cleaned.tlsSettings;
@@ -218,9 +263,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
}
if (next === 'reality') {
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
const tgt = getRandomRealityTarget() as { target: string; sni: string };
reality.target = tgt.target;
reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean);
reality.target = '';
reality.serverNames = [];
reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
cleaned.realitySettings = reality;
}
@@ -244,7 +288,9 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
clearRealityKeypair,
genMldsa65,
clearMldsa65,
randomizeRealityTarget,
scanRealityTarget,
scanRealityCandidates,
applyRealityScanResult,
randomizeShortIds,
getNewEchCert,
clearEchCert,

View File

@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils } from '@/utils';
import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils, Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { Protocols } from '@/schemas/primitives';
import { InfinityIcon } from '@/components/ui';
import { useDatepicker } from '@/hooks/useDatepicker';
@@ -208,6 +209,11 @@ export default function InboundInfoModal({
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
}, [clientStats, clientSettings]);
const wgPubKey = useMemo(() => {
if (!dbInbound?.isWireguard || !inbound?.settings?.secretKey) return '';
return Wireguard.generateKeypair(inbound.settings.secretKey as string).publicKey;
}, [dbInbound?.isWireguard, inbound?.settings?.secretKey]);
const formatLastOnline = useCallback(
(email: string) => {
const ts = lastOnlineMap[email];
@@ -331,9 +337,9 @@ export default function InboundInfoModal({
)}
</div>
<div className="ip-log-actions">
<SyncOutlined spin={refreshing} onClick={() => loadClientIps()} />
<SyncOutlined spin={refreshing} role="button" tabIndex={0} aria-label={t('refresh')} onClick={() => loadClientIps()} onKeyDown={activateOnKey(() => loadClientIps())} />
<Tooltip title={t('pages.inbounds.IPLimitlogclear')}>
<DeleteOutlined onClick={() => clearClientIps()} />
<DeleteOutlined role="button" tabIndex={0} aria-label={t('pages.inbounds.IPLimitlogclear')} onClick={() => clearClientIps()} onKeyDown={activateOnKey(() => clearClientIps())} />
</Tooltip>
</div>
</td>
@@ -389,7 +395,7 @@ export default function InboundInfoModal({
<div className="tg-row">
<Tag color="blue">{clientSettings.tgId}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(clientSettings.tgId, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(clientSettings.tgId, t)} />
</Tooltip>
</div>
</>
@@ -403,7 +409,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(link.link, t)} />
</Tooltip>
</div>
<code className="link-panel-text">{link.link}</code>
@@ -419,7 +425,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">{t('subscription.title')}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subLink, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(subLink, t)} />
</Tooltip>
</div>
<a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
@@ -429,7 +435,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">JSON</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subJsonLink, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(subJsonLink, t)} />
</Tooltip>
</div>
<a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
@@ -507,7 +513,7 @@ export default function InboundInfoModal({
<dd className="value-block">
<code className="value-code">{encryptionLabel}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(encryptionLabel, t)} />
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(encryptionLabel, t)} />
</Tooltip>
</dd>
</div>
@@ -632,7 +638,7 @@ export default function InboundInfoModal({
<dd className="value-block">
<code className="value-code">{inbound.settings.secret as string}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(inbound.settings.secret as string, t)} />
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(inbound.settings.secret as string, t)} />
</Tooltip>
</dd>
</div>
@@ -683,7 +689,7 @@ export default function InboundInfoModal({
<dd className="value-block">
<code className="value-code">{links[0].link}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(links[0].link, t)} />
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(links[0].link, t)} />
</Tooltip>
</dd>
</div>
@@ -725,7 +731,7 @@ export default function InboundInfoModal({
<span className="account-sep">:</span>
<Tag className="value-tag">{account.pass}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
</Tooltip>
<Space size={4} wrap className="share-buttons">
<Tooltip title={`socks5://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`}>
@@ -774,7 +780,7 @@ export default function InboundInfoModal({
<span className="account-sep">:</span>
<Tag className="value-tag">{account.pass}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
</Tooltip>
</dd>
</div>
@@ -791,7 +797,7 @@ export default function InboundInfoModal({
</div>
<div className="info-row">
<dt>{t('pages.xray.wireguard.publicKey')}</dt>
<dd><Tag className="value-tag">{inbound.settings.pubKey as string}</Tag></dd>
<dd><Tag className="value-tag">{wgPubKey}</Tag></dd>
</div>
<div className="info-row">
<dt>{t('pages.inbounds.info.mtu')}</dt>
@@ -840,10 +846,10 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">{t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardConfigs[idx], t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(wireguardConfigs[idx], t)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)} />
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} onClick={() => downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)} />
</Tooltip>
</div>
<code className="link-panel-text">{wireguardConfigs[idx]}</code>
@@ -854,7 +860,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">Peer {idx + 1} link</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardLinks[idx], t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(wireguardLinks[idx], t)} />
</Tooltip>
</div>
<code className="link-panel-text">{wireguardLinks[idx]}</code>
@@ -873,7 +879,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(link.link, t)} />
</Tooltip>
</div>
<code className="link-panel-text">{link.link}</code>

View File

@@ -25,6 +25,7 @@ import {
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { buildRowActionsMenu } from './RowActions';
import { useInboundColumns } from './useInboundColumns';
@@ -160,11 +161,11 @@ export default function InboundList({
hoverable
title={(
<Space>
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />} aria-label={t('pages.inbounds.addInbound')}>
{!isMobile && t('pages.inbounds.addInbound')}
</Button>
<Dropdown trigger={['click']} menu={generalActionsMenu}>
<Button type="primary" icon={<MenuOutlined />}>
<Button type="primary" icon={<MenuOutlined />} aria-label={t('pages.inbounds.generalActions')}>
{!isMobile && t('pages.inbounds.generalActions')}
</Button>
</Dropdown>
@@ -175,6 +176,7 @@ export default function InboundList({
options={nodeFilterOptions}
popupMatchSelectWidth={false}
style={{ minWidth: isMobile ? 90 : 140 }}
aria-label={t('pages.clients.filters.nodes')}
/>
)}
{selectedRowKeys.length > 0 && (
@@ -182,7 +184,7 @@ export default function InboundList({
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
{t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
</Tag>
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete}>
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete} aria-label={t('delete')}>
{!isMobile && t('delete')}
</Button>
</>
@@ -221,9 +223,16 @@ export default function InboundList({
/>
<span className="card-id">#{record.id}</span>
<span className="tag-name">{record.remark}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<div className="card-actions">
<Tooltip title={t('pages.inbounds.inboundInfo')}>
<InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('pages.inbounds.inboundInfo')}
onClick={() => setStatsRecord(record)}
onKeyDown={activateOnKey(() => setStatsRecord(record))}
/>
</Tooltip>
<Switch
checked={record.enable}
@@ -238,7 +247,7 @@ export default function InboundList({
onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
}}
>
<MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
</div>

View File

@@ -43,7 +43,7 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
label: `${t('pages.inbounds.export')}${t('pages.settings.subSettings')}`,
});
}
} else {
} else if (!record.isWireguard) {
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
}
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
const { t } = useTranslation();
return (
<div className="action-buttons">
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onClick('edit')} />
<Dropdown
trigger={['click']}
menu={{
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
onClick: ({ key }) => onClick(key as RowAction),
}}
>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
);

View File

@@ -81,7 +81,6 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
}
export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
if (dbInbound.isWireguard) return true;
if (dbInbound.isSS) {
return !isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(dbInbound.settings) });
}

View File

@@ -7,6 +7,7 @@ import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import { InfinityIcon } from '@/components/ui';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { coerceInboundJsonField } from '@/models/dbinbound';
import { RowActionsCell } from './RowActions';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
@@ -51,6 +52,28 @@ export function useInboundColumns({
const { datepicker } = useDatepicker();
return useMemo(() => {
const fallbackClientCount = (record: DBInboundRecord): ClientCountEntry | null => {
const settings = coerceInboundJsonField(record.settings) as {
clients?: { email?: string; enable?: boolean }[];
};
const clients = Array.isArray(settings.clients) ? settings.clients : [];
if (clients.length === 0) return null;
const active = clients
.filter((client) => client.email && client.enable !== false)
.map((client) => client.email!);
const deactive = clients
.filter((client) => client.email && client.enable === false)
.map((client) => client.email!);
return {
clients: clients.length,
active,
deactive,
depleted: [],
expiring: [],
online: [],
};
};
const cols: TableColumnType<DBInboundRecord>[] = [
{
title: 'ID',
@@ -174,14 +197,14 @@ export function useInboundColumns({
align: 'left',
width: 200,
render: (_, record) => {
const cc = clientCount[record.id];
const cc = clientCount[record.id] || fallbackClientCount(record);
if (!cc) return null;
return (
<>
<Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>
<TeamOutlined /> {cc.clients}
</Tag>
{cc.active.length > 0 && (
{cc.active.length > 0 ? (
<Popover
title={t('subscription.active')}
content={(
@@ -192,6 +215,8 @@ export function useInboundColumns({
>
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.active.length}</Tag>
</Popover>
) : (
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>0</Tag>
)}
{cc.deactive.length > 0 && (
<Popover

View File

@@ -35,6 +35,7 @@ interface QrItem {
header: string;
value: string;
downloadName?: string;
showQr?: boolean;
}
export default function QrCodeModal({
@@ -122,7 +123,7 @@ export default function QrCodeModal({
downloadName: `peer-${idx + 1}.conf`,
});
if (wireguardLinks[idx]) {
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx] });
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx], showQr: false });
}
});
return items;
@@ -137,7 +138,7 @@ export default function QrCodeModal({
value={item.value}
remark={item.header}
downloadName={item.downloadName || ''}
showQr={!isPostQuantumLink(item.value)}
showQr={item.showQr !== false && !isPostQuantumLink(item.value)}
/>
),
})),

View File

@@ -4,6 +4,7 @@ import { Button, QRCode, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons';
import { ClipboardManager, FileManager } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import './QrPanel.css';
interface QrPanelProps {
@@ -96,21 +97,29 @@ export default function QrPanel({
<div className="qr-panel-header">
<Tag color="green" className="qr-remark">{remark}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={copy} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
</Tooltip>
{showQr && (
<Tooltip title={t('downloadImage') !== 'downloadImage' ? t('downloadImage') : 'Download Image'}>
<Button size="small" icon={<PictureOutlined />} onClick={downloadImage} />
<Tooltip title={t('downloadImage')}>
<Button size="small" icon={<PictureOutlined />} aria-label={t('downloadImage')} onClick={downloadImage} />
</Tooltip>
)}
{downloadName && (
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} onClick={download} />
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} onClick={download} />
</Tooltip>
)}
</div>
{showQr && (
<div ref={qrRef} className="qr-panel-canvas">
<div
ref={qrRef}
className="qr-panel-canvas"
role="button"
tabIndex={0}
aria-label={t('copy')}
onClick={copyImage}
onKeyDown={activateOnKey(copyImage)}
>
<Tooltip title={t('copy')}>
<QRCode
className="qr-code"
@@ -120,7 +129,6 @@ export default function QrPanel({
bordered={false}
color="#000000"
bgColor="#ffffff"
onClick={copyImage}
/>
</Tooltip>
</div>

View File

@@ -83,7 +83,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
{isPostgres ? t('pages.index.exportDatabasePgDesc') : t('pages.index.exportDatabaseDesc')}
</div>
</div>
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
<Button type="primary" aria-label={t('pages.index.exportDatabase')} onClick={exportDb} icon={<DownloadOutlined />} />
</div>
<div className="backup-item">
@@ -93,7 +93,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
{isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
</div>
</div>
<Button type="primary" onClick={exportMigration} icon={<DownloadOutlined />} />
<Button type="primary" aria-label={t('pages.index.migrationDownload')} onClick={exportMigration} icon={<DownloadOutlined />} />
</div>
<div className="backup-item">
@@ -103,7 +103,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
{isPostgres ? t('pages.index.importDatabasePgDesc') : t('pages.index.importDatabaseDesc')}
</div>
</div>
<Button type="primary" onClick={importDb} icon={<UploadOutlined />} />
<Button type="primary" aria-label={t('pages.index.importDatabase')} onClick={importDb} icon={<UploadOutlined />} />
</div>
</div>
</Modal>

View File

@@ -200,6 +200,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
onChange={(e) => setRow(i, { file: e.target.value })}
/>
<Button
aria-label={t('delete')}
icon={<DeleteOutlined />}
onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
/>

View File

@@ -34,10 +34,12 @@ import {
DatabaseOutlined,
ForkOutlined,
CopyOutlined,
TelegramFilled,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -66,7 +68,6 @@ export default function IndexPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const [accessLogEnable, setAccessLogEnable] = useState(false);
const [isDevBuild, setIsDevBuild] = useState(false);
const [devChannelEnable, setDevChannelEnable] = useState(false);
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
currentVersion: '',
@@ -90,12 +91,11 @@ export default function IndexPage() {
const [loadingTip, setLoadingTip] = useState(t('loading'));
useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean; isDevBuild?: boolean; devChannelEnable?: boolean }>(
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
).then((msg) => {
if (msg?.success && msg.obj) {
setAccessLogEnable(!!msg.obj.accessLogEnable);
setIsDevBuild(!!msg.obj.isDevBuild);
setDevChannelEnable(!!msg.obj.devChannelEnable);
}
});
@@ -128,11 +128,7 @@ export default function IndexPage() {
}, [refresh]);
function openPanelVersion() {
if (panelUpdateInfo.updateAvailable || isDevBuild) {
setPanelUpdateOpen(true);
} else {
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
}
setPanelUpdateOpen(true);
}
async function handleChannelChange(dev: boolean) {
@@ -217,15 +213,15 @@ export default function IndexPage() {
title={t('menu.link')}
hoverable
actions={[
<Space className="action" key="logs" onClick={() => setLogsOpen(true)}>
<Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.logs')}</span>}
</Space>,
<Space className="action" key="config" onClick={openConfig}>
<Space className="action" key="config" role="button" tabIndex={0} aria-label={t('pages.index.config')} onClick={openConfig} onKeyDown={activateOnKey(openConfig)}>
<ControlOutlined />
{!isMobile && <span>{t('pages.index.config')}</span>}
</Space>,
<Space className="action" key="backup" onClick={() => setBackupOpen(true)}>
<Space className="action" key="backup" role="button" tabIndex={0} aria-label={t('pages.index.backupTitle')} onClick={() => setBackupOpen(true)} onKeyDown={activateOnKey(() => setBackupOpen(true))}>
<CloudServerOutlined />
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
</Space>,
@@ -249,23 +245,18 @@ export default function IndexPage() {
}
hoverable
actions={[
<Space className="action" key="tg" onClick={openTelegram}>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="currentColor"
className="tg-icon"
aria-hidden="true"
>
<path d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
</svg>
<Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
<TelegramFilled className="tg-icon" aria-hidden="true" />
{!isMobile && <span>@XrayUI</span>}
</Space>,
<Space
key="panel-version"
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
role="button"
tabIndex={0}
aria-label={t('pages.index.updatePanel')}
onClick={openPanelVersion}
onKeyDown={activateOnKey(openPanelVersion)}
>
<CloudDownloadOutlined />
{!isMobile && (
@@ -288,7 +279,11 @@ export default function IndexPage() {
<Space
className="action"
key="sys-history"
role="button"
tabIndex={0}
aria-label={t('pages.index.systemHistoryTitle')}
onClick={() => setSysHistoryOpen(true)}
onKeyDown={activateOnKey(() => setSysHistoryOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
@@ -296,7 +291,11 @@ export default function IndexPage() {
<Space
className="action"
key="xray-metrics"
role="button"
tabIndex={0}
aria-label={t('pages.index.xrayMetricsTitle')}
onClick={() => setXrayMetricsOpen(true)}
onKeyDown={activateOnKey(() => setXrayMetricsOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
@@ -403,12 +402,20 @@ export default function IndexPage() {
{showIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(false)}
onKeyDown={activateOnKey(() => setShowIp(false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(true)}
onKeyDown={activateOnKey(() => setShowIp(true))}
/>
)}
</Tooltip>
@@ -463,7 +470,6 @@ export default function IndexPage() {
<PanelUpdateModal
open={panelUpdateOpen}
info={panelUpdateInfo}
isDevBuild={isDevBuild}
devChannelEnable={devChannelEnable}
onChannelChange={handleChannelChange}
onClose={() => setPanelUpdateOpen(false)}

View File

@@ -4,6 +4,7 @@ import { Button, Checkbox, Form, Modal, Select, Space } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { parseLogLine } from './logParse';
import './LogModal.css';
@@ -71,7 +72,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
const titleNode = (
<>
{t('pages.index.logs')}
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
<SyncOutlined spin={loading} className="reload-icon" role="button" tabIndex={0} aria-label={t('refresh')} onClick={refresh} onKeyDown={activateOnKey(refresh)} />
</>
);
@@ -125,7 +126,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
<Button type="primary" onClick={download} icon={<DownloadOutlined />} aria-label={t('download')} />
</Form.Item>
</Form>

View File

@@ -25,7 +25,6 @@ interface BusyEvent {
interface PanelUpdateModalProps {
open: boolean;
info: PanelUpdateInfo;
isDevBuild?: boolean;
devChannelEnable?: boolean;
onChannelChange?: (dev: boolean) => void | Promise<void>;
onClose: () => void;
@@ -35,7 +34,6 @@ interface PanelUpdateModalProps {
export default function PanelUpdateModal({
open,
info,
isDevBuild,
devChannelEnable,
onChannelChange,
onClose,
@@ -113,18 +111,16 @@ export default function PanelUpdateModal({
/>
)}
{isDevBuild && (
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.devChannel')}</span>
<Switch
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
</div>
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.devChannel')}</span>
<Switch
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
</div>
)}
</div>
{devChannelEnable && (
<Alert

View File

@@ -4,6 +4,7 @@ import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd'
import { ReloadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status';
import GeodataSection from './GeodataSection';
import './VersionModal.css';
@@ -145,7 +146,11 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
<Tooltip title={t('update')}>
<ReloadOutlined
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('update')}
onClick={() => updateGeofile(file)}
onKeyDown={activateOnKey(() => updateGeofile(file))}
/>
</Tooltip>
</div>

View File

@@ -4,6 +4,7 @@ import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import './XrayLogModal.css';
@@ -132,7 +133,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
title={
<>
{t('pages.index.accessLogs')}
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
<SyncOutlined spin={loading} className="reload-icon" role="button" tabIndex={0} aria-label={t('refresh')} onClick={refresh} onKeyDown={activateOnKey(refresh)} />
</>
}
>
@@ -177,7 +178,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
<Button type="primary" onClick={download} icon={<DownloadOutlined />} aria-label={t('download')} />
</Form.Item>
</Form>

View File

@@ -9,6 +9,7 @@ import {
} from '@ant-design/icons';
import type { Status } from '@/models/status';
import { activateOnKey } from '@/utils/a11y';
import './XrayStatusCard.css';
interface XrayStatusCardProps {
@@ -67,7 +68,7 @@ export default function XrayStatusCard({
<span>{t('pages.index.xrayStatusError')}</span>
</Col>
<Col>
<BarsOutlined className="cursor-pointer" onClick={onOpenLogs} />
<BarsOutlined className="cursor-pointer" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={onOpenLogs} onKeyDown={activateOnKey(onOpenLogs)} />
</Col>
</Row>
}
@@ -90,21 +91,21 @@ export default function XrayStatusCard({
// sense when one is configured (unlike IP limit, which no longer needs it)
...(accessLogEnable
? [
<Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
<Space className="action" key="xraylogs" role="button" tabIndex={0} aria-label={t('pages.index.accessLogs')} onClick={onOpenXrayLogs} onKeyDown={activateOnKey(onOpenXrayLogs)}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.accessLogs')}</span>}
</Space>,
]
: []),
<Space className="action" key="stop" onClick={onStopXray}>
<Space className="action" key="stop" role="button" tabIndex={0} aria-label={t('pages.index.stopXray')} onClick={onStopXray} onKeyDown={activateOnKey(onStopXray)}>
<PoweroffOutlined />
{!isMobile && <span>{t('pages.index.stopXray')}</span>}
</Space>,
<Space className="action" key="restart" onClick={onRestartXray}>
<Space className="action" key="restart" role="button" tabIndex={0} aria-label={t('pages.index.restartXray')} onClick={onRestartXray} onKeyDown={activateOnKey(onRestartXray)}>
<ReloadOutlined />
{!isMobile && <span>{t('pages.index.restartXray')}</span>}
</Space>,
<Space className="action" key="switch" onClick={onOpenVersionSwitch}>
<Space className="action" key="switch" role="button" tabIndex={0} aria-label={t('pages.index.xraySwitch')} onClick={onOpenVersionSwitch} onKeyDown={activateOnKey(onOpenVersionSwitch)}>
<ToolOutlined />
{!isMobile && (
<span>

View File

@@ -35,6 +35,7 @@ import {
import NodeHistoryPanel from './NodeHistoryPanel';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { isPanelUpdateAvailable } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import './NodeList.css';
interface NodeListProps {
@@ -245,18 +246,18 @@ export default function NodeList({
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} aria-label={t('pages.nodes.probe')} onClick={() => onProbe(record)} />
</Tooltip>
{isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} aria-label={t('pages.nodes.updatePanel')} onClick={() => onUpdateNode(record)} />
</Tooltip>
)}
<Tooltip title={t('edit')}>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
@@ -296,9 +297,9 @@ export default function NodeList({
{t('pages.nodes.address')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? (
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(false)} onKeyDown={activateOnKey(() => setShowAddress(false))} />
) : (
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(true)} onKeyDown={activateOnKey(() => setShowAddress(true))} />
)}
</Tooltip>
</span>
@@ -367,7 +368,7 @@ export default function NodeList({
<span>{record.panelVersion || '-'}</span>
{canUpdate && (
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
<Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} onClick={() => onUpdateNode(record)}>
<Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} role="button" tabIndex={0} onClick={() => onUpdateNode(record)} onKeyDown={activateOnKey(() => onUpdateNode(record))}>
{t('pages.nodes.updateAvailable')}
</Tag>
</Tooltip>
@@ -467,15 +468,32 @@ export default function NodeList({
</div>
) : (
<div key={record.id} className="node-card">
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
<div
className="card-head"
onClick={(e) => {
if (!(e.target as HTMLElement).closest('.card-actions')) toggleExpanded(record.id);
}}
>
<RightOutlined
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
role="button"
tabIndex={0}
aria-expanded={expandedIds.has(record.id)}
aria-label={record.name}
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
/>
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<div className="card-actions">
<Tooltip title={t('info')}>
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('info')}
onClick={() => setStatsNode(record)}
onKeyDown={activateOnKey(() => setStatsNode(record))}
/>
</Tooltip>
<Switch
@@ -512,7 +530,7 @@ export default function NodeList({
],
}}
>
<MoreOutlined className="row-action-trigger" />
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
</div>
@@ -555,9 +573,9 @@ export default function NodeList({
</a>
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? (
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(false)} onKeyDown={activateOnKey(() => setShowAddress(false))} />
) : (
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(true)} onKeyDown={activateOnKey(() => setShowAddress(true))} />
)}
</Tooltip>
</div>

View File

@@ -41,7 +41,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
type="info"
showIcon
style={{ marginTop: 8 }}
message={t('pages.index.devChannelWarning')}
title={t('pages.index.devChannelWarning')}
/>
)}
</div>

View File

@@ -312,6 +312,17 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
<Switch checked={allSetting.ldapUseTLS} onChange={(v) => updateSetting({ ldapUseTLS: v })} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.skipTlsVerify')}
description={t('pages.settings.ldap.skipTlsVerifyDesc')}
>
<Switch
checked={allSetting.ldapInsecureSkipVerify}
disabled={!allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapInsecureSkipVerify: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.bindDn')}>
<Input value={allSetting.ldapBindDN} onChange={(e) => updateSetting({ ldapBindDN: e.target.value })} />
</SettingListItem>

View File

@@ -13,7 +13,7 @@ import {
message,
} from 'antd';
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, RandomUtil } from '@/utils';
import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -37,6 +37,13 @@ interface ApiTokenRow {
interface SecurityTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
saveSetting: (payload: Partial<AllSetting> & Record<string, unknown>) => Promise<unknown>;
}
const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
function apiTokenCreatedAtMilliseconds(createdAt: number): number {
return createdAt < UNIX_MILLISECONDS_THRESHOLD ? createdAt * 1000 : createdAt;
}
type TfaType = 'set' | 'confirm';
@@ -59,7 +66,7 @@ const TFA_INITIAL: TfaState = {
onConfirm: () => {},
};
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
@@ -93,10 +100,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
setUser((prev) => ({ ...prev, [key]: value }));
}
const sendUpdateUser = useCallback(async () => {
const sendUpdateUser = useCallback(async (twoFactorCode = '') => {
setUpdating(true);
try {
const msg = await HttpUtil.post('/panel/api/setting/updateUser', user) as ApiMsg;
const msg = await HttpUtil.post('/panel/api/setting/updateUser', { ...user, twoFactorCode }) as ApiMsg;
if (msg?.success) {
await HttpUtil.post('/logout');
const basePath = window.X_UI_BASE_PATH || '/';
@@ -112,9 +119,11 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
openTfa({
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
token: allSetting.twoFactorToken,
token: '',
type: 'confirm',
onConfirm: (ok: boolean) => { if (ok) sendUpdateUser(); },
onConfirm: (ok: boolean, code?: string) => {
if (ok) sendUpdateUser(code || '');
},
});
} else {
sendUpdateUser();
@@ -194,7 +203,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
function formatTokenDate(ts: number): string {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
}
function toggleTwoFactor() {
@@ -218,12 +227,21 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
openTfa({
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
description: t('pages.settings.security.twoFactorModalRemoveStep'),
token: allSetting.twoFactorToken,
token: '',
type: 'confirm',
onConfirm: (ok: boolean) => {
onConfirm: async (ok: boolean, code?: string) => {
if (!ok) return;
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
const next = {
...allSetting,
twoFactorEnable: false,
twoFactorToken: '',
twoFactorCode: code || '',
};
const msg = await saveSetting(next) as ApiMsg;
if (msg?.success) {
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '', hasTwoFactorToken: false });
}
},
});
}

View File

@@ -76,6 +76,7 @@ export default function SettingsPage() {
setSpinning,
saveDisabled,
saveAll,
savePayload,
} = useAllSettings();
const [entryHost, setEntryHost] = useState('');
@@ -196,7 +197,7 @@ export default function SettingsPage() {
const categoryBody = useMemo(() => {
switch (activeSlug) {
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} saveSetting={savePayload} />;
case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'email': return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;

View File

@@ -115,6 +115,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
value={state.mode}
options={modeOptions}
onChange={onModeChange}
aria-label={t('pages.settings.telegramNotifyTime')}
/>
{state.mode === 'every' && (
<Space.Compact style={{ width: '100%' }}>
@@ -123,12 +124,14 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
style={{ width: '50%' }}
value={state.num}
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
aria-label={t('pages.settings.notifyTime.interval')}
/>
<Select<Unit>
style={{ width: '50%' }}
value={state.unit}
options={unitOptions}
onChange={(unit) => update({ unit })}
aria-label={t('pages.settings.notifyTime.unit')}
/>
</Space.Compact>
)}
@@ -137,6 +140,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
value={state.custom}
placeholder="0 30 8 * * *"
onChange={(e) => update({ custom: e.target.value })}
aria-label={t('pages.settings.notifyTime.custom')}
/>
)}
</Space>

View File

@@ -4,6 +4,7 @@ import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
import * as OTPAuth from 'otpauth';
import { ClipboardManager } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { TotpCodeSchema } from '@/schemas/login';
import './TwoFactorModal.css';
@@ -108,7 +109,14 @@ export default function TwoFactorModal({
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
<Divider />
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
<div className="qr-wrap">
<div
className="qr-wrap"
role="button"
tabIndex={0}
aria-label={t('copy')}
onClick={copyToken}
onKeyDown={activateOnKey(copyToken)}
>
<QRCode
className="qr-code"
value={qrValue}
@@ -119,18 +127,17 @@ export default function TwoFactorModal({
bgColor="#ffffff"
errorLevel="L"
title={t('copy')}
onClick={copyToken}
/>
<span className="qr-token">{token}</span>
</div>
<Divider />
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
</>
) : (
<>
<p>{description}</p>
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
</>
)}
</Modal>

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { Tooltip } from 'antd';
/* Builds a settings category tab label: icon + text on desktop, and on
@@ -6,7 +6,10 @@ import { Tooltip } from 'antd';
old top tab bar's icons-only behaviour. */
export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode {
if (iconsOnly) {
return <Tooltip title={text}>{icon}</Tooltip>;
const labelledIcon = typeof text === 'string' && isValidElement(icon)
? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text })
: icon;
return <Tooltip title={text}>{labelledIcon}</Tooltip>;
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
@@ -31,8 +31,9 @@ import {
} from '@ant-design/icons';
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
import { isPostQuantumLink, wireguardConfigFromLink } from '@/lib/xray/inbound-link';
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
import ConfigBlock from '@/components/clients/ConfigBlock';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import SubUsageSummary from './SubUsageSummary';
@@ -423,8 +424,10 @@ export default function SubPage() {
const rowTitle = parts?.remark || fallback;
const qrLabel = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link);
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
return (
<div key={link} className="sub-link-row">
<Fragment key={link}>
<div className="sub-link-row">
{parts
? <LinkTags parts={parts} />
: <Tag className="sub-link-tag">LINK</Tag>}
@@ -468,6 +471,16 @@ export default function SubPage() {
)}
</div>
</div>
{isWireguardLink && (
<ConfigBlock
label={t('pages.clients.wireguardConfig')}
text={wireguardConfigFromLink(link, rowTitle)}
fileName={`${rowTitle || 'peer'}.conf`}
qrRemark={rowTitle}
tagColor="cyan"
/>
)}
</Fragment>
);
})}
</div>

View File

@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next';
import type { DeletionImpact } from './reference-cleanup';
interface DeletionImpactListProps {
impact: DeletionImpact;
}
export default function DeletionImpactList({ impact }: DeletionImpactListProps) {
const { t } = useTranslation();
const lines: string[] = [];
for (const rule of impact.rules) {
lines.push(
rule.fate === 'removed'
? t('pages.xray.refCleanup.ruleRemoved', { label: rule.label })
: t('pages.xray.refCleanup.ruleModified', { label: rule.label, keeps: rule.keeps ?? '' }),
);
}
for (const balancer of impact.balancers) {
lines.push(t('pages.xray.refCleanup.balancerRemoved', { tag: balancer.tag }));
}
if (impact.observatory) lines.push(t('pages.xray.observatory.deleteAlsoObservatory'));
if (impact.burst) lines.push(t('pages.xray.observatory.deleteAlsoBurst'));
if (lines.length === 0) return null;
return (
<div>
<p style={{ marginBottom: 8 }}>{t('pages.xray.refCleanup.header')}</p>
<ul style={{ margin: 0, paddingInlineStart: 20 }}>
{lines.map((line, i) => (
<li key={i}>
<bdi>{line}</bdi>
</li>
))}
</ul>
</div>
);
}

View File

@@ -68,10 +68,14 @@ export default function BalancerFormModal({
}: BalancerFormModalProps) {
const { t } = useTranslation();
const [state, setState] = useState<FormState>(() => initialState(balancer));
const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});
const [submitAttempted, setSubmitAttempted] = useState(false);
const isEdit = balancer != null;
const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
setState((prev) => ({ ...prev, [key]: value }));
};
const parsed = useMemo(
() => BalancerFormSchema.safeParse(state),
@@ -89,8 +93,17 @@ export default function BalancerFormModal({
return map;
}, [parsed, t]);
const showTagIssue = submitAttempted || !!touched.tag;
const showSelectorIssue = submitAttempted || !!touched.selector;
const tagError = showTagIssue ? issues.tag : '';
const selectorError = showSelectorIssue ? issues.selector : '';
const showDuplicate = showTagIssue && duplicateTag;
function submit() {
if (!parsed.success || duplicateTag) return;
if (!parsed.success || duplicateTag) {
setSubmitAttempted(true);
return;
}
const values = { ...parsed.data };
if (values.strategy !== 'leastLoad') delete values.settings;
onConfirm(values);
@@ -128,7 +141,6 @@ export default function BalancerFormModal({
title={title}
okText={okText}
cancelText={t('close')}
okButtonProps={{ disabled: !parsed.success || duplicateTag }}
mask={{ closable: false }}
onOk={submit}
onCancel={onClose}
@@ -137,8 +149,8 @@ export default function BalancerFormModal({
<Form.Item
label={t('pages.xray.balancer.tag')}
required
validateStatus={issues.tag ? 'error' : duplicateTag ? 'warning' : ''}
help={issues.tag || (duplicateTag ? t('pages.xray.balancer.tagDuplicate') : '')}
validateStatus={tagError ? 'error' : showDuplicate ? 'warning' : ''}
help={tagError || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
hasFeedback
>
<Input
@@ -157,8 +169,8 @@ export default function BalancerFormModal({
<Form.Item
label={t('pages.xray.balancer.selector')}
required
validateStatus={issues.selector ? 'error' : ''}
help={issues.selector || ''}
validateStatus={selectorError ? 'error' : ''}
help={selectorError || ''}
hasFeedback
>
<Select
@@ -212,16 +224,18 @@ export default function BalancerFormModal({
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => updateBaselines([...baselines, ''])}
/>
{baselines.map((b, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Input
value={b}
aria-label={t('pages.xray.balancer.baselines')}
placeholder="e.g. 1s"
onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
/>
<InputAddon onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
<InputAddon ariaLabel={t('remove')} onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
@@ -232,28 +246,32 @@ export default function BalancerFormModal({
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
/>
{costs.map((c, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Switch
checked={c.regexp}
aria-label={t('pages.xray.balancer.costRegexp')}
checkedChildren="re"
unCheckedChildren="lit"
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
/>
<Input
value={c.match}
aria-label={t('pages.xray.balancer.costMatch')}
placeholder="tag pattern"
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
/>
<InputNumber
value={c.value}
aria-label={t('pages.xray.balancer.costValue')}
placeholder="weight"
style={{ width: 100 }}
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
/>
<InputAddon onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
<InputAddon ariaLabel={t('remove')} onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>

Some files were not shown because too many files have changed in this diff Show More