Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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));

Expand All @@ -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 (
<CompactPage
Expand All @@ -77,27 +80,26 @@ export const CompactLocationsPage = () => {
<div className="main-content">
<InstanceSwitcher />
<div className="locations">
{isPresent(instanceInfo) &&
displayedLocations.map((location) => {
const isOpen =
location.id === openLocation || displayedLocations.length === 1;
return (
<LocationCard
instance={instanceInfo}
disableOpen={displayedLocations.length <= 1}
location={location}
key={`${location.instance_id}-${location.id}`}
isOpen={isOpen}
onOpen={() => {
if (isOpen) {
useAppStore.setState({ expandedLocation: null });
} else {
useAppStore.setState({ expandedLocation: location.id });
}
}}
/>
);
})}
{displayedLocations.map((location) => {
const isOpen =
location.id === openLocation || displayedLocations.length === 1;
return (
<LocationCard
instance={instanceInfo}
disableOpen={displayedLocations.length <= 1}
location={location}
key={`${location.connection_type}-${location.id}`}
isOpen={isOpen}
onOpen={() => {
if (isOpen) {
useAppStore.setState({ expandedLocation: null });
} else {
useAppStore.setState({ expandedLocation: location.id });
}
}}
/>
);
})}
</div>
</div>
</ScrollContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
})),
Expand All @@ -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 },
})),
Expand Down
4 changes: 2 additions & 2 deletions new-ui/src/pages/full/OverviewPage/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});

Expand Down
2 changes: 1 addition & 1 deletion new-ui/src/shared/components/LocationCard/LocationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface Props {
isOpen: boolean;
onOpen: () => void;
disableOpen?: boolean;
instance: InstanceInfo;
instance?: InstanceInfo;
}

const views: Record<LocationCardViewsValue, ReactNode> = {
Expand Down
4 changes: 2 additions & 2 deletions new-ui/src/shared/components/LocationCard/context/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,7 +38,7 @@ export const useLocationCardContext = (): LocationCardContextValue => {
};

interface LocationCardProviderProps {
instance: InstanceInfo;
instance?: InstanceInfo;
location: LocationInfo;
children: ReactNode;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const DefaultView = () => {

return (
<div className="location-view-default">
{instance.client_traffic_policy === ClientTrafficPolicy.None && (
{(instance?.client_traffic_policy === ClientTrafficPolicy.None || !instance) && (
<Fragment>
<Divider spacing={ThemeSpacing.Md} />
<Toggle
Expand Down
15 changes: 8 additions & 7 deletions new-ui/src/shared/components/form/FormInput/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ export const FormInput = ({ mapError, onDismiss, ...props }: FormInputProps) =>
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);

Expand All @@ -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];

Expand All @@ -57,7 +58,7 @@ export const FormInput = ({ mapError, onDismiss, ...props }: FormInputProps) =>
}
}
return undefined;
}, [mapError, errorState[0], isPristine, wasSubmittedWithFailure]);
}, [mapError, errorState[0], isPristine, wasSubmitted]);

return (
<Input
Expand Down
10 changes: 7 additions & 3 deletions new-ui/src/shared/rust-api/query.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { queryOptions } from '@tanstack/react-query';
import { queryOptions, skipToken } from '@tanstack/react-query';

import { isPresent } from '../utils/isPresent';
import { api } from './api';
import type { ConnectionArgs, LocationDetailsArgs, StatsArgs } from './types';

Expand All @@ -15,10 +16,13 @@ export const getInstancesQueryOptions = queryOptions({
refetchInterval: 30_000,
});

export const getLocationsQueryOptions = (instanceId: number) =>
// 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({
Expand Down
2 changes: 2 additions & 0 deletions new-ui/src/shared/rust-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions new-ui/src/shared/utils/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
38 changes: 37 additions & 1 deletion new-ui/src/shared/utils/zod.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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');
2 changes: 1 addition & 1 deletion src-tauri/core/src/database/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading