Skip to content
Open
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
119 changes: 119 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
version: "3"

env:
SQLX_OFFLINE: "true"

tasks:
dev:
cmds:
- npx concurrently -n "NEW,TAURI" "cd new-ui && pnpm dev" "cargo tauri dev"

dev-tauri:
desc: Run only the Tauri side with the local dev database and debug logging.
dir: src-tauri
env:
DATABASE_URL: sqlite:dev.db
DEFGUARD_CLIENT_DEV: "1"
DEFGUARD_CLIENT_LOG_LEVEL: debug
cmds:
- cargo tauri dev

dev-web:
desc: Run only the web frontend dev server.
dir: new-ui
cmds:
- pnpm dev

build:
cmds:
- cd new-ui && pnpm build
- cargo tauri build --config src-tauri/tauri.local.conf.json

lint:
desc: Format and lint the Rust workspace and the UI.
cmds:
- task: lint-rust
- task: lint-ui

lint-rust:
desc: Format and lint the Rust workspace in src-tauri.
dir: src-tauri
cmds:
- cargo fmt --all
- cargo clippy --workspace --all-targets --all-features -- -D warnings

test-rust:
desc: Run the Rust workspace tests in src-tauri with nextest. Extra args go after --.
dir: src-tauri
cmds:
- cargo nextest run --workspace --no-fail-fast {{.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

lint-ui:
desc: Format and lint the UI in new-ui.
dir: new-ui
cmds:
- pnpm fix
- pnpm lint

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

e2e-build:
desc: Build the client and install both frontend dependency sets.
platforms: [linux, darwin]
cmds:
- cd new-ui && pnpm install --frozen-lockfile && pnpm build
- cd e2e && pnpm install --frozen-lockfile
- cargo tauri build

e2e-provision:
desc: Provision the configured Core deployment. It must already be running.
platforms: [linux, darwin]
dir: e2e
preconditions:
- sh: test -f .env
msg: Missing e2e/.env, copy e2e/.env.example and fill it locally.
cmds:
- pnpm provision

e2e-test:
desc: Run the WebDriver suite. Core, Edge, gateway and defguard-service are external prerequisites.
platforms: [linux, darwin]
dir: e2e
preconditions:
- sh: test -f .env
msg: Missing e2e/.env, copy e2e/.env.example and fill it locally.
- sh: test -S /var/run/defguard.socket
msg: defguard-service is not running at /var/run/defguard.socket.
- sh: test -x "${TAURI_DRIVER:-$HOME/.cargo/bin/tauri-driver}"
msg: tauri-driver is missing, set TAURI_DRIVER to its location.
- sh: command -v WebKitWebDriver
msg: WebKitWebDriver is missing, enter nix develop.
- sh: command -v xvfb-run
msg: xvfb-run is missing, enter nix develop.
- sh: command -v dbus-run-session
msg: dbus-run-session is missing, enter nix develop.
cmds:
- NATIVE_DRIVER="${NATIVE_DRIVER:-$(command -v WebKitWebDriver)}" dbus-run-session -- xvfb-run -a pnpm test

e2e:
platforms: [linux, darwin]
cmds:
- task: e2e-build
- task: e2e-provision
- task: e2e-test
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>
);
};
Loading
Loading