feat(sub): per-client external links and remote subscriptions

Add a Links tab to the client form for attaching third-party share
links and remote subscription URLs per client. They are merged into
the client's raw/JSON/Clash subscription output: links are emitted
verbatim and parsed for JSON/Clash; subscription URLs are fetched
(cached, with a short timeout) and their configs merged in.

i18n keys added across all 13 locales.
This commit is contained in:
MHSanaei
2026-06-14 20:57:14 +02:00
parent 7c2598fae9
commit dcb923b4a1
33 changed files with 1204 additions and 28 deletions
@@ -1,27 +1,23 @@
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
interface Option {
value: number;
}
interface SelectAllClearButtonsProps {
options: Option[];
value: number[];
onChange: (value: number[]) => void;
interface SelectAllClearButtonsProps<T extends string | number = number> {
options: Array<{ value: T }>;
value: T[];
onChange: (value: T[]) => void;
/** Override the default "Select all" label (defaults to the inbound copy). */
selectAllLabel?: string;
/** Override the default "Clear all" label (defaults to the inbound copy). */
clearLabel?: string;
}
export default function SelectAllClearButtons({
export default function SelectAllClearButtons<T extends string | number = number>({
options,
value,
onChange,
selectAllLabel,
clearLabel,
}: SelectAllClearButtonsProps) {
}: SelectAllClearButtonsProps<T>) {
const { t } = useTranslation();
const optionValues = options.map((o) => o.value);
+17 -1
View File
@@ -22,6 +22,7 @@ import {
type ClientsSummary,
type ClientPageResponse,
type InboundOption,
type ExternalLink,
type BulkAdjustResult,
type BulkAttachResult,
type BulkCreateResult,
@@ -30,7 +31,10 @@ import {
} from '@/schemas/client';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
// One row sent to POST /clients/:email/externalLinks.
export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
@@ -350,6 +354,12 @@ export function useClients() {
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const setExternalLinksMut = useMutation({
mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkAttachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
@@ -364,6 +374,7 @@ export function useClients() {
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkDetachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
@@ -424,6 +435,10 @@ export function useClients() {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return attachMut.mutateAsync({ email, inboundIds });
}, [attachMut]);
const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return setExternalLinksMut.mutateAsync({ email, externalLinks });
}, [setExternalLinksMut]);
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
@@ -553,6 +568,7 @@ export function useClients() {
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
setExternalLinks,
bulkAttach,
detach,
bulkDetach,
+13 -2
View File
@@ -503,12 +503,12 @@ export const sections: readonly Section[] = [
{
method: 'GET',
path: '/panel/api/clients/get/:email',
summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
summary: 'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
],
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
},
{
method: 'POST',
@@ -563,6 +563,17 @@ export const sections: readonly Section[] = [
body: '{\n "inboundIds": [5]\n}',
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/api/clients/:email/externalLinks',
summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
{ name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' },
],
body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n ]\n}',
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/api/clients/resetAllTraffics',
+108 -3
View File
@@ -16,16 +16,17 @@ import {
Tabs,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import { EyeOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
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 { formatInboundLabel } from '@/lib/inbounds/label';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -38,6 +39,13 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
// One editable row in the Links tab. `key` is a stable client-side id for React.
interface ExternalLinkRow {
key: number;
kind: 'link' | 'subscription';
value: string;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
@@ -51,10 +59,13 @@ interface SaveMetaEdit {
email: string;
attach: number[];
detach: number[];
externalLinks: ExternalLinkInput[];
}
interface SaveMetaCreate {
isEdit: false;
email: string;
externalLinks: ExternalLinkInput[];
}
interface SaveCreatePayload {
@@ -67,6 +78,7 @@ interface ClientFormModalProps {
mode: Mode;
client: ClientRecord | null;
inbounds: InboundOption[];
attachedExternalLinks?: ExternalLink[];
attachedIds?: number[];
tgBotEnable?: boolean;
groups?: string[];
@@ -98,6 +110,7 @@ interface FormState {
comment: string;
enable: boolean;
inboundIds: number[];
externalLinks: ExternalLinkRow[];
}
function emptyForm(): FormState {
@@ -121,9 +134,19 @@ function emptyForm(): FormState {
comment: '',
enable: true,
inboundIds: [],
externalLinks: [],
};
}
let externalLinkRowSeq = 0;
function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
return (links || []).map((l) => ({
key: (externalLinkRowSeq += 1),
kind: l.kind === 'subscription' ? 'subscription' : 'link',
value: l.value || '',
}));
}
function bytesToGB(bytes: number): number {
if (!bytes || bytes <= 0) return 0;
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
@@ -139,6 +162,7 @@ export default function ClientFormModal({
mode,
client,
inbounds,
attachedExternalLinks = [],
attachedIds = [],
tgBotEnable = false,
groups = [],
@@ -162,6 +186,27 @@ export default function ClientFormModal({
setForm((prev) => ({ ...prev, [key]: value }));
}
function addExternalLinkRow(kind: 'link' | 'subscription') {
setForm((prev) => ({
...prev,
externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }],
}));
}
function updateExternalLinkRow(key: number, value: string) {
setForm((prev) => ({
...prev,
externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)),
}));
}
function removeExternalLinkRow(key: number) {
setForm((prev) => ({
...prev,
externalLinks: prev.externalLinks.filter((r) => r.key !== key),
}));
}
useEffect(() => {
if (!open) return;
setIpsModalOpen(false);
@@ -186,6 +231,7 @@ export default function ClientFormModal({
comment: client.comment || '',
enable: !!client.enable,
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
externalLinks: toExternalLinkRows(attachedExternalLinks),
};
if (et < 0) {
next.delayedStart = true;
@@ -300,6 +346,9 @@ export default function ClientFormModal({
[inbounds],
);
const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);
const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]);
async function loadIps() {
if (!isEdit || !client?.email) return;
setIpsLoading(true);
@@ -400,6 +449,10 @@ export default function ClientFormModal({
clientPayload.reverse = { tag: reverseTag };
}
const externalLinks: ExternalLinkInput[] = form.externalLinks
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
.filter((r) => r.value !== '');
setSubmitting(true);
try {
let msg;
@@ -413,11 +466,12 @@ export default function ClientFormModal({
email: client.email,
attach: toAttach,
detach: toDetach,
externalLinks,
});
} else {
msg = await save(
{ client: clientPayload, inboundIds: form.inboundIds },
{ isEdit: false },
{ isEdit: false, email: clientPayload.email as string, externalLinks },
);
}
if (msg?.success) close();
@@ -692,6 +746,57 @@ export default function ClientFormModal({
</>
),
},
{
key: 'links',
label: t('pages.clients.tabLinks'),
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
{t('pages.clients.linksHint')}
</Typography.Paragraph>
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
{t('pages.clients.addExternalLink')}
</Button>
<div style={{ marginTop: 12, marginBottom: 24 }}>
{linkRows.length === 0 ? (
<Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
) : linkRows.map((row) => (
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
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)} />
</Tooltip>
</div>
))}
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
{t('pages.clients.addExternalSubscription')}
</Button>
<div style={{ marginTop: 12 }}>
{subscriptionRows.length === 0 ? (
<Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
) : subscriptionRows.map((row) => (
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
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)} />
</Tooltip>
</div>
))}
</div>
</>
),
},
]}
/>
</Form>
+20 -5
View File
@@ -53,7 +53,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
import { useClients } from '@/hooks/useClients';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
import AppSidebar from '@/layouts/AppSidebar';
import { IntlUtil, SizeFormatter } from '@/utils';
@@ -199,7 +199,7 @@ export default function ClientsPage() {
setQuery,
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
resetTraffic, resetAllTraffics, delDepleted, setEnable,
applyTrafficEvent, applyClientStatsEvent,
refresh,
@@ -220,6 +220,7 @@ export default function ClientsPage() {
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
const [infoOpen, setInfoOpen] = useState(false);
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
const [qrOpen, setQrOpen] = useState(false);
@@ -429,6 +430,7 @@ export default function ClientsPage() {
setFormMode('add');
setEditingClient(null);
setEditingAttachedIds([]);
setEditingExternalLinks([]);
setFormOpen(true);
}
@@ -441,6 +443,7 @@ export default function ClientsPage() {
setEditingClient(merged);
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
setEditingAttachedIds([...ids]);
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
setFormOpen(true);
}
@@ -567,10 +570,18 @@ export default function ClientsPage() {
const onSave = useCallback(async (
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
meta:
| { isEdit: false; email: string; externalLinks: ExternalLinkInput[] }
| { isEdit: true; email: string; attach: number[]; detach: number[]; externalLinks: ExternalLinkInput[] },
) => {
if (!meta.isEdit) {
return create(payload);
const createMsg = await create(payload);
if (!createMsg?.success) return createMsg;
if (meta.email && meta.externalLinks.length > 0) {
const r = await setExternalLinks(meta.email, meta.externalLinks);
if (!r?.success) return r;
}
return createMsg;
}
const updateMsg = await update(meta.email, payload);
if (!updateMsg?.success) return updateMsg;
@@ -582,8 +593,11 @@ export default function ClientsPage() {
const r = await detach(meta.email, meta.detach);
if (!r?.success) return r;
}
// Always replace the client's external links (an empty set clears them).
const r = await setExternalLinks(meta.email, meta.externalLinks);
if (!r?.success) return r;
return updateMsg;
}, [create, update, attach, detach]);
}, [create, update, attach, detach, setExternalLinks]);
const pageClass = useMemo(() => {
const classes = ['clients-page'];
@@ -1243,6 +1257,7 @@ export default function ClientsPage() {
mode={formMode}
client={editingClient}
attachedIds={editingAttachedIds}
attachedExternalLinks={editingExternalLinks}
inbounds={inbounds}
tgBotEnable={tgBotEnable}
groups={allGroups}
+12
View File
@@ -71,9 +71,20 @@ export const ClientPageResponseSchema = z.object({
groups: nullableStringArray.optional(),
});
// A per-client external link surfaced in the client's subscription:
// kind=link is a single share link, kind=subscription is a remote sub URL.
export const ExternalLinkSchema = z.object({
kind: z.enum(['link', 'subscription']).default('link'),
value: z.string(),
remark: z.string().optional().default(''),
}).loose();
export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
export const ClientHydrateSchema = z.object({
client: ClientRecordSchema,
inboundIds: nullableNumberArray,
externalLinks: ExternalLinkListSchema.optional(),
});
export const BulkAdjustResultSchema = z.object({
@@ -203,6 +214,7 @@ export const ClientBulkAddFormSchema = z.object({
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
export type InboundOption = z.infer<typeof InboundOptionSchema>;
export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;