Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
507ec08
formatting
wojcik91 Jul 10, 2026
2ceda81
add wiremock dependency
wojcik91 Jul 10, 2026
025f044
add mock WebSocket helpers
wojcik91 Jul 10, 2026
bcc6072
setup enrollment flow base
wojcik91 Jul 10, 2026
a85e8d2
add methods for other enrollment endpoints
wojcik91 Jul 10, 2026
05aa524
test enrollment helpers
wojcik91 Jul 10, 2026
8266d26
add helpers for MFA flow requests
wojcik91 Jul 10, 2026
17df6f7
add helpers for polling MFA results
wojcik91 Jul 10, 2026
410ed59
add MFA helpers tests
wojcik91 Jul 10, 2026
c6c18fa
add enrollment sessions & MFA polling tasks to AppState
wojcik91 Jul 10, 2026
710e1d6
add MFA-related tauri events
wojcik91 Jul 10, 2026
99004fa
add tauri commands for calling new mfa & enrollment helpers
wojcik91 Jul 10, 2026
5d26849
Merge branch 'release/2.1' into migrate_mfa_to_rust
wojcik91 Jul 12, 2026
2ff5b06
refactor AddInstance and Enrollment workflows to use new commands
wojcik91 Jul 13, 2026
2e29969
remove unused types
wojcik91 Jul 13, 2026
ac801c0
formatting
wojcik91 Jul 13, 2026
d7a05e1
fix derive order
wojcik91 Jul 13, 2026
f591f44
refactor basic MFA code flow
wojcik91 Jul 13, 2026
c533671
add missing posture data
wojcik91 Jul 13, 2026
b3c222a
refactor OIDC flow
wojcik91 Jul 13, 2026
ad52b1e
refactor mobile auth flow
wojcik91 Jul 13, 2026
1eab962
remove redundant helpers
wojcik91 Jul 13, 2026
97d18d8
use new helpers in CLI
wojcik91 Jul 13, 2026
f47d40e
remove legacy CLI tests
wojcik91 Jul 13, 2026
d3bfa9d
make powershell setting windows-only
wojcik91 Jul 13, 2026
1414b82
cleanup
wojcik91 Jul 13, 2026
30d6b3b
avoid sending PSK to frontend
wojcik91 Jul 13, 2026
35fc033
extract more shared helpers
wojcik91 Jul 13, 2026
4acf649
Merge branch 'release/2.1' into migrate_mfa_to_rust
wojcik91 Jul 13, 2026
bf70ae6
use different approach to setting windows shell in justfile
wojcik91 Jul 13, 2026
c7079f2
fix the location ID used for connection
wojcik91 Jul 13, 2026
1520cc7
fix proxy API typing
wojcik91 Jul 13, 2026
19dd458
add missing css file import
wojcik91 Jul 14, 2026
47c1c26
restore relevant comments
wojcik91 Jul 14, 2026
90efd9d
cleanup sessions
wojcik91 Jul 14, 2026
58e8919
restore pre-existing 404 fallback
wojcik91 Jul 14, 2026
394a6d1
make error type naming consistent
wojcik91 Jul 14, 2026
73c455a
fix invalid code error
wojcik91 Jul 14, 2026
2f125a0
fix user-facing errors
wojcik91 Jul 14, 2026
0125454
remove duplicate frontend timeout
wojcik91 Jul 14, 2026
fa66a28
add more tests
wojcik91 Jul 14, 2026
e01d209
fix posture errors
wojcik91 Jul 14, 2026
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
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
set shell := ["powershell.exe", "-c"]
set windows-shell := ["powershell.exe", "-c"]

dev:
npx concurrently \
Expand Down
14 changes: 10 additions & 4 deletions new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import './style.scss';

