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
25 changes: 25 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
version: '3'

env:
SQLX_OFFLINE: 'true'

includes:
rust:
taskfile: ./src-tauri/Taskfile.yml
Expand Down Expand Up @@ -45,6 +48,28 @@ tasks:
dir: new-ui
cmd: pnpm lint

test-rust:
desc: Run the Rust workspace tests in src-tauri with nextest. Extra args go after --.
dir: src-tauri
cmd: cargo nextest run --workspace --no-fail-fast {{.CLI_ARGS}}

test-ui:
desc: Run the UI unit tests in new-ui with vitest. Extra args go after --.
dir: new-ui
cmd: pnpm test {{.CLI_ARGS}}

query-data:
desc: Recreate a throwaway sqlite db from migrations and regenerate the sqlx offline query data in src-tauri/.sqlx.
dir: src-tauri
env:
DATABASE_URL: sqlite:{{.TASKFILE_DIR}}/src-tauri/query-data.db
SQLX_OFFLINE: 'false'
cmds:
- sqlx database drop -y
- sqlx database create
- sqlx migrate run --source migrations
- cargo sqlx prepare --workspace -- --workspace --all-targets --all-features

build:
desc: Build the frontend and the Tauri bundle (unsigned MSI on Windows).
cmds:
Expand Down
2 changes: 2 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
set windows-shell := ["powershell.exe", "-c"]

export SQLX_OFFLINE := "true"

dev:
npx concurrently \
-n "NEW,TAURI" \
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import './style.scss';
import clsx from 'clsx';
import { TruncatedText } from '../../../../../shared/components/TruncatedText/TruncatedText';
import { useAppData } from '../../../../../shared/providers/AppDataContext';
import { TooltipContent } from '../../../../../shared/providers/tooltip/TooltipContent';
import { TooltipProvider } from '../../../../../shared/providers/tooltip/TooltipContext';
import { TooltipTrigger } from '../../../../../shared/providers/tooltip/TooltipTrigger';
import type {
InstanceInfo,
LocationInfo,
OverviewViewSelection,
} from '../../../../../shared/rust-api/types';

const labelTooltipOpenDelayMs = 1000;

