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
1 change: 1 addition & 0 deletions new-ui/src/shared/components/Button/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
font: var(--btn-font);
color: inherit;
user-select: none;
white-space: nowrap;

@include animate(color);
}
Expand Down
31 changes: 31 additions & 0 deletions new-ui/src/shared/components/LocationCard/api/connectError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest';
import { ConnectionType, type LocationInfo } from '../../../rust-api/types';
import { findRouteConflicts } from './connectError';

const mocks = vi.hoisted(() => ({ checkRouteConflict: vi.fn() }));

vi.mock('@tauri-apps/api/event', () => ({ emitTo: vi.fn() }));
vi.mock('../../../rust-api/api', () => ({
api: { checkRouteConflict: mocks.checkRouteConflict },
}));

const location = { id: 7 } as LocationInfo;
const conflict = { id: 3, connection_type: ConnectionType.Location, name: 'Office' };

describe('findRouteConflicts', () => {
it('returns the conflicts only for a route conflict error', async () => {
mocks.checkRouteConflict.mockRejectedValueOnce({
kind: 'routeConflict',
message: 'conflict',
conflicts: [conflict],
});
await expect(findRouteConflicts(location)).resolves.toEqual([conflict]);
expect(mocks.checkRouteConflict).toHaveBeenCalledWith(7);

mocks.checkRouteConflict.mockResolvedValueOnce(undefined);
await expect(findRouteConflicts(location)).resolves.toEqual([]);

mocks.checkRouteConflict.mockRejectedValueOnce({ kind: 'other', message: 'db' });
await expect(findRouteConflicts(location)).resolves.toEqual([]);
});
});
11 changes: 11 additions & 0 deletions new-ui/src/shared/components/LocationCard/api/connectError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ export const parseConnectError = (err: unknown): ConnectError | null => {
return result.success ? result.data : null;
};

export const findRouteConflicts = (
location: LocationInfo,
): Promise<ConflictingConnection[]> =>
api.checkRouteConflict(location.id).then(
() => [],
(err: unknown) => {
const connectError = parseConnectError(err);
return connectError?.kind === 'routeConflict' ? connectError.conflicts : [];
},
);

