mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 17:37:19 +00:00
Feature/fix external subscription client expiry (#6333)
* fix(sub): honor client expiry for external links * fix(ui): show client expiry on external links * fix(sub): address external expiry review
This commit is contained in:
@@ -7290,7 +7290,7 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.",
|
||||
"summary": "Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions. The owning client's disabled or expired state also stops these rows from being emitted on future subscription fetches; credentials already imported by an app remain valid until the external provider revokes them.",
|
||||
"operationId": "post_panel_api_clients_email_externalLinks",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -7315,7 +7315,7 @@
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET."
|
||||
"description": "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means no link-specific expiry; the owning client's enabled state and expiry still apply. A negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -17,6 +17,8 @@ interface DateTimePickerProps {
|
||||
format?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
allowClear?: boolean;
|
||||
maxDate?: Dayjs;
|
||||
}
|
||||
|
||||
const LIGHT_THEME = {
|
||||
@@ -53,6 +55,8 @@ export default function DateTimePicker({
|
||||
format = 'YYYY-MM-DD HH:mm:ss',
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
allowClear = true,
|
||||
maxDate,
|
||||
}: DateTimePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { datepicker } = useDatepicker();
|
||||
@@ -78,6 +82,14 @@ export default function DateTimePicker({
|
||||
return LIGHT_THEME;
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const commitChange = (next: Dayjs | null) => {
|
||||
if (next && maxDate && next.isAfter(maxDate)) {
|
||||
if (datepicker === 'jalalian') setClearNonce((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -100,19 +112,20 @@ export default function DateTimePicker({
|
||||
onChange={(next: number | string | null) => {
|
||||
if (suppressMountEmit.current) return;
|
||||
if (next == null || next === '') {
|
||||
onChange(null);
|
||||
commitChange(null);
|
||||
return;
|
||||
}
|
||||
const ms = typeof next === 'number' ? next : Number(next);
|
||||
if (Number.isFinite(ms)) onChange(dayjs(ms));
|
||||
if (Number.isFinite(ms)) commitChange(dayjs(ms));
|
||||
}}
|
||||
showTime={showTime}
|
||||
outputFormat="timestamp"
|
||||
maxDate={maxDate?.toDate()}
|
||||
persianNumbers
|
||||
rtlCalendar
|
||||
theme={persianTheme}
|
||||
/>
|
||||
{value && !disabled && (
|
||||
{value && allowClear && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="jdp-clear"
|
||||
@@ -120,7 +133,7 @@ export default function DateTimePicker({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
commitChange(null);
|
||||
setClearNonce((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
@@ -134,13 +147,15 @@ export default function DateTimePicker({
|
||||
return (
|
||||
<DatePicker
|
||||
value={value}
|
||||
onChange={(next) => onChange(next || null)}
|
||||
onCalendarChange={(next) => onChange((Array.isArray(next) ? next[0] : next) || null)}
|
||||
onChange={(next) => commitChange(next || null)}
|
||||
onCalendarChange={(next) => commitChange((Array.isArray(next) ? next[0] : next) || null)}
|
||||
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
||||
needConfirm={false}
|
||||
format={format}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
allowClear={allowClear}
|
||||
maxDate={maxDate}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function resolveExternalLinkExpiry(
|
||||
externalExpiry: number | null | undefined,
|
||||
clientExpiry: number | null | undefined,
|
||||
): number {
|
||||
const explicitExpiry = Number(externalExpiry) || 0;
|
||||
if (explicitExpiry > 0) return explicitExpiry;
|
||||
|
||||
const inheritedExpiry = Number(clientExpiry) || 0;
|
||||
return inheritedExpiry > 0 ? inheritedExpiry : 0;
|
||||
}
|
||||
@@ -1118,14 +1118,14 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/:email/externalLinks',
|
||||
summary:
|
||||
"Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.",
|
||||
"Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions. The owning client's disabled or expired state also stops these rows from being emitted on future subscription fetches; credentials already imported by an app remain valid until the external provider revokes them.",
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
{
|
||||
name: 'externalLinks',
|
||||
in: 'body',
|
||||
type: 'object[]',
|
||||
desc: 'Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.',
|
||||
desc: "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means no link-specific expiry; the owning client's enabled state and expiry still apply. A negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.",
|
||||
},
|
||||
],
|
||||
body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] " }\n ]\n}',
|
||||
|
||||
@@ -34,6 +34,7 @@ import { HttpUtil, IntlUtil, RandomUtil, Wireguard } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
|
||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
@@ -1370,17 +1371,22 @@ export default function ClientFormModal({
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={`externalLinks.${index}.expiryTime`}
|
||||
render={({ field: expiryField }) => (
|
||||
<DateTimePicker
|
||||
value={
|
||||
Number(expiryField.value) > 0
|
||||
? dayjs(Number(expiryField.value))
|
||||
: null
|
||||
}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
/>
|
||||
)}
|
||||
render={({ field: expiryField }) => {
|
||||
const displayedExpiry = resolveExternalLinkExpiry(
|
||||
expiryField.value,
|
||||
expiryDate,
|
||||
);
|
||||
const hasSpecificExpiry = Number(expiryField.value) > 0;
|
||||
return (
|
||||
<DateTimePicker
|
||||
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
allowClear={hasSpecificExpiry}
|
||||
maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1442,17 +1448,22 @@ export default function ClientFormModal({
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={`externalLinks.${index}.expiryTime`}
|
||||
render={({ field: expiryField }) => (
|
||||
<DateTimePicker
|
||||
value={
|
||||
Number(expiryField.value) > 0
|
||||
? dayjs(Number(expiryField.value))
|
||||
: null
|
||||
}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
/>
|
||||
)}
|
||||
render={({ field: expiryField }) => {
|
||||
const displayedExpiry = resolveExternalLinkExpiry(
|
||||
expiryField.value,
|
||||
expiryDate,
|
||||
);
|
||||
const hasSpecificExpiry = Number(expiryField.value) > 0;
|
||||
return (
|
||||
<DateTimePicker
|
||||
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
|
||||
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
|
||||
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
allowClear={hasSpecificExpiry}
|
||||
maxDate={expiryDate > 0 ? dayjs(expiryDate) : undefined}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
|
||||
describe('resolveExternalLinkExpiry', () => {
|
||||
it('uses the client expiry when the external link has no specific expiry', () => {
|
||||
expect(resolveExternalLinkExpiry(0, 1_800_000_000_000)).toBe(1_800_000_000_000);
|
||||
});
|
||||
|
||||
it('keeps an explicit external-link expiry', () => {
|
||||
expect(resolveExternalLinkExpiry(1_700_000_000_000, 1_800_000_000_000)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('stays empty when neither expiry is set', () => {
|
||||
expect(resolveExternalLinkExpiry(0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,32 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DateTimePicker from '@/components/form/DateTimePicker';
|
||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
vi.mock('persian-calendar-suite', () => ({
|
||||
PersianDateTimePicker: ({
|
||||
maxDate,
|
||||
onChange,
|
||||
}: {
|
||||
maxDate?: Date;
|
||||
onChange?: (value: number) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Persian date time picker"
|
||||
data-testid="persian-date-time-picker"
|
||||
data-max-date={maxDate?.toISOString()}
|
||||
onClick={() => onChange?.((maxDate?.getTime() ?? 0) + 1)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => setDatepicker('gregorian'));
|
||||
|
||||
function openPicker(): void {
|
||||
const input = document.querySelector('.ant-picker input');
|
||||
if (!input) throw new Error('picker input not rendered');
|
||||
@@ -40,4 +61,34 @@ describe('DateTimePicker', () => {
|
||||
|
||||
expect(document.querySelector('.ant-picker-ok')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the Gregorian clear control and disables dates after maxDate', () => {
|
||||
const maxDate = dayjs().add(1, 'day').startOf('day');
|
||||
renderWithProviders(
|
||||
<DateTimePicker value={maxDate} onChange={vi.fn()} allowClear={false} maxDate={maxDate} />,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.ant-picker-clear')).toBeNull();
|
||||
openPicker();
|
||||
const blockedCell = document.querySelector(
|
||||
`.ant-picker-cell[title="${maxDate.add(1, 'day').format('YYYY-MM-DD')}"]`,
|
||||
);
|
||||
expect(blockedCell?.classList.contains('ant-picker-cell-disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('applies clear and max-date constraints to the Jalali picker', () => {
|
||||
setDatepicker('jalalian');
|
||||
const maxDate = dayjs('2030-01-02T03:04:05');
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<DateTimePicker value={maxDate} onChange={onChange} allowClear={false} maxDate={maxDate} />,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.jdp-clear')).toBeNull();
|
||||
expect(screen.getByTestId('persian-date-time-picker').dataset.maxDate).toBe(
|
||||
maxDate.toDate().toISOString(),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('persian-date-time-picker'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user