diff --git a/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx index 002408fc..1a9e864b 100644 --- a/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx +++ b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx @@ -14,6 +14,7 @@ import { api } from '../../../shared/rust-api/api'; import { getInstancesQueryOptions, getLocationsQueryOptions, + getTunnelsQueryOptions, } from '../../../shared/rust-api/query'; import { useAppStore } from '../../../shared/store/useAppStore'; import { ThemeSpacing } from '../../../shared/types'; @@ -28,18 +29,18 @@ export const CompactLocationsPage = () => { const routeData = useLoaderData({ from: '/compact/' }); const { data: instances } = useQuery(getInstancesQueryOptions); + const { data: tunnels } = useQuery(getTunnelsQueryOptions); const allInstances = instances ?? routeData.instances; - const allTunnels = routeData.tunnels; + const allTunnels = tunnels ?? routeData.tunnels; const queryInstanceId = useMemo(() => { - if (!isPresent(selection)) return routeData.instances[0].id; + if (!isPresent(selection)) return allInstances[0]?.id; if (selection.kind === 'instance') return selection.id; return ( - allTunnels.find((t) => t.id === selection.id)?.instance_id ?? - routeData.instances[0].id + allTunnels.find((t) => t.id === selection.id)?.instance_id ?? allInstances[0]?.id ); - }, [selection, routeData.instances, allTunnels]); + }, [selection, allInstances, allTunnels]); const { data: locations } = useQuery(getLocationsQueryOptions(queryInstanceId)); @@ -62,9 +63,11 @@ export const CompactLocationsPage = () => { useEffect(() => { if (selection?.kind === 'tunnel') return; if (selection === null || instanceInfo === undefined) { - setViewSelection({ kind: 'instance', id: routeData.instances[0].id }); + if (allInstances.length > 0) { + setViewSelection({ kind: 'instance', id: allInstances[0].id }); + } } - }, [routeData.instances, instanceInfo, selection, setViewSelection]); + }, [allInstances, instanceInfo, selection, setViewSelection]); return ( {
- {isPresent(instanceInfo) && - displayedLocations.map((location) => { - const isOpen = - location.id === openLocation || displayedLocations.length === 1; - return ( - { - if (isOpen) { - useAppStore.setState({ expandedLocation: null }); - } else { - useAppStore.setState({ expandedLocation: location.id }); - } - }} - /> - ); - })} + {displayedLocations.map((location) => { + const isOpen = + location.id === openLocation || displayedLocations.length === 1; + return ( + { + if (isOpen) { + useAppStore.setState({ expandedLocation: null }); + } else { + useAppStore.setState({ expandedLocation: location.id }); + } + }} + /> + ); + })}
diff --git a/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx b/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx index 1394885b..175abf6e 100644 --- a/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx +++ b/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx @@ -26,7 +26,10 @@ export const InstanceSwitcher = () => { key: 'instances', label: 'Instances', options: instances.map((instance) => ({ - key: instance.id, + // Instances and tunnels have separate id spaces, so prefix the key with + // the kind to keep it unique across groups (the Select marks the checkmark + // by comparing option keys). + key: `instance-${instance.id}`, label: instance.name, value: { kind: 'instance', id: instance.id }, })), @@ -36,7 +39,7 @@ export const InstanceSwitcher = () => { key: 'tunnels', label: 'Tunnels', options: tunnels.map((tunnel) => ({ - key: tunnel.id ?? tunnel.name, + key: `tunnel-${tunnel.id ?? tunnel.name}`, label: tunnel.name, value: { kind: 'tunnel', id: tunnel.id }, })), diff --git a/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx b/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx index ee37b279..1876b814 100644 --- a/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx +++ b/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx @@ -41,9 +41,9 @@ export const OverviewPage = () => { ); const queryInstanceId = useMemo(() => { - if (!isPresent(selection)) return instances[0].id; + if (!isPresent(selection)) return instances[0]?.id; if (selection.kind === 'instance') return selection.id; - return selectedTunnel?.instance_id ?? instances[0].id; + return selectedTunnel?.instance_id ?? instances[0]?.id; }, [selection, instances, selectedTunnel]); const { data: locations } = useQuery(getLocationsQueryOptions(queryInstanceId)); diff --git a/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx b/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx index 91d89873..44b3ac98 100644 --- a/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx +++ b/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx @@ -21,12 +21,15 @@ import { api } from '../../../../../shared/rust-api/api'; import { ThemeSpacing } from '../../../../../shared/types'; import { isPresent } from '../../../../../shared/utils/isPresent'; import { - cidrRegex, - patternValidEndpoint, patternValidIpV6WithMask, patternValidIpWithMask, - patternValidWireguardKey, } from '../../../../../shared/utils/patterns'; +import { + allowedIpsSchema, + endpointSchema, + optionalWireguardKeySchema, + wireguardKeySchema, +} from '../../../../../shared/utils/zod'; const modalNameKey = ModalName.UpdateTunnel; @@ -72,27 +75,13 @@ const formSchema = z.object({ (ip) => patternValidIpWithMask.test(ip) || patternValidIpV6WithMask.test(ip), ); }, 'Field is invalid'), - prvkey: z - .string() - .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - pubkey: z - .string() - .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - server_pubkey: z - .string() - .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - preshared_key: z - .string() - .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - endpoint: z.string().refine((v) => patternValidEndpoint.test(v), 'Invalid address'), + prvkey: wireguardKeySchema, + pubkey: wireguardKeySchema, + server_pubkey: wireguardKeySchema, + preshared_key: optionalWireguardKeySchema, + endpoint: endpointSchema, dns: z.string(), - allowed_ips: z.string().refine((v) => { - if (!v) return true; - return v - .split(',') - .map((s) => s.trim()) - .every((cidr) => cidrRegex.test(cidr)); - }, 'Invalid CIDR notation'), + allowed_ips: allowedIpsSchema, persistent_keep_alive: z.number().int().min(0), pre_up: z.string(), post_up: z.string(), diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx b/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx index e65d9af0..e910fddf 100644 --- a/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx @@ -9,28 +9,19 @@ import { useAppForm } from '../../../../../shared/form'; import { formChangeLogic } from '../../../../../shared/formLogic'; import { ThemeSpacing } from '../../../../../shared/types'; import { - cidrRegex, - patternValidEndpoint, - patternValidWireguardKey, -} from '../../../../../shared/utils/patterns'; + allowedIpsSchema, + endpointSchema, + optionalWireguardKeySchema, + wireguardKeySchema, +} from '../../../../../shared/utils/zod'; import { useTunnelWizardStore } from '../../hooks/useTunnelWizardStore'; const formSchema = z.object({ - server_pubkey: z - .string() - .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - preshared_key: z - .string() - .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - endpoint: z.string().refine((v) => patternValidEndpoint.test(v), 'Invalid address'), + server_pubkey: wireguardKeySchema, + preshared_key: optionalWireguardKeySchema, + endpoint: endpointSchema, dns: z.string(), - allowed_ips: z.string().refine((v) => { - if (!v) return true; - return v - .split(',') - .map((s) => s.trim()) - .every((cidr) => cidrRegex.test(cidr)); - }, 'Invalid CIDR notation'), + allowed_ips: allowedIpsSchema, persistent_keep_alive: z.number().int().min(0), }); diff --git a/new-ui/src/shared/components/LocationCard/LocationCard.tsx b/new-ui/src/shared/components/LocationCard/LocationCard.tsx index d578eb25..5c204e12 100644 --- a/new-ui/src/shared/components/LocationCard/LocationCard.tsx +++ b/new-ui/src/shared/components/LocationCard/LocationCard.tsx @@ -24,7 +24,7 @@ interface Props { isOpen: boolean; onOpen: () => void; disableOpen?: boolean; - instance: InstanceInfo; + instance?: InstanceInfo; } const views: Record = { diff --git a/new-ui/src/shared/components/LocationCard/context/context.tsx b/new-ui/src/shared/components/LocationCard/context/context.tsx index 4583759a..538bd6bf 100644 --- a/new-ui/src/shared/components/LocationCard/context/context.tsx +++ b/new-ui/src/shared/components/LocationCard/context/context.tsx @@ -15,7 +15,7 @@ import { LocationCardViews, type LocationCardViewsValue } from './types'; interface LocationCardContextValue { location: LocationInfo; - instance: InstanceInfo; + instance?: InstanceInfo; currentView: LocationCardViewsValue; previousView: LocationCardViewsValue | null; postureError: string | null; @@ -38,7 +38,7 @@ export const useLocationCardContext = (): LocationCardContextValue => { }; interface LocationCardProviderProps { - instance: InstanceInfo; + instance?: InstanceInfo; location: LocationInfo; children: ReactNode; } diff --git a/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx b/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx index 46e5aade..f9784c11 100644 --- a/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx +++ b/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx @@ -29,7 +29,7 @@ export const DefaultView = () => { return (
- {instance.client_traffic_policy === ClientTrafficPolicy.None && ( + {(instance?.client_traffic_policy === ClientTrafficPolicy.None || !instance) && ( return undefined; }, [onDismiss, props.type]); - // allows field to show error even if isPristine is true, this is needed in cases as input required or checkbox checked but user just clicked submit - const wasSubmittedWithFailure = useStore( - form.store, - (store) => !store.isSubmitSuccessful && store.submissionAttempts > 0, - ); + // allows field to show error even if isPristine is true, this is needed in cases as input required or checkbox checked but user just clicked submit. + // Keyed on submissionAttempts rather than isSubmitSuccessful: a submit whose + // handler injects server-side field errors via setErrorMap (without throwing) + // is still recorded as "successful" by the form, which would otherwise hide + // the error on a pristine field. + const wasSubmitted = useStore(form.store, (store) => store.submissionAttempts > 0); const isPristine = useStore(field.store, (state) => state.meta.isPristine); @@ -39,7 +40,7 @@ export const FormInput = ({ mapError, onDismiss, ...props }: FormInputProps) => const errorMessage = useMemo(() => { // ignore errors unless some touches the field or submit's the form - if (isPristine && !wasSubmittedWithFailure) return undefined; + if (isPristine && !wasSubmitted) return undefined; const fieldError = errorState[0]; @@ -57,7 +58,7 @@ export const FormInput = ({ mapError, onDismiss, ...props }: FormInputProps) => } } return undefined; - }, [mapError, errorState[0], isPristine, wasSubmittedWithFailure]); + }, [mapError, errorState[0], isPristine, wasSubmitted]); return ( +// Accepts an absent instance id and self-disables via skipToken, so callers +// (e.g. a tunnel selection with no backing instance) can pass through whatever +// they resolved without a sentinel id or a separate `enabled` flag. +export const getLocationsQueryOptions = (instanceId: number | undefined) => queryOptions({ queryKey: ['locations', instanceId] as const, - queryFn: () => api.getLocations(instanceId), + queryFn: isPresent(instanceId) ? () => api.getLocations(instanceId) : skipToken, }); export const hasAnyVisibleLocationsQueryOptions = queryOptions({ diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts index c84aabf1..7ffb204b 100644 --- a/new-ui/src/shared/rust-api/types.ts +++ b/new-ui/src/shared/rust-api/types.ts @@ -345,10 +345,12 @@ export type TunnelRequest = { prvkey: string; address: string; server_pubkey: string; + preshared_key?: string; allowed_ips?: string; endpoint: string; dns?: string; persistent_keep_alive: number; + route_all_traffic: boolean; pre_up?: string; post_up?: string; pre_down?: string; diff --git a/new-ui/src/shared/utils/patterns.ts b/new-ui/src/shared/utils/patterns.ts index 845194b3..60baf039 100644 --- a/new-ui/src/shared/utils/patterns.ts +++ b/new-ui/src/shared/utils/patterns.ts @@ -75,9 +75,12 @@ export const patternValidIpWithMask = export const cidrRegex = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}|[0-9a-fA-F:.]+\/\d{1,3})$/; -// Regular expression to match IPv4, IPv6, domain name, or localhost with port +// Regular expression to match a WireGuard endpoint. A bare IPv4 literal must +// include a port (a port-less IP is almost always a mistake), while domain names +// and localhost may omit it. IPv6 endpoints are validated separately via +// patternValidIpV6WithPort (port required there too). export const patternValidEndpoint = - /^(localhost|\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b|\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b)(?::(\d+))?$/; + /^(?:(?:localhost|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(?::\d+)?|(?:[0-9]{1,3}\.){3}[0-9]{1,3}:\d+)$/; // Copied from zod source code export const patternValidIpV6 = diff --git a/new-ui/src/shared/utils/zod.ts b/new-ui/src/shared/utils/zod.ts index fcad5086..2e69a823 100644 --- a/new-ui/src/shared/utils/zod.ts +++ b/new-ui/src/shared/utils/zod.ts @@ -1,4 +1,10 @@ -import type z from 'zod'; +import { z } from 'zod'; +import { + cidrRegex, + patternValidEndpoint, + patternValidIpV6WithPort, + patternValidWireguardKey, +} from './patterns'; export const createZodIssue = ( message: string, @@ -8,3 +14,33 @@ export const createZodIssue = ( message, path, }); + +// Shared field schemas for the WireGuard tunnel forms (add/edit). Kept here so +// the tunnel wizard and the edit-tunnel modal validate identically. + +// WireGuard endpoint (host:port): IPv4:port, domain[:port], or [IPv6]:port. +export const endpointSchema = z + .string() + .refine( + (v) => patternValidEndpoint.test(v) || patternValidIpV6WithPort.test(v), + 'Invalid address', + ); + +// A required WireGuard key. +export const wireguardKeySchema = z + .string() + .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'); + +// An optional WireGuard key - an empty value is allowed (e.g. preshared key). +export const optionalWireguardKeySchema = z + .string() + .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'); + +// Comma-separated list of CIDR ranges; an empty value is allowed. +export const allowedIpsSchema = z.string().refine((v) => { + if (!v) return true; + return v + .split(',') + .map((s) => s.trim()) + .every((cidr) => cidrRegex.test(cidr)); +}, 'Invalid CIDR notation'); diff --git a/src-tauri/core/src/database/models/mod.rs b/src-tauri/core/src/database/models/mod.rs index 1340da6e..449de17a 100644 --- a/src-tauri/core/src/database/models/mod.rs +++ b/src-tauri/core/src/database/models/mod.rs @@ -11,7 +11,7 @@ pub mod wireguard_keys; // Typestate structs to make working with optional IDs easier pub type Id = i64; -#[derive(Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[derive(Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)] pub struct NoId; const PURGE_DURATION: chrono::Duration = chrono::Duration::days(30); diff --git a/src-tauri/core/src/database/models/tunnel.rs b/src-tauri/core/src/database/models/tunnel.rs index 262ad647..452c81ad 100644 --- a/src-tauri/core/src/database/models/tunnel.rs +++ b/src-tauri/core/src/database/models/tunnel.rs @@ -15,6 +15,7 @@ use crate::{ #[serde_as] #[derive(Clone, Deserialize, Serialize)] pub struct Tunnel { + #[serde(default)] pub id: I, pub name: String, // encryption keys