export const handleFullViewConnectError = (
location: LocationInfo,
err: unknown,
Expand Down
6 changes: 6 additions & 0 deletions new-ui/src/shared/components/LocationCard/context/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
shouldStartMfa,
usableMfaMethods,
} from '../../../utils/mfa';
import { findRouteConflicts, openRouteConflictInFullView } from '../api/connectError';
import {
LocationCardViews,
type LocationCardViewsValue,
Expand Down Expand Up @@ -136,6 +137,11 @@ export const LocationCardProvider = ({
);

const startMfa = useCallback(async () => {
const conflicts = await findRouteConflicts(location);
if (conflicts.length > 0) {
await openRouteConflictInFullView({ location, conflicts });
return;
}
mfaStarted.current = true;
const appConfig = await api.getAppConfig();
setAutoConnectOpenid(appConfig.auto_start_openid_mfa);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import { isPresent } from '../../utils/isPresent';
import { ConnectionAbility, resolveMfaStepPlan, shouldStartMfa } from '../../utils/mfa';
import { Divider } from '../Divider/Divider';
import { IconKind } from '../Icon';
import { handleFullViewConnectError } from '../LocationCard/api/connectError';
import {
findRouteConflicts,
handleFullViewConnectError,
openRouteConflictModal,
} from '../LocationCard/api/connectError';
import { ConnectButton } from '../LocationCard/components/ConnectButton/ConnectButton';
import { connectBlockedTooltip } from '../LocationCard/components/ConnectButton/consts';
import { LocationCardConnectionInfo } from '../LocationCard/components/LocationCardConnectionInfo/LocationCardConnectionInfo';
Expand Down Expand Up @@ -95,13 +99,19 @@ export const OverviewLocationCard = ({ location, instance }: Props) => {

if (shouldStartMfa(location)) {
const stepPlan = resolveMfaStepPlan(location, [], instance);
useConnectModal.getState().open({
view: mfaMethodToConnectModalView(stepPlan[0]),
location,
stepPlan,
autoStartOpenId: appConfig.auto_start_openid_mfa,
mfaMethod: stepPlan[0],
});
const openMfa = () =>
useConnectModal.getState().open({
view: mfaMethodToConnectModalView(stepPlan[0]),
location,
stepPlan,
autoStartOpenId: appConfig.auto_start_openid_mfa,
mfaMethod: stepPlan[0],
});
void findRouteConflicts(location).then((conflicts) =>
conflicts.length > 0
? openRouteConflictModal(location, conflicts, openMfa)
: openMfa(),
);
return;
}

Expand Down
66 changes: 41 additions & 25 deletions new-ui/src/shared/providers/TauriEventProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { runConfigureFactorsRequest } from '../../pages/full/ConfigureMfaPage/ho
import { mfaMethodToConnectModalView } from '../../pages/full/OverviewPage/components/ConnectModal/hooks/types';
import { useConnectModal } from '../../pages/full/OverviewPage/components/ConnectModal/hooks/useConnectModal';
import {
findRouteConflicts,
handleFullViewConnectError,
openRouteConflictModal,
type RouteConflictPayload,
Expand All @@ -27,14 +28,42 @@ import {
type TunnelsDisabledPayload,
} from '../rust-api/types';
import { useAppStore } from '../store/useAppStore';
import { resolveMfaStepPlan } from '../utils/mfa';
import { resolveMfaStepPlan, shouldStartMfa } from '../utils/mfa';

export const TauriEventProvider = ({ children }: PropsWithChildren) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { setViewSelection } = useAppData();

useEffect(() => {
const openMfaModal = async (location: LocationInfo) => {
const appConfig = await api.getAppConfig();
// A failed refresh keeps the cached instances. With none, the plan uses
// the steps' own flags, which beats dropping the prompt.
let instances = queryClient.getQueryData(getInstancesQueryOptions.queryKey);
try {
instances = await queryClient.fetchQuery(getInstancesQueryOptions);
} catch (err) {
void logError(`MFA prompt: failed to load instances: ${err}`);
}
const instance = instances?.find(({ id }) => id === location.instance_id);
const stepPlan = resolveMfaStepPlan(location, [], instance);

await navigate({ to: '/full/overview' });
useConnectModal.getState().open({
view: mfaMethodToConnectModalView(stepPlan[0]),
location,
stepPlan,
autoStartOpenId: appConfig.auto_start_openid_mfa,
mfaMethod: stepPlan[0],
});
};
const openMfaModalOrLog = (location: LocationInfo) => {
void openMfaModal(location).catch((err) => {
void logError(`Failed to open the MFA prompt: ${err}`);
});
};

const unlisteners = Promise.all([
listen<AddInstanceEventPayload>(TauriEvent.AddInstance, (event) => {
void debug(`UI Received event AddInstance (${event.payload.url})`);
Expand Down Expand Up @@ -74,30 +103,13 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => {
connection_type: ConnectionType.Location,
active: false,
};
void (async () => {
const appConfig = await api.getAppConfig();
// A failed refresh keeps the cached instances. With none, the plan uses
// the steps' own flags, which beats dropping the prompt.
let instances = queryClient.getQueryData(getInstancesQueryOptions.queryKey);
try {
instances = await queryClient.fetchQuery(getInstancesQueryOptions);
} catch (err) {
void logError(`MfaTrigger: failed to load instances: ${err}`);
}
const instance = instances?.find(({ id }) => id === location.instance_id);
const stepPlan = resolveMfaStepPlan(location, [], instance);

await navigate({ to: '/full/overview' });
useConnectModal.getState().open({
view: mfaMethodToConnectModalView(stepPlan[0]),
location,
stepPlan,
autoStartOpenId: appConfig.auto_start_openid_mfa,
mfaMethod: stepPlan[0],
});
})().catch((err) => {
void logError(`MfaTrigger: failed to open the MFA prompt: ${err}`);
});
void findRouteConflicts(location).then((conflicts) =>
conflicts.length > 0
? openRouteConflictModal(location, conflicts, () =>
openMfaModalOrLog(location),
)
: openMfaModalOrLog(location),
);
}
},
),
Expand All @@ -114,6 +126,10 @@ export const TauriEventProvider = ({ children }: PropsWithChildren) => {
listen<RouteConflictPayload>(TauriEvent.RouteConflict, (event) => {
if (getCurrentWindow().label !== WindowId.FullView) return;
const { location, conflicts } = event.payload;
if (shouldStartMfa(location)) {
openRouteConflictModal(location, conflicts, () => openMfaModalOrLog(location));
return;
}
// The connect is not awaited, so the confirm modal closes before a new error can reopen it.
const connect = () => {
void api
Expand Down
4 changes: 4 additions & 0 deletions new-ui/src/shared/rust-api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ const setLocationMfaStepPlan = (args: SetLocationMfaStepPlanArgs): Promise<void>
const connect = (args: ConnectionArgs): Promise<void> =>
invoke(TauriCommand.Connect, args);

const checkRouteConflict = (locationId: number): Promise<void> =>
invoke(TauriCommand.CheckRouteConflict, { locationId });

const disconnect = (args: ConnectionArgs): Promise<void> =>
invoke(TauriCommand.Disconnect, args);

Expand Down Expand Up @@ -348,6 +351,7 @@ export const api = {
setLocationMfaStepPlan,
// Connections
connect,
checkRouteConflict,
disconnect,
getLastConnection,
getConnectionHistory,
Expand Down
1 change: 1 addition & 0 deletions new-ui/src/shared/rust-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const TauriCommand = {
SetLocationMfaStepPlan: 'set_location_mfa_step_plan',
// Connections
Connect: 'connect',
CheckRouteConflict: 'check_route_conflict',
Disconnect: 'disconnect',
LastConnection: 'last_connection',
AllConnections: 'all_connections',
Expand Down
1 change: 1 addition & 0 deletions src-tauri/permissions/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ commands.allow = [
"save_device_config",
"all_instances",
"connect",
"check_route_conflict",
"disconnect",
"update_instance",
"location_stats",
Expand Down
11 changes: 11 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,17 @@ pub async fn connect(
Ok(())
}

#[tauri::command(async)]
pub async fn check_route_conflict(location_id: Id) -> Result<(), ConnectError> {
let location = Location::find_by_id(&*DB_POOL, location_id)
.await?
.ok_or(Error::NotFound)?;
ConnectionTarget::Location(location)
.ensure_no_route_conflict(&DB_POOL, None)
.await?;
Ok(())
}

#[tauri::command(async)]
pub async fn start_global_logwatcher(handle: AppHandle) -> Result<(), Error> {
let result = spawn_global_log_watcher_task(&handle, tracing::Level::DEBUG).await;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/gui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ pub fn run_app() {
save_device_config,
all_instances,
connect,
check_route_conflict,
disconnect,
update_instance,
location_stats,
Expand Down
Loading