Browse Source

fix(clients): render all tunnel configs for multi-inbound client (#6346) (#6349)

When a client belongs to multiple AmneziaWG or WireGuard inbounds (e.g. across
remote nodes), findAmneziaWGInbounds and findWireguardInbounds only returned the
first matching inbound. Consequently, ClientInfoModal and ClientQrModal rendered
only one config block, making other inbounds' configs unreachable.

- Add findAmneziaWGInbounds and findWireguardInbounds returning all matching inbounds
- Add formatTunnelConfigMeta helper to unify label, fileName, and qrRemark resolution
- Support addressOverride in buildWireguardClientConfig from tunnelAllowedIPs
- Render all tunnel configs in ClientInfoModal and ClientQrModal with node remarks
- Distinguish download filenames with inbound remark suffix to avoid collisions
- Add component integration tests covering multi-inbound modal rendering
MRVX 8 hours ago
parent
commit
47964afbc5

+ 23 - 0
frontend/src/lib/inbounds/label.ts

@@ -7,3 +7,26 @@ export function formatInboundLabel(tag?: string, remark?: string): string {
   if (remarkText) return remarkText;
   if (remarkText) return remarkText;
   return (tag || '').trim();
   return (tag || '').trim();
 }
 }
+
+export function formatTunnelConfigMeta(
+  inbound: { id?: number; tag?: string; remark?: string },
+  email?: string,
+  totalCount = 1,
+): {
+  label?: string;
+  fileName: string;
+  qrRemark: string;
+} {
+  const inboundName =
+    formatInboundLabel(inbound.tag, inbound.remark) ||
+    (inbound.id != null ? `inbound-${inbound.id}` : '');
+  const label = totalCount > 1 ? inboundName : undefined;
+  const suffix = inbound.remark || inbound.tag || (inbound.id != null ? `${inbound.id}` : '');
+  const safeSuffix = suffix ? `-${suffix.replace(/[^\w.-]+/g, '_')}` : '';
+  const emailPrefix = email || 'client';
+  const fileName = `${emailPrefix}${totalCount > 1 ? safeSuffix : ''}.conf`;
+  const qrRemark =
+    totalCount > 1 && inboundName ? [inboundName, email].filter(Boolean).join(' - ') : email || '';
+
+  return { label, fileName, qrRemark };
+}

+ 67 - 41
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -10,7 +10,7 @@ import {
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
 import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
 import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
-import { formatInboundLabel } from '@/lib/inbounds/label';
+import { formatInboundLabel, formatTunnelConfigMeta } from '@/lib/inbounds/label';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useClientHwids } from '@/hooks/useClientHwids';
 import { useClientHwids } from '@/hooks/useClientHwids';
@@ -22,12 +22,12 @@ import ClientHwidListModal from '@/components/clients/ClientHwidList';
 import ConfigBlock from '@/components/clients/ConfigBlock';
 import ConfigBlock from '@/components/clients/ConfigBlock';
 import {
 import {
   buildWireguardClientConfig,
   buildWireguardClientConfig,
-  findWireguardInbound,
+  findWireguardInbounds,
   isWireguardClient,
   isWireguardClient,
 } from './wireguardConfig';
 } from './wireguardConfig';
 import {
 import {
   buildAmneziaWGClientConfig,
   buildAmneziaWGClientConfig,
-  findAmneziaWGInbound,
+  findAmneziaWGInbounds,
   isAmneziaWGClient,
   isAmneziaWGClient,
 } from './amneziawgConfig';
 } from './amneziawgConfig';
 import './ClientInfoModal.css';
 import './ClientInfoModal.css';
