diff --git a/frontend/src/components/form/DateTimePicker.css b/frontend/src/components/form/DateTimePicker.css index b745afb4a..5fbcbf514 100644 --- a/frontend/src/components/form/DateTimePicker.css +++ b/frontend/src/components/form/DateTimePicker.css @@ -1,5 +1,6 @@ .jdp-wrap { width: 100%; + position: relative; } .jdp-wrap > * { @@ -33,3 +34,38 @@ pointer-events: none; opacity: 0.6; } + +/* persian-calendar-suite has no allowClear; overlay our own clear button so + the Jalali picker matches the Gregorian AntD DatePicker's X affordance. */ +.jdp-wrap .jdp-clear { + position: absolute; + top: 50%; + right: 11px; + transform: translateY(-50%); + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + font-size: 12px; + line-height: 1; + color: rgba(0, 0, 0, 0.25); + transition: color 0.2s; +} + +.jdp-wrap .jdp-clear:hover { + color: rgba(0, 0, 0, 0.45); +} + +.jdp-dark .jdp-clear { + color: rgba(255, 255, 255, 0.30); +} + +.jdp-dark .jdp-clear:hover, +.jdp-ultra .jdp-clear:hover { + color: rgba(255, 255, 255, 0.45); +} diff --git a/frontend/src/components/form/DateTimePicker.tsx b/frontend/src/components/form/DateTimePicker.tsx index bdd521b6f..ce590db86 100644 --- a/frontend/src/components/form/DateTimePicker.tsx +++ b/frontend/src/components/form/DateTimePicker.tsx @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { CloseCircleFilled } from '@ant-design/icons'; import { DatePicker } from 'antd'; import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; @@ -54,6 +55,10 @@ export default function DateTimePicker({ }: DateTimePickerProps) { const { datepicker } = useDatepicker(); const { isDark, isUltra } = useTheme(); + const jalaliRef = useRef(null); + // Bumped on clear: persian-calendar-suite reads `value` only on mount, so + // remounting via key is the only way to reflect an externally cleared value. + const [clearNonce, setClearNonce] = useState(0); const persianTheme = useMemo(() => { if (isUltra) return ULTRA_DARK_THEME; @@ -61,10 +66,21 @@ export default function DateTimePicker({ return LIGHT_THEME; }, [isDark, isUltra]); + // The library hardcodes a Persian placeholder and exposes no working prop to + // override it, so clear it (or apply the caller's) on the input directly so + // the empty field shows no leftover Persian text. No dep array: re-apply + // after every render (incl. clear-remounts). + useEffect(() => { + if (datepicker !== 'jalalian') return; + const input = jalaliRef.current?.querySelector('input'); + if (input) input.placeholder = placeholder; + }); + if (datepicker === 'jalalian') { return ( -
+
{ if (next == null || next === '') { @@ -80,6 +96,21 @@ export default function DateTimePicker({ rtlCalendar theme={persianTheme} /> + {value && !disabled && ( + + )}
); } diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index d815ad679..aadbe6e0d 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -12,6 +12,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp'; import { getHeaderValue } from './headers'; +import { canEnableTlsFlow } from './protocol-capabilities'; // Share-link generators. Each per-protocol fn takes a typed inbound plus // client overrides and returns a URL (or '' when the protocol doesn't @@ -186,7 +187,7 @@ export function genVmessLink(input: GenVmessLinkInput): string { const stream = inbound.streamSettings; if (!stream) return ''; - const tls = forceTls === 'same' ? stream.security : forceTls; + const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls; const obj: Record = { v: '2', ps: remark, @@ -382,7 +383,6 @@ export function genVlessLink(input: GenVlessLinkInput): string { if (tls.settings.pinnedPeerCertSha256.length > 0) { params.set('pcs', tls.settings.pinnedPeerCertSha256.join(',')); } - if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow); } applyExternalProxyTLSParams(externalProxy, params, security); } else if (security === 'reality') { @@ -402,12 +402,23 @@ export function genVlessLink(input: GenVlessLinkInput): string { if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]); if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX); if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify); - if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow); } } else { params.set('security', 'none'); } + // XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the + // VLESS-level encryption stands in for transport TLS). Mirrors the backend's + // vlessFlowAllowed and the form's flow-field gating so panel link, share + // link and subscription agree. + if (flow.length > 0 && canEnableTlsFlow({ + protocol: inbound.protocol, + settings: inbound.settings, + streamSettings: stream, + })) { + params.set('flow', flow); + } + const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`); for (const [key, value] of params) url.searchParams.set(key, value); url.hash = encodeURIComponent(remark); diff --git a/frontend/src/schemas/protocols/security/index.ts b/frontend/src/schemas/protocols/security/index.ts index 19841c6ab..2540da84e 100644 --- a/frontend/src/schemas/protocols/security/index.ts +++ b/frontend/src/schemas/protocols/security/index.ts @@ -15,9 +15,18 @@ export type Security = z.infer; // 'none' neither key appears. The Xray panel's StreamSettings class emits // `undefined` for the inactive branch which strips the key during JSON // serialization, so this DU faithfully describes what's on disk. -export const SecuritySettingsSchema = z.discriminatedUnion('security', [ - z.object({ security: z.literal('none') }), - z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }), - z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }), +// +// Tunnel (dokodemo-door / TProxy) is transportless and may carry only +// `sockopt` — its streamSettings has no `security` key at all. The +// transportless branch accepts that shape, mirroring NetworkSettingsSchema's +// `network: never().optional()` handling. A present-but-invalid security +// still fails both branches so a typo can't slip through. +export const SecuritySettingsSchema = z.union([ + z.discriminatedUnion('security', [ + z.object({ security: z.literal('none') }), + z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }), + z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }), + ]), + z.object({ security: z.never().optional() }), ]); export type SecuritySettings = z.infer; diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index 28ead0757..071fdebe1 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -567,3 +567,94 @@ describe('external proxy pinned cert (pcs)', () => { expect(new URL(link).searchParams.has('pcs')).toBe(false); }); }); + +// #5322: the panel copy-link must carry XTLS Vision `flow` for VLESS+XHTTP +// when VLESS encryption (vlessenc) is on, matching the form's flow display +// and the backend subscription. Gating is via canEnableTlsFlow. +describe('genVlessLink flow gating (#5322)', () => { + function vlessXhttp(encryption: string) { + return InboundSchema.parse({ + id: 1, + up: 0, + down: 0, + total: 0, + remark: 'vlessenc', + enable: true, + expiryTime: 0, + listen: '', + port: 443, + tag: 'inbound-vless-xhttp', + sniffing: { + enabled: false, + destOverride: [], + metadataOnly: false, + routeOnly: false, + ipsExcluded: [], + domainsExcluded: [], + }, + protocol: 'vless', + settings: { + clients: [ + { + id: '11111111-2222-3333-4444-555555555555', + email: 'a@example.test', + flow: 'xtls-rprx-vision', + limitIp: 0, + totalGB: 0, + expiryTime: 0, + enable: true, + tgId: 0, + subId: 's1', + comment: '', + reset: 0, + }, + ], + decryption: 'none', + encryption, + fallbacks: [], + }, + streamSettings: { + network: 'xhttp', + xhttpSettings: {}, + security: 'none', + }, + }); + } + + const clientId = '11111111-2222-3333-4444-555555555555'; + + it('emits flow for VLESS+XHTTP when vless encryption is enabled', () => { + const link = genVlessLink({ + inbound: vlessXhttp('mlkem768x25519plus.native.0rtt.SGVsbG8'), + address: 'example.test', + port: 443, + clientId, + flow: 'xtls-rprx-vision', + }); + expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision'); + }); + + it('omits flow for VLESS+XHTTP without vless encryption', () => { + const link = genVlessLink({ + inbound: vlessXhttp('none'), + address: 'example.test', + port: 443, + clientId, + flow: 'xtls-rprx-vision', + }); + expect(new URL(link).searchParams.has('flow')).toBe(false); + }); + + it('still emits flow for classic TCP+REALITY Vision', () => { + const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!; + const typed = InboundSchema.parse(raw); + const link = genVlessLink({ + inbound: typed, + address: 'example.test', + port: 443, + clientId: (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id, + flow: 'xtls-rprx-vision', + }); + expect(new URL(link).searchParams.get('flow')).toBe('xtls-rprx-vision'); + }); +});