import { useLoaderData, useNavigate, useSearch } from '@tanstack/react-router';
import { error } from '@tauri-apps/plugin-log';
import { Fragment, useMemo } from 'react';
import z from 'zod';
import { Button } from '../../../shared/components/Button/Button';
Expand Down Expand Up @@ -68,16 +69,21 @@ export const AddInstancePage = () => {
});
return;
}
void error(`Failed to add instance: ${result.error ?? 'unknown error'}`);
Snackbar.error('Communication error, contact administrator.');
return;
}
if (result.cookie) {
await edgeApi.createDevice(value.url, result.cookie, value.name.trim());
if (result.session_id) {
await edgeApi.createDevice(result.session_id, value.name.trim());
}
if (result.startResponse && !result.startResponse.user.enrolled && result.cookie) {
if (
result.startResponse &&
!result.startResponse.user.enrolled &&
result.session_id
) {
useEnrollmentStore
.getState()
.start(result.startResponse, value.url, result.cookie, undefined);
.start(result.startResponse, result.session_id, undefined);
navigate({
to: '/full/enrollment',
replace: true,
Expand Down
15 changes: 7 additions & 8 deletions new-ui/src/pages/full/EnrollmentPage/hooks/activateUser.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { edgeApi } from '../../../../shared/edge-api/api';
import { api } from '../../../../shared/rust-api/api';
import { useEnrollmentStore } from './useEnrollmentStore';

/**
* Activate the enrolling user by calling the proxy activation API.
* Activate the enrolling user by calling the enrollment Tauri command.
*
* Reads proxy URL, cookie, skipPassword, and userPassword from the store via
* Reads sessionId, skipPassword, and userPassword from the store via
* {@linkcode useEnrollmentStore.getState} so every invocation uses the latest
* values (no stale-closure risk after in-flight {@linkcode setState} updates).
*
* When `skipPassword` is true the `password` key is omitted from the request
* body instead of sending `null` or an empty string.
*/
export const activateUser = async () => {
const { proxyUrl, sessionCookie, skipPassword, userPassword } =
useEnrollmentStore.getState();
const body = skipPassword ? {} : { password: userPassword ?? '' };
// biome-ignore lint/style/noNonNullAssertion: proxy and cookie are set in start()
return edgeApi.activateUser(proxyUrl!, sessionCookie!, body);
const { sessionId, skipPassword, userPassword } = useEnrollmentStore.getState();
const password = skipPassword ? null : (userPassword ?? '');
// biome-ignore lint/style/noNonNullAssertion: sessionId is set in start(), called before this
return api.enrollmentActivateUser(sessionId!, password, null);
};
24 changes: 11 additions & 13 deletions new-ui/src/pages/full/EnrollmentPage/hooks/useEnrollmentStore.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import dayjs from 'dayjs';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import type { EnrollmentStartResponse } from '../../../../shared/edge-api/types';
import type { MfaMethodValue } from '../../../../shared/rust-api/types';
import type {
EnrollmentStartResult,
MfaMethodValue,
} from '../../../../shared/rust-api/types';
import { EnrollmentStep, type EnrollmentStepValue } from '../types';

type StoreValues = {
activeStep: EnrollmentStepValue;
startResponse: EnrollmentStartResponse | null;
proxyUrl: string | null;
startResponse: EnrollmentStartResult | null;
sessionId: string | null;
skipMfa: boolean;
skipPassword: boolean;
skipMfaChoice: boolean;
Expand All @@ -17,13 +19,11 @@ type StoreValues = {
userTotpSecret: string | null;
userRecoveryCodes: string[] | null;
userMfaChoice: MfaMethodValue;
sessionCookie: string | null;
};

const defaults: StoreValues = {
activeStep: EnrollmentStep.Welcome,
sessionCookie: null,
proxyUrl: null,
sessionId: null,
userRecoveryCodes: null,
startResponse: null,
skipMfa: false,
Expand All @@ -37,9 +37,8 @@ const defaults: StoreValues = {

interface Store extends StoreValues {
start: (
response: EnrollmentStartResponse,
url: string,
cookie: string,
response: EnrollmentStartResult,
sessionId: string,
totpSecret?: string,
) => void;
next: () => void;
Expand All @@ -50,13 +49,12 @@ export const useEnrollmentStore = create<Store>()(
persist(
(set, get) => ({
...defaults,
start: (response, url, cookie, secret) => {
start: (response, sessionId, secret) => {
set({
...defaults,
proxyUrl: url,
sessionId,
activeStep: EnrollmentStep.Welcome,
startResponse: response,
sessionCookie: cookie,
// if smtp is not present then it's not possible to choose.
skipMfaChoice:
!response.settings.smtp_configured || !response.settings.mfa_required,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { Button } from '../../../../../shared/components/Button/Button';
import { ButtonVariant } from '../../../../../shared/components/Button/types';
import { Controls } from '../../../../../shared/components/Controls/Controls';
import { RenderMarkdown } from '../../../../../shared/components/RenderMarkdown/RenderMarkdown';
import { api } from '../../../../../shared/rust-api/api';
import { isPresent } from '../../../../../shared/utils/isPresent';
import { useEnrollmentStore } from '../../hooks/useEnrollmentStore';
import bannerSrc from './assets/banner.png';

export const FinishStep = () => {
const navigate = useNavigate();
const response = useEnrollmentStore((s) => s.startResponse);
const sessionId = useEnrollmentStore((s) => s.sessionId);
const markdownContent = response?.final_page_content;
const showMarkdown = isPresent(markdownContent) && markdownContent.length > 0;

Expand All @@ -32,6 +34,12 @@ export const FinishStep = () => {
variant={ButtonVariant.Primary}
text="Got it"
onClick={() => {
// Release the enrollment session now that the wizard is done.
// Best-effort: enrollment_finish errors if the session is already
// gone, so swallow failures and leave the wizard regardless.
if (isPresent(sessionId)) {
void api.enrollmentFinish(sessionId).catch(() => {});
}
navigate({ to: '/full/overview', replace: true });
}}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,34 @@
/** biome-ignore-all lint/style/noNonNullAssertion: this cannot be shown without those store values */
/** biome-ignore-all lint/style/noNonNullAssertion: sessionId is set in start(), called before */
import { useMutation } from '@tanstack/react-query';
import './style.scss';
import clsx from 'clsx';
import { useState } from 'react';
import { useShallow } from 'zustand/shallow';
import {
Icon,
IconKind,
type IconKindValue,
} from '../../../../../shared/components/Icon';
import { RadioIndicator } from '../../../../../shared/components/RadioIndicator/RadioIndicator';
import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox';
import { edgeApi } from '../../../../../shared/edge-api/api';
import { api } from '../../../../../shared/rust-api/api';
import { MfaMethod, type MfaMethodValue } from '../../../../../shared/rust-api/types';
import { ThemeSpacing } from '../../../../../shared/types';
import { EnrollmentControls } from '../../components/EnrollmentControls/EnrollmentControls';
import { useEnrollmentStore } from '../../hooks/useEnrollmentStore';

export const MfaChoiceStep = () => {
const [proxyUrl, cookie] = useEnrollmentStore(
useShallow((s) => [s.proxyUrl!, s.sessionCookie!]),
);
const sessionId = useEnrollmentStore((s) => s.sessionId);
const [mfa, setMfa] = useState<MfaMethodValue>(
useEnrollmentStore.getState().userMfaChoice,
);

const { mutate, isPending } = useMutation({
mutationFn: () => edgeApi.startMfaSetup(proxyUrl, cookie, mfa),
mutationFn: () => api.enrollmentRegisterMfaStart(sessionId!, mfa),
onSuccess: (resp) => {
if (mfa === MfaMethod.Totp) {
const secret = resp.result?.totp_secret;
useEnrollmentStore.setState({
userMfaChoice: mfa,
userTotpSecret: secret,
userTotpSecret: resp.totp_secret,
});
} else {
useEnrollmentStore.setState({ userMfaChoice: mfa });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,57 +1,71 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import './style.scss';
import { useMutation } from '@tanstack/react-query';
import { useShallow } from 'zustand/shallow';
import { error as logError } from '@tauri-apps/plugin-log';
import { CodeInput } from '../../../../../shared/components/CodeInput/CodeInput';
import { CopyField } from '../../../../../shared/components/CopyField/CopyField';
import { Divider } from '../../../../../shared/components/Divider/Divider';
import { QrCard } from '../../../../../shared/components/QrCard/QrCard';
import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox';
import { edgeApi } from '../../../../../shared/edge-api/api';
import { api } from '../../../../../shared/rust-api/api';
import { MfaMethod } from '../../../../../shared/rust-api/types';
import { ThemeSpacing } from '../../../../../shared/types';
import { isPresent } from '../../../../../shared/utils/isPresent';
import { EnrollmentControls } from '../../components/EnrollmentControls/EnrollmentControls';
import { activateUser } from '../../hooks/activateUser';
import { useEnrollmentStore } from '../../hooks/useEnrollmentStore';

/**
* Map a register-mfa-finish error to a user-facing message. The proxy rejects
* a wrong code with a `proxy_error` (HTTP 400) whose message contains "invalid"
* (core returns "Code invalid" / "Email code invalid"); anything else is
* unexpected and gets a generic message.
*/
const mfaFinishErrorMessage = (err: unknown): string => {
try {
const parsed = JSON.parse(String(err)) as { type?: string; message?: string };
if (
parsed.type === 'proxy_error' &&
parsed.message?.toLowerCase().includes('invalid')
) {
return 'Invalid code';
}
} catch {
// Non-JSON error: fall through to the generic message.
}
return 'MFA setup failed. Please try again.';
};

export const MfaConfigurationStep = () => {
const sessionId = useEnrollmentStore((s) => s.sessionId);
const method = useEnrollmentStore((s) => s.userMfaChoice);
const [code, setCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [proxyUrl, cookie] = useEnrollmentStore(
// biome-ignore lint/style/noNonNullAssertion: safe
useShallow((s) => [s.proxyUrl!, s.sessionCookie!]),
);

const { mutate, isPending } = useMutation({
mutationFn: async () => {
const resp = await edgeApi.finishMfaSetup(proxyUrl, cookie, {
// biome-ignore lint/style/noNonNullAssertion: checked in handleSubmit
code: code!,
method,
});
// biome-ignore lint/style/noNonNullAssertion: checked in handleSubmit
const resp = await api.enrollmentRegisterMfaFinish(sessionId!, code!, method);
await activateUser();
return resp;
},
onError: () => {},
onError: (err) => {
void logError(`MFA configuration failed: ${err}`);
setError(mfaFinishErrorMessage(err));
},
onSuccess: (resp) => {
if (resp.result) {
useEnrollmentStore.setState({
userRecoveryCodes: resp.result.recovery_codes,
deadline: null,
});
useEnrollmentStore.getState().next();
}
if (resp.error) {
setError('Enter a valid code');
}
useEnrollmentStore.setState({
userRecoveryCodes: resp.recovery_codes,
deadline: null,
});
useEnrollmentStore.getState().next();
},
});

const handleSubmit = useCallback(() => {
if (code?.trim().length !== 6) {
setError('');
setError('Enter a valid code');
return;
}
mutate();
}, [code, mutate]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
/** biome-ignore-all lint/style/noNonNullAssertion: ensured by wizard page */
import { error } from '@tauri-apps/plugin-log';
import './style.scss';
import { useStore } from '@tanstack/react-form';
import { useMutation } from '@tanstack/react-query';
import clsx from 'clsx';
import { useCallback } from 'react';
import z from 'zod';
import { useShallow } from 'zustand/shallow';
import { Icon, type IconKindValue } from '../../../../../shared/components/Icon';
import { SizedBox } from '../../../../../shared/components/SizedBox/SizedBox';
import { edgeApi } from '../../../../../shared/edge-api/api';
import { useAppForm, withForm } from '../../../../../shared/form';
import { formChangeLogic } from '../../../../../shared/formLogic';
import { api } from '../../../../../shared/rust-api/api';
import { MfaMethod } from '../../../../../shared/rust-api/types';
import { ThemeSpacing } from '../../../../../shared/types';
import { EnrollmentControls } from '../../components/EnrollmentControls/EnrollmentControls';
Expand Down Expand Up @@ -59,11 +59,9 @@ const defaultValues: FormFields = {
};

export const PasswordStep = () => {
const [proxyUrl, cookie] = useEnrollmentStore(
useShallow((s) => [s.proxyUrl!, s.sessionCookie!]),
);
const sessionId = useEnrollmentStore((s) => s.sessionId);
const { mutateAsync: startMfa } = useMutation({
mutationFn: () => edgeApi.startMfaSetup(proxyUrl, cookie, MfaMethod.Totp),
mutationFn: () => api.enrollmentRegisterMfaStart(sessionId!, MfaMethod.Totp),
});

const form = useAppForm({
Expand All @@ -74,25 +72,24 @@ export const PasswordStep = () => {
onChange: formSchema,
},
onSubmit: async ({ value }) => {
const { skipMfaChoice, skipMfa } = useEnrollmentStore.getState();
if (skipMfaChoice) {
const mfaResponse = await startMfa();
if (mfaResponse.result) {
try {
const { skipMfaChoice, skipMfa } = useEnrollmentStore.getState();
if (skipMfaChoice) {
const mfaResponse = await startMfa();
useEnrollmentStore.setState({
userTotpSecret: mfaResponse.result.totp_secret ?? null,
userTotpSecret: mfaResponse.totp_secret,
});
} else {
console.error(mfaResponse);
return;
}
useEnrollmentStore.setState({
userPassword: value.password.trim(),
});
if (skipMfa) {
await activateUser();
}
useEnrollmentStore.getState().next();
} catch (err) {
void error(`Password step failed: ${err}`);
}
useEnrollmentStore.setState({
userPassword: value.password.trim(),
});
if (skipMfa) {
await activateUser();
}
useEnrollmentStore.getState().next();
},
});

Expand Down
Loading
Loading