@@ -180,35 +180,47 @@ export default function ClientInfoModal({
       : '';
       : '';
 
 
   const showSubscription = !!(subSettings?.enable && client?.subId);
   const showSubscription = !!(subSettings?.enable && client?.subId);
-  const wgInbound = useMemo(
-    () => findWireguardInbound(client, inboundsById),
+  const wgInbounds = useMemo(
+    () => findWireguardInbounds(client, inboundsById),
     [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 wgConfigs = useMemo(() => {
+    if (!client || !isWireguardClient(client)) return [];
+    return wgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildWireguardClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
 
-  const awgInbound = useMemo(
-    () => findAmneziaWGInbound(client, inboundsById),
+  const awgInbounds = useMemo(
+    () => findAmneziaWGInbounds(client, inboundsById),
     [client, inboundsById],
     [client, inboundsById],
   );
   );
-  const awgConfigText = useMemo(() => {
-    if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
-    const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
-    return buildAmneziaWGClientConfig(
-      client,
-      awgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-      address,
-    );
-  }, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
+  const awgConfigs = useMemo(() => {
+    if (!client || !isAmneziaWGClient(client)) return [];
+    return awgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildAmneziaWGClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
 
   async function copyValue(text: string) {
   async function copyValue(text: string) {
     if (!text) return;
     if (!text) return;
@@ -779,27 +791,41 @@ export default function ClientInfoModal({
               </>
               </>
             )}
             )}
 
 
-            {wgConfigText && client && (
+            {wgConfigs.length > 0 && client && (
               <>
               <>
                 <Divider>{t('pages.clients.wireguardConfig')}</Divider>
                 <Divider>{t('pages.clients.wireguardConfig')}</Divider>
-                <ConfigBlock
-                  label={t('pages.clients.config')}
-                  text={wgConfigText}
-                  fileName={`${client.email}.conf`}
-                  qrRemark={client.email || 'peer'}
-                />
+                {wgConfigs.map(({ inbound, text }) => {
+                  const meta = formatTunnelConfigMeta(inbound, client.email, wgConfigs.length);
+                  return (
+                    <ConfigBlock
+                      key={`wg-${inbound.id}`}
+                      label={meta.label || t('pages.clients.config')}
+                      text={text}
+                      fileName={meta.fileName}
+                      qrRemark={meta.qrRemark}
+                      tagColor="cyan"
+                    />
+                  );
+                })}
               </>
               </>
             )}
             )}
 
 
-            {awgConfigText && client && (
+            {awgConfigs.length > 0 && client && (
               <>
               <>
                 <Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
                 <Divider>{t('pages.clients.amneziaWgConfig')}</Divider>
-                <ConfigBlock
-                  label={t('pages.clients.config')}
-                  text={awgConfigText}
-                  fileName={`${client.email}.conf`}
-                  qrRemark={client.email || 'peer'}
-                />
+                {awgConfigs.map(({ inbound, text }) => {
+                  const meta = formatTunnelConfigMeta(inbound, client.email, awgConfigs.length);
+                  return (
+                    <ConfigBlock
+                      key={`awg-${inbound.id}`}
+                      label={meta.label || t('pages.clients.config')}
+                      text={text}
+                      fileName={meta.fileName}
+                      qrRemark={meta.qrRemark}
+                      tagColor="purple"
+                    />
+                  );
+                })}
               </>
               </>
             )}
             )}
           </>
           </>

+ 65 - 54
frontend/src/pages/clients/ClientQrModal.tsx

@@ -6,14 +6,15 @@ import { isPostQuantumLink } from '@/lib/xray/inbound-link';
 import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
 import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
 import { QrPanel } from '@/pages/inbounds/qr';
 import { QrPanel } from '@/pages/inbounds/qr';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import { formatTunnelConfigMeta } from '@/lib/inbounds/label';
 import {
 import {
   buildWireguardClientConfig,
   buildWireguardClientConfig,
-  findWireguardInbound,
+  findWireguardInbounds,
   isWireguardClient,
   isWireguardClient,
 } from './wireguardConfig';
 } from './wireguardConfig';
 import {
 import {
   buildAmneziaWGClientConfig,
   buildAmneziaWGClientConfig,
-  findAmneziaWGInbound,
+  findAmneziaWGInbounds,
   isAmneziaWGClient,
   isAmneziaWGClient,
 } from './amneziawgConfig';
 } from './amneziawgConfig';
 
 
@@ -67,38 +68,50 @@ export default function ClientQrModal({
       ? subSettings.subJsonURI + subId
       ? subSettings.subJsonURI + subId
       : '';
       : '';
 
 
-  const wgInbound = useMemo(
-    () => findWireguardInbound(client, inboundsById),
+  const wgInbounds = useMemo(
+    () => findWireguardInbounds(client, inboundsById),
     [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 wgConfigs = useMemo(() => {
+    if (!client || !isWireguardClient(client)) return [];
+    return wgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildWireguardClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, wgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
 
-  const awgInbound = useMemo(
-    () => findAmneziaWGInbound(client, inboundsById),
+  const awgInbounds = useMemo(
+    () => findAmneziaWGInbounds(client, inboundsById),
     [client, inboundsById],
     [client, inboundsById],
   );
   );
-  const awgConfigText = useMemo(() => {
-    if (!client || !awgInbound || !isAmneziaWGClient(client)) return '';
-    const address = awgInbound ? (tunnelAllowedIPs?.[awgInbound.id] ?? '') : '';
-    return buildAmneziaWGClientConfig(
-      client,
-      awgInbound,
-      window.location.hostname,
-      subSettings?.publicHost ?? '',
-      address,
-    );
-  }, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
+  const awgConfigs = useMemo(() => {
+    if (!client || !isAmneziaWGClient(client)) return [];
+    return awgInbounds
+      .map((ib) => {
+        const address = tunnelAllowedIPs?.[ib.id] ?? '';
+        const text = buildAmneziaWGClientConfig(
+          client,
+          ib,
+          window.location.hostname,
+          subSettings?.publicHost ?? '',
+          address,
+        );
+        return { inbound: ib, text };
+      })
+      .filter((c) => !!c.text);
+  }, [client, awgInbounds, tunnelAllowedIPs, subSettings?.publicHost]);
 
 
   const hasAnything =
   const hasAnything =
-    !!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
+    !!subLink || !!subJsonLink || wgConfigs.length > 0 || awgConfigs.length > 0 || links.length > 0;
 
 
   // The reset runs during render so the effect only carries the request.
   // The reset runs during render so the effect only carries the request.
   const openSubId = open ? (client?.subId ?? '') : '';
   const openSubId = open ? (client?.subId ?? '') : '';
@@ -172,42 +185,40 @@ export default function ClientQrModal({
         ),
         ),
       });
       });
     });
     });
-    if (wgConfigText) {
-      out.push({
-        key: 'wg-config',
-        label: (
+    wgConfigs.forEach(({ inbound, text }) => {
+      const meta = formatTunnelConfigMeta(inbound, client?.email, wgConfigs.length);
+      const label = (
+        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
           <Tag color="cyan" style={{ margin: 0 }}>
           <Tag color="cyan" style={{ margin: 0 }}>
             {t('pages.clients.wireguardConfig')}
             {t('pages.clients.wireguardConfig')}
           </Tag>
           </Tag>
-        ),
-        children: (
-          <QrPanel
-            value={wgConfigText}
-            remark={client?.email || 'peer'}
-            downloadName={`${client?.email || 'peer'}.conf`}
-          />
-        ),
-      });
-    }
-    if (awgConfigText) {
+          {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
+        </span>
+      );
       out.push({
       out.push({
-        key: 'awg-config',
-        label: (
+        key: `wg-config-${inbound.id}`,
+        label,
+        children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
+      });
+    });
+    awgConfigs.forEach(({ inbound, text }) => {
+      const meta = formatTunnelConfigMeta(inbound, client?.email, awgConfigs.length);
+      const label = (
+        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
           <Tag color="purple" style={{ margin: 0 }}>
           <Tag color="purple" style={{ margin: 0 }}>
             {t('pages.clients.amneziaWgConfig')}
             {t('pages.clients.amneziaWgConfig')}
           </Tag>
           </Tag>
-        ),
-        children: (
-          <QrPanel
-            value={awgConfigText}
-            remark={client?.email || 'peer'}
-            downloadName={`${client?.email || 'peer'}.conf`}
-          />
-        ),
+          {meta.label && <span style={{ opacity: 0.85, fontSize: 12 }}>{meta.label}</span>}
+        </span>
+      );
+      out.push({
+        key: `awg-config-${inbound.id}`,
+        label,
+        children: <QrPanel value={text} remark={meta.qrRemark} downloadName={meta.fileName} />,
       });
       });
-    }
+    });
     return out;
     return out;
-  }, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
+  }, [subLink, subJsonLink, wgConfigs, awgConfigs, links, client?.email, t]);
 
 
   // Expanding the first panel is a render-time adjustment, not a side effect.
   // Expanding the first panel is a render-time adjustment, not a side effect.
   const firstKey = open && items.length > 0 ? items[0].key : null;
   const firstKey = open && items.length > 0 ? items[0].key : null;

+ 5 - 5
frontend/src/pages/clients/amneziawgConfig.ts

@@ -5,7 +5,7 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 // AmneziaWG clients are wire-identical to WireGuard clients (same
 // AmneziaWG clients are wire-identical to WireGuard clients (same
 // privateKey/publicKey/allowedIPs/preSharedKey/keepAlive fields on
 // privateKey/publicKey/allowedIPs/preSharedKey/keepAlive fields on
 // model.Client — see wireguardConfig.ts's isWireguardClient), so this duck
 // model.Client — see wireguardConfig.ts's isWireguardClient), so this duck
-// type can't tell the two protocols apart on its own; findAmneziaWGInbound's
+// type can't tell the two protocols apart on its own; findAmneziaWGInbounds's
 // protocol==='amneziawg' filter below is what actually disambiguates.
 // protocol==='amneziawg' filter below is what actually disambiguates.
 export function isAmneziaWGClient(client: ClientRecord | null | undefined): boolean {
 export function isAmneziaWGClient(client: ClientRecord | null | undefined): boolean {
   if (!client) return false;
   if (!client) return false;
@@ -18,13 +18,13 @@ export function isAmneziaWGClient(client: ClientRecord | null | undefined): bool
   );
   );
 }
 }
 
 
-export function findAmneziaWGInbound(
+export function findAmneziaWGInbounds(
   client: ClientRecord | null | undefined,
   client: ClientRecord | null | undefined,
   inboundsById: Record<number, InboundOption>,
   inboundsById: Record<number, InboundOption>,
-): InboundOption | undefined {
+): InboundOption[] {
   return (client?.inboundIds || [])
   return (client?.inboundIds || [])
-    .map((id) => inboundsById[id])
-    .find((ib) => ib?.protocol === 'amneziawg');
+    .map((id) => inboundsById?.[id])
+    .filter((ib): ib is InboundOption => ib?.protocol === 'amneziawg');
 }
 }
 
 
 // h4Line renders one H magic-header line, matching the Go backend's
 // h4Line renders one H magic-header line, matching the Go backend's

+ 6 - 5
frontend/src/pages/clients/wireguardConfig.ts

@@ -13,13 +13,13 @@ export function isWireguardClient(client: ClientRecord | null | undefined): bool
   );
   );
 }
 }
 
 
-export function findWireguardInbound(
+export function findWireguardInbounds(
   client: ClientRecord | null | undefined,
   client: ClientRecord | null | undefined,
   inboundsById: Record<number, InboundOption>,
   inboundsById: Record<number, InboundOption>,
-): InboundOption | undefined {
+): InboundOption[] {
   return (client?.inboundIds || [])
   return (client?.inboundIds || [])
-    .map((id) => inboundsById[id])
-    .find((ib) => ib?.protocol === 'wireguard');
+    .map((id) => inboundsById?.[id])
+    .filter((ib): ib is InboundOption => ib?.protocol === 'wireguard');
 }
 }
 
 
 export function buildWireguardClientConfig(
 export function buildWireguardClientConfig(
@@ -27,13 +27,14 @@ export function buildWireguardClientConfig(
   inbound: InboundOption | undefined,
   inbound: InboundOption | undefined,
   host = window.location.hostname,
   host = window.location.hostname,
   publicHost = '',
   publicHost = '',
+  addressOverride = '',
 ): string {
 ): string {
   const endpointHost = resolveShareHost(
   const endpointHost = resolveShareHost(
     inbound ?? {},
     inbound ?? {},
     inbound?.nodeAddress ?? '',
     inbound?.nodeAddress ?? '',
     preferPublicHost(host, publicHost),
     preferPublicHost(host, publicHost),
   );
   );
-  const address = client.allowedIPs || '10.0.0.2/32';
+  const address = addressOverride || client.allowedIPs || '10.0.0.2/32';
   const endpoint = `${endpointHost}:${inbound?.port || ''}`;
   const endpoint = `${endpointHost}:${inbound?.port || ''}`;
   const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
   const inboundName = inbound ? formatInboundLabel(inbound.tag, inbound.remark) : '';
   const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');
   const remark = [inboundName, client.email, client.comment].filter(Boolean).join(' - ');

+ 183 - 0
frontend/src/test/multi-tunnel-client-config.test.tsx

@@ -0,0 +1,183 @@
+import { describe, it, expect } from 'vitest';
+import { screen } from '@testing-library/react';
+
+import ClientInfoModal from '@/pages/clients/ClientInfoModal';
+import ClientQrModal from '@/pages/clients/ClientQrModal';
+import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import { renderWithProviders } from './test-utils';
+
+const deAwgInbound: InboundOption = {
+  id: 101,
+  tag: 'awg-de',
+  remark: 'DE · Kelsterbach',
+  port: 52716,
+  protocol: 'amneziawg',
+  nodeAddress: 'de.vpn.example.com',
+  awgServer: {
+    publicKey: 'deServerPublicKey==',
+    primaryDns: '1.1.1.1',
+    secondaryDns: '1.0.0.1',
+    mtu: 1420,
+    jc: 4,
+    jmin: 40,
+    jmax: 100,
+    s1: 30,
+    s2: 90,
+    s3: 0,
+    s4: 0,
+    h1: '123',
+    h2: '456',
+    h3: '789',
+    h4: '101112',
+  },
+};
+
+const fiAwgInbound: InboundOption = {
+  id: 102,
+  tag: 'awg-fi',
+  remark: 'FI · Helsinki',
+  port: 26641,
+  protocol: 'amneziawg',
+  nodeAddress: 'fi.vpn.example.com',
+  awgServer: {
+    publicKey: 'fiServerPublicKey==',
+    primaryDns: '8.8.8.8',
+    secondaryDns: '8.8.4.4',
+    mtu: 1380,
+    jc: 10,
+    jmin: 20,
+    jmax: 80,
+    s1: 25,
+    s2: 50,
+    s3: 0,
+    s4: 0,
+    h1: '999',
+    h2: '888',
+    h3: '777',
+    h4: '666',
+  },
+};
+
+const usWgInbound: InboundOption = {
+  id: 201,
+  tag: 'wg-us',
+  remark: 'US · New York',
+  port: 51820,
+  protocol: 'wireguard',
+  nodeAddress: 'us.vpn.example.com',
+  wgPublicKey: 'usWgServerPublicKey==',
+  wgDns: '1.1.1.1',
+  wgMtu: 1420,
+};
+
+const euWgInbound: InboundOption = {
+  id: 202,
+  tag: 'wg-eu',
+  remark: 'EU · Frankfurt',
+  port: 51821,
+  protocol: 'wireguard',
+  nodeAddress: 'eu.vpn.example.com',
+  wgPublicKey: 'euWgServerPublicKey==',
+  wgDns: '9.9.9.9',
+  wgMtu: 1400,
+};
+
+const multiAwgClient: ClientRecord = {
+  id: 'c1',
+  email: 'NSK-RT-01',
+  privateKey: 'clientPrivateKey==',
+  publicKey: 'clientPublicKey==',
+  preSharedKey: 'clientPsk==',
+  allowedIPs: '10.8.0.2/32',
+  keepAlive: 25,
+  inboundIds: [101, 102],
+  enable: true,
+} as unknown as ClientRecord;
+
+const multiWgClient: ClientRecord = {
+  id: 'c2',
+  email: 'WG-CLIENT',
+  privateKey: 'wgClientPrivateKey==',
+  publicKey: 'wgClientPublicKey==',
+  preSharedKey: 'wgClientPsk==',
+  allowedIPs: '10.0.0.2/32',
+  keepAlive: 25,
+  inboundIds: [201, 202],
+  enable: true,
+} as unknown as ClientRecord;
+
+const singleAwgClient: ClientRecord = {
+  id: 'c3',
+  email: 'SINGLE-CLIENT',
+  privateKey: 'clientPrivateKey==',
+  publicKey: 'clientPublicKey==',
+  allowedIPs: '10.8.0.2/32',
+  inboundIds: [101],
+  enable: true,
+} as unknown as ClientRecord;
+
+describe('Multi-tunnel Client Modals', () => {
+  it('renders distinct labeled ConfigBlocks in ClientInfoModal for multiple AmneziaWG inbounds', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={multiAwgClient}
+        inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
+        isOnline={false}
+        tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(screen.getAllByText('DE · Kelsterbach')).toHaveLength(2);
+    expect(screen.getByText('FI · Helsinki')).toBeTruthy();
+    expect(document.querySelectorAll('.config-block')).toHaveLength(2);
+  });
+
+  it('renders distinct labeled ConfigBlocks in ClientInfoModal for multiple WireGuard inbounds', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={multiWgClient}
+        inboundsById={{ 201: usWgInbound, 202: euWgInbound }}
+        isOnline={false}
+        tunnelAllowedIPs={{ 201: '10.0.1.2/32', 202: '10.0.2.2/32' }}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(screen.getAllByText('US · New York')).toHaveLength(2);
+    expect(screen.getByText('EU · Frankfurt')).toBeTruthy();
+    expect(document.querySelectorAll('.config-block')).toHaveLength(2);
+  });
+
+  it('renders single default-labeled ConfigBlock in ClientInfoModal for single inbound', () => {
+    renderWithProviders(
+      <ClientInfoModal
+        open
+        client={singleAwgClient}
+        inboundsById={{ 101: deAwgInbound }}
+        isOnline={false}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(document.querySelectorAll('.config-block')).toHaveLength(1);
+    expect(screen.getByText('Config')).toBeTruthy();
+  });
+
+  it('renders separate collapse panels in ClientQrModal for multiple AmneziaWG inbounds', () => {
+    renderWithProviders(
+      <ClientQrModal
+        open
+        client={multiAwgClient}
+        inboundsById={{ 101: deAwgInbound, 102: fiAwgInbound }}
+        tunnelAllowedIPs={{ 101: '10.8.1.5/32', 102: '10.8.2.10/32' }}
+        onOpenChange={() => {}}
+      />,
+    );
+
+    expect(screen.getByText('DE · Kelsterbach')).toBeTruthy();
+    expect(screen.getByText('FI · Helsinki')).toBeTruthy();
+  });
+});