diff --git a/apps/accounts/migrations/0007_user_xc_allowed_ips.py b/apps/accounts/migrations/0007_user_xc_allowed_ips.py deleted file mode 100644 index 962dc639..00000000 --- a/apps/accounts/migrations/0007_user_xc_allowed_ips.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('accounts', '0006_user_stream_limit'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='xc_allowed_ips', - field=models.TextField(blank=True, default=''), - ), - ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 155bfc15..2a32f95a 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -31,7 +31,6 @@ class User(AbstractUser): custom_properties = models.JSONField(default=dict, blank=True, null=True) api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True) stream_limit = models.IntegerField(default=0) - xc_allowed_ips = models.TextField(blank=True, default='') def __str__(self): return self.username diff --git a/apps/accounts/permissions.py b/apps/accounts/permissions.py index 62673038..f564f68f 100644 --- a/apps/accounts/permissions.py +++ b/apps/accounts/permissions.py @@ -6,7 +6,8 @@ from dispatcharr.utils import network_access_allowed class Authenticated(IsAuthenticated): def has_permission(self, request, view): is_authenticated = super().has_permission(request, view) - network_allowed = network_access_allowed(request, "UI") + user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None + network_allowed = network_access_allowed(request, "UI", user) return is_authenticated and network_allowed diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 067f6b45..8a9f609e 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -1,5 +1,4 @@ import json -import ipaddress from rest_framework import serializers from django.contrib.auth.models import Group, Permission @@ -67,7 +66,6 @@ class UserSerializer(serializers.ModelSerializer): "custom_properties", "avatar_config", "stream_limit", - "xc_allowed_ips", "is_staff", "is_superuser", "last_login", @@ -76,19 +74,6 @@ class UserSerializer(serializers.ModelSerializer): "last_name", ] - def validate_xc_allowed_ips(self, value): - if not value or not value.strip(): - return value - for cidr in value.split(','): - cidr = cidr.strip() - if not cidr: - continue - try: - ipaddress.ip_network(cidr, strict=False) - except ValueError: - raise serializers.ValidationError(f"'{cidr}' is not a valid CIDR range") - return value - def validate_custom_properties(self, value): """Validate custom_properties structure and size.""" if value is None: diff --git a/apps/output/views.py b/apps/output/views.py index f10eff42..958dd3be 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from apps.epg.models import ProgramData from apps.accounts.models import User -from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed +from dispatcharr.utils import network_access_allowed from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 from datetime import datetime, timedelta @@ -1926,7 +1926,7 @@ def xc_get_user(request): if custom_properties["xc_password"] != password: return None - if not user_xc_ip_allowed(request, user): + if not network_access_allowed(request, 'XC_API', user): return None return user diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py index 900d218e..cb926890 100644 --- a/apps/proxy/ts_proxy/views.py +++ b/apps/proxy/ts_proxy/views.py @@ -40,7 +40,7 @@ from .url_utils import ( from .utils import get_logger from uuid import UUID import gevent -from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed +from dispatcharr.utils import network_access_allowed from apps.proxy.utils import check_user_stream_limits logger = get_logger() @@ -572,7 +572,7 @@ def stream_xc(request, username, password, channel_id): if custom_properties["xc_password"] != password: return Response({"error": "Invalid credentials"}, status=401) - if not user_xc_ip_allowed(request, user): + if not network_access_allowed(request, 'STREAMS', user): return Response({"error": "Invalid credentials"}, status=401) if user.user_level < 10: diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index fa77fa0f..c5d39fb9 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -19,7 +19,7 @@ from rest_framework.permissions import AllowAny from apps.accounts.models import User from apps.accounts.permissions import IsAdmin from apps.proxy.utils import check_user_stream_limits -from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed +from dispatcharr.utils import network_access_allowed logger = logging.getLogger(__name__) @@ -1031,7 +1031,7 @@ def stream_xc_movie(request, username, password, stream_id, extension): if custom_properties["xc_password"] != password: return Response({"error": "Invalid credentials"}, status=401) - if not user_xc_ip_allowed(request, user): + if not network_access_allowed(request, 'STREAMS', user): return Response({"error": "Invalid credentials"}, status=401) # All authenticated users get access to VOD from all active M3U accounts @@ -1068,7 +1068,7 @@ def stream_xc_episode(request, username, password, stream_id, extension): if custom_properties["xc_password"] != password: return Response({"error": "Invalid credentials"}, status=401) - if not user_xc_ip_allowed(request, user): + if not network_access_allowed(request, 'STREAMS', user): return Response({"error": "Invalid credentials"}, status=401) # All authenticated users get access to series/episodes from all active M3U accounts diff --git a/core/management/commands/reset_user_network.py b/core/management/commands/reset_user_network.py new file mode 100644 index 00000000..a6a43243 --- /dev/null +++ b/core/management/commands/reset_user_network.py @@ -0,0 +1,22 @@ +from django.core.management.base import BaseCommand +from apps.accounts.models import User + + +class Command(BaseCommand): + help = "Reset per-user network access restrictions" + + def add_arguments(self, parser): + parser.add_argument('--user', type=str, help='Username to reset (omit to reset all users)') + + def handle(self, *args, **options): + username = options.get('user') + qs = User.objects.filter(username=username) if username else User.objects.all() + count = 0 + for user in qs: + props = user.custom_properties or {} + if 'allowed_networks' in props: + del props['allowed_networks'] + user.custom_properties = props + user.save(update_fields=['custom_properties']) + count += 1 + self.stdout.write(f"Reset allowed_networks for {count} user(s).") diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index f96cd539..02fe7853 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -38,27 +38,7 @@ def get_client_ip(request): return ip -def user_xc_ip_allowed(request, user): - """Check request IP against per-user XC allowed ranges. Empty = allow all (0.0.0.0/0).""" - allowed_ips = getattr(user, 'xc_allowed_ips', '') or '' - if not allowed_ips.strip(): - return True - - cidrs = [c.strip() for c in allowed_ips.split(',') if c.strip()] - if not cidrs: - return True - - client_ip = ipaddress.ip_address(get_client_ip(request)) - for cidr in cidrs: - try: - if client_ip in ipaddress.ip_network(cidr, strict=False): - return True - except ValueError: - continue - return False - - -def network_access_allowed(request, settings_key): +def network_access_allowed(request, settings_key, user=None): try: network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value except CoreSettings.DoesNotExist: @@ -86,4 +66,19 @@ def network_access_allowed(request, settings_key): network_allowed = True break - return network_allowed + if not network_allowed: + return False + + if user is not None: + user_networks = (getattr(user, 'custom_properties', None) or {}).get('allowed_networks', {}) + raw = user_networks.get(settings_key, '') + if raw: + for cidr in (c.strip() for c in raw.split(',') if c.strip()): + try: + if client_ip in ipaddress.ip_network(cidr, strict=False): + return True + except ValueError: + continue + return False + + return True diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index cb3a8c23..a849e066 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -21,10 +21,18 @@ import { RotateCcwKey, X } from 'lucide-react'; import { Copy, Key } from 'lucide-react'; import { useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; -import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; +import { USER_LEVELS, USER_LEVEL_LABELS, NETWORK_ACCESS_OPTIONS } from '../../constants'; import useAuthStore from '../../store/auth'; import { copyToClipboard } from '../../utils'; -import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX, IPV4_REGEX, IPV6_REGEX } from '../../utils/networkUtils'; +import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils'; + +const isValidNetworkEntry = (entry) => + entry.match(IPV4_CIDR_REGEX) || + entry.match(IPV6_CIDR_REGEX) || + (entry + '/32').match(IPV4_CIDR_REGEX) || + (entry + '/128').match(IPV6_CIDR_REGEX); + +const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS); const User = ({ user = null, isOpen, onClose }) => { const profiles = useChannelsStore((s) => s.profiles); @@ -50,11 +58,11 @@ const User = ({ user = null, isOpen, onClose }) => { stream_limit: 0, password: '', xc_password: '', - xc_allowed_ips: [], channel_profiles: [], hide_adult_content: false, epg_days: 0, epg_prev_days: 0, + allowed_ips: [], }, validate: (values) => ({ @@ -66,20 +74,14 @@ const User = ({ user = null, isOpen, onClose }) => { : null, password: !user && !values.password && values.user_level != USER_LEVELS.STREAMER - ? 'Password is requried' + ? 'Password is required' : null, xc_password: values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i) ? 'XC password must be alphanumeric' : null, - xc_allowed_ips: values.xc_allowed_ips.some( - (entry) => - !entry.match(IPV4_CIDR_REGEX) && - !entry.match(IPV6_CIDR_REGEX) && - !entry.match(IPV4_REGEX) && - !entry.match(IPV6_REGEX) - ) - ? 'Each entry must be a valid IP address or CIDR range (e.g. 192.168.1.1 or 192.168.1.0/24)' + allowed_ips: (values.allowed_ips || []).some((t) => !isValidNetworkEntry(t)) + ? 'Invalid IP address or CIDR range' : null, }), }); @@ -102,18 +104,12 @@ const User = ({ user = null, isOpen, onClose }) => { const customProps = user?.custom_properties || {}; - // Always save xc_password, even if it's empty (to allow clearing) customProps.xc_password = values.xc_password || ''; delete values.xc_password; - // Serialize xc_allowed_ips array to comma-separated string for the backend - values.xc_allowed_ips = (values.xc_allowed_ips || []).join(','); - - // Save hide_adult_content in custom_properties customProps.hide_adult_content = values.hide_adult_content || false; delete values.hide_adult_content; - // Save EPG defaults in custom_properties customProps.epg_days = values.epg_days || 0; delete values.epg_days; customProps.epg_prev_days = values.epg_prev_days || 0; @@ -121,13 +117,18 @@ const User = ({ user = null, isOpen, onClose }) => { values.custom_properties = customProps; - // If 'All' is included, clear this and we assume access to all channels + // Serialize per-user network restrictions into custom_properties (same list for all types) + const joined = (values.allowed_ips || []).join(','); + delete values.allowed_ips; + const allowed_networks = {}; + if (joined) NETWORK_KEYS.forEach((key) => { allowed_networks[key] = joined; }); + customProps.allowed_networks = allowed_networks; + if (values.channel_profiles.includes('0')) { values.channel_profiles = []; } if (!user && values.user_level == USER_LEVELS.STREAMER) { - // Generate random password - they can't log in, but user can't be created without a password values.password = Math.random().toString(36).slice(2); } @@ -157,6 +158,7 @@ const User = ({ user = null, isOpen, onClose }) => { useEffect(() => { if (user?.id) { const customProps = user.custom_properties || {}; + const networks = customProps.allowed_networks || {}; form.setValues({ username: user.username, @@ -170,12 +172,14 @@ const User = ({ user = null, isOpen, onClose }) => { ? user.channel_profiles.map((id) => `${id}`) : ['0'], xc_password: customProps.xc_password || '', - xc_allowed_ips: user.xc_allowed_ips - ? user.xc_allowed_ips.split(',').filter(Boolean) - : [], hide_adult_content: customProps.hide_adult_content || false, epg_days: customProps.epg_days || 0, epg_prev_days: customProps.epg_prev_days || 0, + allowed_ips: [...new Set( + NETWORK_KEYS.flatMap((key) => + networks[key] ? networks[key].split(',').filter(Boolean) : [] + ) + )], }); if (customProps.xc_password) { @@ -241,12 +245,10 @@ const User = ({ user = null, isOpen, onClose }) => { } const resp = await API.revokeApiKey(payload); - // backend returns { success: true } - clear local state if (resp && resp.success) { setGeneratedKey(null); setUserAPIKey(null); - // If we're revoking the current authenticated user's key, update auth store if (user?.id && authUser?.id === user.id) { setUser({ ...authUser, api_key: null }); } @@ -404,12 +406,12 @@ const User = ({ user = null, isOpen, onClose }) => { /> {isAdmin && ( )} {canGenerateKey && ( diff --git a/frontend/src/components/forms/settings/NetworkAccessForm.jsx b/frontend/src/components/forms/settings/NetworkAccessForm.jsx index ca165ce6..2c5ba03a 100644 --- a/frontend/src/components/forms/settings/NetworkAccessForm.jsx +++ b/frontend/src/components/forms/settings/NetworkAccessForm.jsx @@ -6,7 +6,7 @@ import { checkSetting, updateSetting, } from '../../../utils/pages/SettingsUtils.js'; -import { Alert, Button, Flex, Stack, Text, TextInput } from '@mantine/core'; +import { Alert, Button, Flex, Stack, TagsInput, Text } from '@mantine/core'; import ConfirmationDialog from '../../ConfirmationDialog.jsx'; import { getNetworkAccessFormInitialValues, @@ -14,6 +14,9 @@ import { getNetworkAccessDefaults, } from '../../../utils/forms/settings/NetworkAccessFormUtils.js'; +const toTags = (str) => (str ? str.split(',').map((s) => s.trim()).filter(Boolean) : []); +const toStr = (tags) => (tags || []).join(','); + const NetworkAccessForm = React.memo(({ active }) => { const settings = useSettingsStore((s) => s.settings); @@ -43,14 +46,12 @@ const NetworkAccessForm = React.memo(({ active }) => { useEffect(() => { const networkAccessSettings = settings['network_access']?.value || {}; - // M3U/EPG endpoints default to local networks only - const m3uEpgDefaults = - '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10'; + const defaults = getNetworkAccessDefaults(); networkAccessForm.setValues( Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { - const defaultValue = - key === 'M3U_EPG' ? m3uEpgDefaults : '0.0.0.0/0,::/0'; - acc[key] = networkAccessSettings[key] || defaultValue; + acc[key] = networkAccessSettings[key] + ? toTags(networkAccessSettings[key]) + : defaults[key]; return acc; }, {}) ); @@ -65,24 +66,28 @@ const NetworkAccessForm = React.memo(({ active }) => { setNetworkAccessError(null); setRestoredDefaults([]); - // Check for blank fields and substitute defaults before saving const currentValues = networkAccessForm.getValues(); const defaults = getNetworkAccessDefaults(); const restoredLabels = []; - const submitValues = { ...currentValues }; + const tagValues = { ...currentValues }; Object.keys(currentValues).forEach((key) => { - if (!currentValues[key] || currentValues[key].trim() === '') { - submitValues[key] = defaults[key]; + if (!currentValues[key] || currentValues[key].length === 0) { + tagValues[key] = defaults[key]; restoredLabels.push(NETWORK_ACCESS_OPTIONS[key]?.label || key); } }); if (restoredLabels.length > 0) { - networkAccessForm.setValues(submitValues); + networkAccessForm.setValues(tagValues); setRestoredDefaults(restoredLabels); } + // Backend expects comma-separated strings + const submitValues = Object.fromEntries( + Object.entries(tagValues).map(([k, v]) => [k, toStr(v)]) + ); + pendingSaveValuesRef.current = submitValues; const check = await checkSetting({ @@ -111,8 +116,9 @@ const NetworkAccessForm = React.memo(({ active }) => { const saveNetworkAccess = async () => { setSaved(false); setSaving(true); - const values = - pendingSaveValuesRef.current || networkAccessForm.getValues(); + const values = pendingSaveValuesRef.current || Object.fromEntries( + Object.entries(networkAccessForm.getValues()).map(([k, v]) => [k, toStr(v)]) + ); try { await updateSetting({ ...settings['network_access'], @@ -136,11 +142,7 @@ const NetworkAccessForm = React.memo(({ active }) => {
{saved && ( - + )} {restoredDefaults.length > 0 && ( @@ -149,35 +151,25 @@ const NetworkAccessForm = React.memo(({ active }) => { )} {networkAccessError && ( - + )} {Object.entries(NETWORK_ACCESS_OPTIONS).map(([key, config]) => ( - ))} - - @@ -188,7 +180,7 @@ const NetworkAccessForm = React.memo(({ active }) => { opened={networkAccessConfirmOpen} onClose={() => setNetworkAccessConfirmOpen(false)} onConfirm={saveNetworkAccess} - title={`Confirm Network Access Blocks`} + title="Confirm Network Access Blocks" loading={saving} message={ <> @@ -197,10 +189,9 @@ const NetworkAccessForm = React.memo(({ active }) => { included in the allowed networks for the web UI. Are you sure you want to proceed? -
    {netNetworkAccessConfirmCIDRs.map((cidr) => ( -
  • {cidr}
  • +
  • {cidr}
  • ))}
diff --git a/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx index d3008354..24cea70c 100644 --- a/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx +++ b/frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx @@ -4,11 +4,12 @@ import NetworkAccessForm from '../NetworkAccessForm'; // ── Constants mock ───────────────────────────────────────────────────────────── vi.mock('../../../../constants.js', () => ({ - NETWORK_ACCESS_OPTIONS: [ - { value: 'all', label: 'All' }, - { value: 'local', label: 'Local Only' }, - { value: 'custom', label: 'Custom' }, - ], + NETWORK_ACCESS_OPTIONS: { + M3U_EPG: { label: 'M3U / EPG Endpoints', description: 'Limit M3U/EPG access' }, + STREAMS: { label: 'Stream Endpoints', description: 'Limit stream access' }, + XC_API: { label: 'XC API', description: 'Limit XC API access' }, + UI: { label: 'UI', description: 'Limit UI access' }, + }, })); // ── Store mock ───────────────────────────────────────────────────────────────── @@ -82,6 +83,12 @@ vi.mock('@mantine/core', () => ({ {error && {error}} ), + TagsInput: ({ label, placeholder, error, ...rest }) => ( +
+ + {error && {error}} +
+ ), })); // ────────────────────────────────────────────────────────────────────────────── @@ -103,12 +110,10 @@ import { // Helpers // ────────────────────────────────────────────────────────────────────────────── const mockInitialValues = { - m3u_access: 'local', - epg_access: 'local', - recordings_access: 'all', - m3u_custom_cidrs: '', - epg_custom_cidrs: '', - recordings_custom_cidrs: '', + M3U_EPG: ['127.0.0.0/8', '192.168.0.0/16'], + STREAMS: ['0.0.0.0/0', '::/0'], + XC_API: ['0.0.0.0/0', '::/0'], + UI: ['0.0.0.0/0', '::/0'], }; const makeFormMock = (overrides = {}) => ({ @@ -134,9 +139,10 @@ const makeSettings = (overrides = {}) => ({ network_access: { key: 'network_access', value: { - m3u_access: 'local', - epg_access: 'local', - recordings_access: 'all', + M3U_EPG: '127.0.0.0/8,192.168.0.0/16', + STREAMS: '0.0.0.0/0,::/0', + XC_API: '0.0.0.0/0,::/0', + UI: '0.0.0.0/0,::/0', }, }, ...overrides, diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js index ac863b0b..bf9b6e3f 100644 --- a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js +++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js @@ -2,46 +2,34 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js'; import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js'; // Default CIDR ranges for M3U/EPG endpoints (local networks only) -const M3U_EPG_DEFAULTS = - '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10'; +const M3U_EPG_DEFAULTS = ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '::1/128', 'fc00::/7', 'fe80::/10']; +const OPEN_DEFAULTS = ['0.0.0.0/0', '::/0']; -export const getNetworkAccessFormInitialValues = () => { - return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { +const isValidEntry = (entry) => + entry.match(IPV4_CIDR_REGEX) || + entry.match(IPV6_CIDR_REGEX) || + (entry + '/32').match(IPV4_CIDR_REGEX) || + (entry + '/128').match(IPV6_CIDR_REGEX); + +export const getNetworkAccessFormInitialValues = () => + Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { // M3U/EPG endpoints default to local networks only - acc[key] = key === 'M3U_EPG' ? M3U_EPG_DEFAULTS : '0.0.0.0/0,::/0'; + acc[key] = key === 'M3U_EPG' ? M3U_EPG_DEFAULTS : OPEN_DEFAULTS; return acc; }, {}); -}; -export const getNetworkAccessFormValidation = () => { - return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { - acc[key] = (value) => { - if (!value || value.trim() === '') { - return null; // Empty values will be replaced with defaults on submit - } - - if ( - value - .split(',') - .some( - (cidr) => - !(cidr.match(IPV4_CIDR_REGEX) || cidr.match(IPV6_CIDR_REGEX)) - ) - ) { - return 'Invalid CIDR range'; - } - - return null; +export const getNetworkAccessFormValidation = () => + Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { + acc[key] = (tags) => { + if (!tags || tags.length === 0) return null; + return tags.some((t) => !isValidEntry(t)) ? 'Invalid IP address or CIDR range' : null; }; return acc; }, {}); -}; -export const getNetworkAccessDefaults = () => { - return { - M3U_EPG: M3U_EPG_DEFAULTS, - STREAMS: '0.0.0.0/0,::/0', - XC_API: '0.0.0.0/0,::/0', - UI: '0.0.0.0/0,::/0', - }; -}; +export const getNetworkAccessDefaults = () => ({ + M3U_EPG: M3U_EPG_DEFAULTS, + STREAMS: OPEN_DEFAULTS, + XC_API: OPEN_DEFAULTS, + UI: OPEN_DEFAULTS, +}); diff --git a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js index 2cc885ec..2aa3ecfc 100644 --- a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js @@ -27,9 +27,9 @@ describe('NetworkAccessFormUtils', () => { const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues(); expect(result).toEqual({ - 'network-access-admin': '0.0.0.0/0,::/0', - 'network-access-api': '0.0.0.0/0,::/0', - 'network-access-streaming': '0.0.0.0/0,::/0', + 'network-access-admin': ['0.0.0.0/0', '::/0'], + 'network-access-api': ['0.0.0.0/0', '::/0'], + 'network-access-streaming': ['0.0.0.0/0', '::/0'], }); }); @@ -80,9 +80,9 @@ describe('NetworkAccessFormUtils', () => { NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; - expect(validator('192.168.1.0/24')).toBeNull(); - expect(validator('10.0.0.0/8')).toBeNull(); - expect(validator('0.0.0.0/0')).toBeNull(); + expect(validator(['192.168.1.0/24'])).toBeNull(); + expect(validator(['10.0.0.0/8'])).toBeNull(); + expect(validator(['0.0.0.0/0'])).toBeNull(); }); it('should validate valid IPv6 CIDR ranges', () => { @@ -90,18 +90,18 @@ describe('NetworkAccessFormUtils', () => { NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; - expect(validator('2001:db8::/32')).toBeNull(); - expect(validator('::/0')).toBeNull(); + expect(validator(['2001:db8::/32'])).toBeNull(); + expect(validator(['::/0'])).toBeNull(); }); - it('should validate multiple CIDR ranges separated by commas', () => { + it('should validate multiple CIDR entries', () => { const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; - expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull(); - expect(validator('0.0.0.0/0,::/0')).toBeNull(); - expect(validator('192.168.1.0/24,2001:db8::/32')).toBeNull(); + expect(validator(['192.168.1.0/24', '10.0.0.0/8'])).toBeNull(); + expect(validator(['0.0.0.0/0', '::/0'])).toBeNull(); + expect(validator(['192.168.1.0/24', '2001:db8::/32'])).toBeNull(); }); it('should return error for invalid IPv4 CIDR ranges', () => { @@ -109,30 +109,31 @@ describe('NetworkAccessFormUtils', () => { NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; - expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range'); - expect(validator('invalid')).toBe('Invalid CIDR range'); - expect(validator('192.168.1.0/256')).toBe('Invalid CIDR range'); + expect(validator(['192.168.1.256.1/24'])).toBe('Invalid IP address or CIDR range'); + expect(validator(['invalid'])).toBe('Invalid IP address or CIDR range'); + expect(validator(['192.168.1.0/256'])).toBe('Invalid IP address or CIDR range'); }); - it('should return error when any CIDR in comma-separated list is invalid', () => { + it('should return error when any entry in the list is invalid', () => { const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; - expect(validator('192.168.1.0/24,invalid')).toBe('Invalid CIDR range'); - expect(validator('invalid,192.168.1.0/24')).toBe('Invalid CIDR range'); - expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe( - 'Invalid CIDR range' + expect(validator(['192.168.1.0/24', 'invalid'])).toBe('Invalid IP address or CIDR range'); + expect(validator(['invalid', '192.168.1.0/24'])).toBe('Invalid IP address or CIDR range'); + expect(validator(['192.168.1.0/24', '10.0.0.0/8', 'invalid'])).toBe( + 'Invalid IP address or CIDR range' ); }); - it('should handle empty strings', () => { + it('should handle empty arrays', () => { const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; // Empty values are allowed — defaults are substituted on submit - expect(validator('')).toBe(null); + expect(validator([])).toBe(null); + expect(validator(null)).toBe(null); }); it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => { diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js index fbca5f59..9c8e5fc0 100644 --- a/frontend/src/utils/networkUtils.js +++ b/frontend/src/utils/networkUtils.js @@ -1,11 +1,3 @@ -// Plain IPv4 address regex -export const IPV4_REGEX = - /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/; - -// Plain IPv6 address regex -export const IPV6_REGEX = - /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/; - // IPv4 CIDR regex - validates IP address and prefix length (0-32) export const IPV4_CIDR_REGEX = /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/;