type Props = {
instances: InstanceInfo[];
tunnels: LocationInfo[];
Expand All @@ -20,9 +24,16 @@ type SelectionItemProps = {
};

const SelectionItem = ({ label, selected, onClick }: SelectionItemProps) => (
<button className={clsx('item', { selected })} onClick={onClick}>
<TruncatedText className="label" text={label} tooltipPlacement="right" delayTooltip />
</button>
<TooltipProvider placement="top" openDelayMs={labelTooltipOpenDelayMs}>
<TooltipTrigger>
<button className={clsx('item', { selected })} onClick={onClick}>
<span className="label">{label}</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</TooltipProvider>
);

export const OverviewSelection = ({ instances, tunnels }: Props) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
> .label {
font: var(--t-body-sm-400);
color: inherit;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

&:not(.selected):hover {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import './style.scss';
import { type UseQueryResult, useMutation, useQueries } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Checkbox } from '../../../../../shared/components/Checkbox/Checkbox';
import { Divider } from '../../../../../shared/components/Divider/Divider';
import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox';
import { useAppData } from '../../../../../shared/providers/AppDataContext';
import { Snackbar } from '../../../../../shared/providers/snackbar/snackbar';
import { api } from '../../../../../shared/rust-api/api';
import { getLocationsQueryOptions } from '../../../../../shared/rust-api/query';
import type { LocationInfo } from '../../../../../shared/rust-api/types';
import { ThemeSpacing } from '../../../../../shared/types';
import { AutoConnectInstanceCard } from './components/AutoConnectInstanceCard/AutoConnectInstanceCard';
import { buildAutoConnectCards } from './helpers';

type Props = {
launchAtLogin: boolean;
};

type ToggleArgs = {
location: LocationInfo;
autoConnect: boolean;
};

const combineLocations = (results: UseQueryResult<LocationInfo[]>[]) =>
results.map((result) => result.data ?? []);

export const AutoConnectSection = ({ launchAtLogin }: Props) => {
const { instances } = useAppData();
const [eligibleOnly, setEligibleOnly] = useState(true);
const locationsPerInstance = useQueries({
queries: instances.map((instance) => getLocationsQueryOptions(instance.id)),
combine: combineLocations,
});

const { mutate: setAutoConnect, isPending } = useMutation({
mutationFn: ({ location, autoConnect }: ToggleArgs) =>
api.setLocationAutoConnect({ locationId: location.id, autoConnect }),
meta: { invalidate: ['locations'] },
onError: (_error, { location }) => {
Snackbar.error(`Couldn't change auto-connect for ${location.name}.`);
},
});

const { cards, eligibleCards } = useMemo(
() => buildAutoConnectCards(instances, locationsPerInstance),
[instances, locationsPerInstance],
);
const showFilter = eligibleCards.length > 0;
const filterApplied = showFilter && eligibleOnly;
const visibleCards = filterApplied ? eligibleCards : cards;

if (cards.length === 0) return null;

return (
<div className="auto-connect-section">
<Divider spacing={ThemeSpacing.Xl} />
<p className="title">Auto-connect locations</p>
<SizedBox height={ThemeSpacing.Xs} />
<p className="description">
Select the locations to connect automatically when the Defguard client starts.{' '}
<span>
Locations with MFA can't auto-connect. Only one All traffic location can
auto-connect, across all instances.
</span>
</p>
{!launchAtLogin && (
<>
<SizedBox height={ThemeSpacing.Xs} />
<p className="hint">
Defguard doesn't start with your operating system, so these locations connect
only when you open the client.
</p>
</>
)}
<SizedBox height={ThemeSpacing.Xl2} />
{showFilter && (
<>
<Checkbox
text="Show only locations eligible for auto-connect"
active={filterApplied}
onClick={() => setEligibleOnly((value) => !value)}
/>
<SizedBox height={ThemeSpacing.Lg} />
</>
)}
<div className="instances">
{visibleCards.map(({ instance, locations }) => (
<AutoConnectInstanceCard
key={instance.id}
name={instance.name}
locations={locations}
disabled={isPending}
onToggle={(location) =>
setAutoConnect({ location, autoConnect: !location.auto_connect })
}
/>
))}
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import './style.scss';
import { Badge } from '../../../../../../../shared/components/Badge/Badge';
import { Divider } from '../../../../../../../shared/components/Divider/Divider';
import { IconKind } from '../../../../../../../shared/components/Icon';
import { Toggle } from '../../../../../../../shared/components/Toggle/Toggle';
import { TooltipContent } from '../../../../../../../shared/providers/tooltip/TooltipContent';
import { TooltipProvider } from '../../../../../../../shared/providers/tooltip/TooltipContext';
import { TooltipTrigger } from '../../../../../../../shared/providers/tooltip/TooltipTrigger';
import {
AutoConnectBlock,
type LocationInfo,
} from '../../../../../../../shared/rust-api/types';
import { ThemeSpacing } from '../../../../../../../shared/types';
import { isPresent } from '../../../../../../../shared/utils/isPresent';

type Props = {
name: string;
locations: LocationInfo[];
disabled: boolean;
onToggle: (location: LocationInfo) => void;
};

export const AutoConnectInstanceCard = ({
name,
locations,
disabled,
onToggle,
}: Props) => {
return (
<div className="auto-connect-instance-card">
<p className="title">{name}</p>
<Divider spacing={ThemeSpacing.Md} />
<div className="locations">
{locations.map((location) => (
<LocationRow
key={location.id}
location={location}
disabled={disabled}
onToggle={() => onToggle(location)}
/>
))}
</div>
</div>
);
};

type LocationRowProps = {
location: LocationInfo;
disabled: boolean;
onToggle: () => void;
};

const LocationRow = ({ location, disabled, onToggle }: LocationRowProps) => {
const blocked = location.auto_connect_blocked;

return (
<div className="location">
<Toggle
label={location.name}
active={location.auto_connect}
disabled={disabled || isPresent(blocked)}
onClick={onToggle}
/>
{blocked === AutoConnectBlock.Mfa && (
<LockedBadge
text="MFA required"
tooltip="Only locations without MFA can auto-connect."
/>
)}
{blocked === AutoConnectBlock.AllTrafficTaken && (
<LockedBadge
text="All traffic auto-connect taken"
tooltip="Another All traffic location, possibly in a different instance, already auto-connects or is managed by the Defguard service."
/>
)}
{!isPresent(blocked) && (
<Badge
text={`${location.routes_all_traffic ? 'All traffic' : 'Predefined traffic'} / No MFA`}
/>
)}
</div>
);
};

type LockedBadgeProps = {
text: string;
tooltip: string;
};

const LockedBadge = ({ text, tooltip }: LockedBadgeProps) => {
return (
<TooltipProvider placement="top">
<TooltipTrigger>
<Badge disabled icon={IconKind.LockClosed} text={text} />
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</TooltipProvider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.auto-connect-instance-card {
display: flex;
flex-flow: column;
align-items: stretch;
justify-content: flex-start;
padding: var(--spacing-lg);
box-sizing: border-box;
border-radius: var(--radius-lg);
background-color: var(--bg-white-5);

& > .title {
font: var(--t-body-sm-400);
color: var(--fg-white-80);
}

& > .locations {
display: flex;
flex-flow: column;
align-items: stretch;
justify-content: flex-start;
row-gap: var(--spacing-sm);

& > .location {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
column-gap: var(--spacing-md);
padding: var(--spacing-sm);
box-sizing: border-box;

& > .badge {
--font: var(--t-body-xs-400);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
AutoConnectBlock,
type AutoConnectBlockValue,
type InstanceInfo,
type LocationInfo,
} from '../../../../../shared/rust-api/types';
import { buildAutoConnectCards } from './helpers';

const instanceOf = (id: number) => ({ id, name: `instance-${id}` }) as InstanceInfo;

const locationOf = (
id: number,
auto_connect_blocked: AutoConnectBlockValue | null = null,
) => ({ id, auto_connect_blocked }) as LocationInfo;

const idsOf = (cards: { instance: InstanceInfo; locations: LocationInfo[] }[]) =>
cards.map(({ instance, locations }) => [instance.id, locations.map(({ id }) => id)]);

describe('buildAutoConnectCards', () => {
it('drops instances without locations, including ones still loading', () => {
const { cards } = buildAutoConnectCards(
[instanceOf(1), instanceOf(2), instanceOf(3)],
[[locationOf(10)], []],
);
expect(idsOf(cards)).toEqual([[1, [10]]]);
});

it('keeps locations blocked by all traffic as eligible', () => {
const { eligibleCards } = buildAutoConnectCards(
[instanceOf(1)],
[[locationOf(10), locationOf(11, AutoConnectBlock.AllTrafficTaken)]],
);
expect(idsOf(eligibleCards)).toEqual([[1, [10, 11]]]);
});

it('leaves MFA locations out of the eligible cards', () => {
const { cards, eligibleCards } = buildAutoConnectCards(
[instanceOf(1), instanceOf(2)],
[
[locationOf(10), locationOf(11, AutoConnectBlock.Mfa)],
[locationOf(20, AutoConnectBlock.Mfa)],
],
);
expect(idsOf(cards)).toEqual([
[1, [10, 11]],
[2, [20]],
]);
expect(idsOf(eligibleCards)).toEqual([[1, [10]]]);
});
});
Loading
Loading