From a1a2a9c082eae39586674996ad9ab5ebb184916b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Thu, 28 Aug 2025 14:43:07 +0200 Subject: [PATCH 1/3] handle updated enrollment configuration --- .../AddInstanceInitForm.tsx | 23 ++++++++++------ .../EnrollmentSideBar/EnrollmentSideBar.tsx | 6 +++-- .../hooks/store/useEnrollmentStore.tsx | 11 +++++++- .../steps/ChooseMfaStep/ChooseMfaStep.tsx | 26 +++++++++++-------- src/shared/defguard-ui | 2 +- src/shared/hooks/api/types.ts | 11 +++++++- 6 files changed, 55 insertions(+), 24 deletions(-) diff --git a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceInitForm/AddInstanceInitForm.tsx b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceInitForm/AddInstanceInitForm.tsx index 01ec4d22..65d98146 100644 --- a/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceInitForm/AddInstanceInitForm.tsx +++ b/src/pages/client/pages/ClientAddInstancePage/components/AddInstanceFormCard/components/AddInstanceInitForm/AddInstanceInitForm.tsx @@ -139,10 +139,12 @@ export const AddInstanceInitForm = ({ nextStep }: Props) => { ); } debug('Response received with status OK'); - const r = (await res.json()) as EnrollmentStartResponse; + const startResponse = (await res.json()) as EnrollmentStartResponse; // get client registered instances const clientInstances = await clientApi.getInstances(); - const instance = clientInstances.find((i) => i.uuid === r.instance.id); + const instance = clientInstances.find( + (i) => i.uuid === startResponse.instance.id, + ); let proxy_api_url = values.url; if (proxy_api_url[proxy_api_url.length - 1] === '/') { proxy_api_url = proxy_api_url.slice(0, -1); @@ -191,24 +193,29 @@ export const AddInstanceInitForm = ({ nextStep }: Props) => { } // register new instance // is user in need of full enrollment ? - if (r.user.enrolled) { + if (startResponse.user.enrolled) { //no, only create new device for desktop client debug('User already active, adding device only.'); nextStep({ url: proxy_api_url, cookie: authCookie, - device_names: r.user.device_names, + device_names: startResponse.user.device_names, }); } else { // yes, enroll the user debug('User is not active. Starting enrollment.'); - const sessionEnd = dayjs.unix(r.deadline_timestamp).utc().local().format(); + const sessionEnd = dayjs + .unix(startResponse.deadline_timestamp) + .utc() + .local() + .format(); const sessionStart = dayjs().local().format(); initEnrollment({ - userInfo: r.user, - adminInfo: r.admin, - endContent: r.final_page_content, + userInfo: startResponse.user, + adminInfo: startResponse.admin, + endContent: startResponse.final_page_content, proxy_url: proxy_api_url, + enrollmentSettings: startResponse.settings, sessionEnd, sessionStart, cookie: authCookie, diff --git a/src/pages/enrollment/components/EnrollmentSideBar/EnrollmentSideBar.tsx b/src/pages/enrollment/components/EnrollmentSideBar/EnrollmentSideBar.tsx index d16f9cdc..962551fb 100644 --- a/src/pages/enrollment/components/EnrollmentSideBar/EnrollmentSideBar.tsx +++ b/src/pages/enrollment/components/EnrollmentSideBar/EnrollmentSideBar.tsx @@ -22,6 +22,8 @@ type SideBarItem = { export const EnrollmentSideBar = () => { const { LL } = useI18nContext(); + const enrollmentSettings = useEnrollmentStore((s) => s.enrollmentSettings); + const vpnOptional = useEnrollmentStore((state) => state.vpnOptional); const currentStep = useEnrollmentStore((state) => state.step); @@ -41,7 +43,7 @@ export const EnrollmentSideBar = () => { case EnrollmentStepKey.DEVICE: return `${stepsLL.vpn()}${vpnOptional ? '*' : ''}`; case EnrollmentStepKey.MFA: - return stepsLL.mfa(); + return `${stepsLL.mfa()}${enrollmentSettings.mfa_required ? '' : '*'}`; case EnrollmentStepKey.MFA_CHOICE: return stepsLL.mfaChoice(); case EnrollmentStepKey.MFA_SETUP: @@ -56,7 +58,7 @@ export const EnrollmentSideBar = () => { return ''; } }, - [LL.pages.enrollment.sideBar.steps, vpnOptional], + [LL.pages.enrollment.sideBar.steps, vpnOptional, enrollmentSettings.mfa_required], ); const stepsData = useMemo( diff --git a/src/pages/enrollment/hooks/store/useEnrollmentStore.tsx b/src/pages/enrollment/hooks/store/useEnrollmentStore.tsx index 20eb2a6e..7f498984 100644 --- a/src/pages/enrollment/hooks/store/useEnrollmentStore.tsx +++ b/src/pages/enrollment/hooks/store/useEnrollmentStore.tsx @@ -5,6 +5,7 @@ import { createWithEqualityFn } from 'zustand/traditional'; import type { AdminInfo, CreateDeviceResponse, + EnrollmentSettings, UserInfo, } from '../../../../shared/hooks/api/types'; import { MfaMethod } from '../../../../shared/types'; @@ -15,6 +16,13 @@ const defaultValues: StoreValues = { // assume default dev proxy_url: '/api/v1/', loading: false, + enrollmentSettings: { + admin_device_management: false, + mfa_required: false, + only_client_activation: false, + smtp_configured: false, + vpn_setup_optional: true, + }, step: EnrollmentStepKey.WELCOME, mfaMethod: MfaMethod.TOTP, recoveryCodes: [], @@ -46,6 +54,7 @@ const persistKeys: Array = [ 'deviceKeys', 'deviceResponse', 'cookie', + 'enrollmentSettings', ]; export const useEnrollmentStore = createWithEqualityFn()( @@ -75,8 +84,8 @@ export const useEnrollmentStore = createWithEqualityFn()( type Store = StoreValues & StoreMethods; type StoreValues = { - // next and back are disabled loading: boolean; + enrollmentSettings: EnrollmentSettings; step: EnrollmentStepKey; mfaMethod: MfaMethod; nextSubject: Subject; diff --git a/src/pages/enrollment/steps/ChooseMfaStep/ChooseMfaStep.tsx b/src/pages/enrollment/steps/ChooseMfaStep/ChooseMfaStep.tsx index aeccf33b..21bbb261 100644 --- a/src/pages/enrollment/steps/ChooseMfaStep/ChooseMfaStep.tsx +++ b/src/pages/enrollment/steps/ChooseMfaStep/ChooseMfaStep.tsx @@ -18,6 +18,7 @@ import { EnrollmentNavDirection } from '../../hooks/types'; export const ChooseMfaStep = () => { const selectedMethod = useEnrollmentStore((s) => s.mfaMethod); + const enrollmentSettings = useEnrollmentStore((s) => s.enrollmentSettings); const [setStore, navSubject] = useEnrollmentStore( (s) => [s.setState, s.nextSubject], shallow, @@ -55,6 +56,7 @@ export const ChooseMfaStep = () => { }} /> { @@ -63,17 +65,19 @@ export const ChooseMfaStep = () => { />
-
+ ); +}; diff --git a/src/pages/enrollment/steps/MfaSetupStep/MfaSetupStep.tsx b/src/pages/enrollment/steps/MfaSetupStep/MfaSetupStep.tsx index 084d07a6..10f826c8 100644 --- a/src/pages/enrollment/steps/MfaSetupStep/MfaSetupStep.tsx +++ b/src/pages/enrollment/steps/MfaSetupStep/MfaSetupStep.tsx @@ -1,27 +1,25 @@ +import './style.scss'; + import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; +import { error } from '@tauri-apps/plugin-log'; +import { type Ref, useEffect, useRef } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; -import QRCode from 'react-qr-code'; import z from 'zod'; import { shallow } from 'zustand/shallow'; import { FormInput } from '../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; import { Card } from '../../../../shared/defguard-ui/components/Layout/Card/Card'; import { LoaderSpinner } from '../../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; +import { useToaster } from '../../../../shared/defguard-ui/hooks/toasts/useToaster'; import { isPresent } from '../../../../shared/defguard-ui/utils/isPresent'; import { MfaMethod } from '../../../../shared/types'; -import { useEnrollmentStore } from '../../hooks/store/useEnrollmentStore'; -import { useEnrollmentApi } from '../../hooks/useEnrollmentApi'; -import './style.scss'; -import { error } from '@tauri-apps/plugin-log'; -import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; -import { MessageBox } from '../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; -import SvgIconCopy from '../../../../shared/defguard-ui/components/svg/IconCopy'; -import { useToaster } from '../../../../shared/defguard-ui/hooks/toasts/useToaster'; -import { useClipboard } from '../../../../shared/hooks/useClipboard'; import { EnrollmentStepIndicator } from '../../components/EnrollmentStepIndicator/EnrollmentStepIndicator'; import { EnrollmentStepKey } from '../../const'; +import { useEnrollmentStore } from '../../hooks/store/useEnrollmentStore'; import { EnrollmentNavDirection } from '../../hooks/types'; +import { useEnrollmentApi } from '../../hooks/useEnrollmentApi'; +import { MfaSetupEmail } from './MfaSetupEmail'; +import { MfaSetupTotp } from './MfaSetupTotp'; const formSchema = z.object({ code: z.string().trim().min(6, 'Enter valid code').max(6, 'Enter valid code'), @@ -30,7 +28,6 @@ const formSchema = z.object({ type FormFields = z.infer; export const MfaSetupStep = () => { - const toaster = useToaster(); const submitRef = useRef(null); const [userInfo, mfaMethod] = useEnrollmentStore((s) => [s.userInfo, s.mfaMethod]); const [nextSubject, setStoreState] = useEnrollmentStore( @@ -39,16 +36,78 @@ export const MfaSetupStep = () => { ); const { - enrollment: { registerCodeMfaFinish, registerCodeMfaStart }, + enrollment: { registerCodeMfaStart }, } = useEnrollmentApi(); - const { data: startData, isLoading: startLoading } = useQuery({ + const { + data: startData, + isLoading: startLoading, + refetch, + } = useQuery({ queryFn: () => registerCodeMfaStart(mfaMethod), queryKey: ['register-mfa', mfaMethod], refetchOnWindowFocus: false, enabled: isPresent(mfaMethod), }); + // biome-ignore lint/correctness/useExhaustiveDependencies: rxjs sub + useEffect(() => { + const sub = nextSubject.subscribe((direction) => { + switch (direction) { + case EnrollmentNavDirection.NEXT: + submitRef.current?.click(); + break; + case EnrollmentNavDirection.BACK: + setStoreState({ step: EnrollmentStepKey.MFA_CHOICE }); + break; + } + }); + return () => { + sub.unsubscribe(); + }; + }, [nextSubject]); + + return ( + +
+ +

Configure MFA

+
+ {startLoading && ( +
+ +
+ )} + {!startLoading && isPresent(userInfo) && ( + <> + {mfaMethod === MfaMethod.TOTP && isPresent(startData?.totp_secret) && ( + + + + )} + {mfaMethod === MfaMethod.EMAIL && ( + + + + )} + + )} +
+ ); +}; + +type CodeFormProps = { + inputRef: Ref; +}; + +const CodeForm = ({ inputRef }: CodeFormProps) => { + const toaster = useToaster(); + const mfaMethod = useEnrollmentStore((s) => s.mfaMethod); + const setStoreState = useEnrollmentStore((s) => s.setState); + const { + enrollment: { registerCodeMfaFinish }, + } = useEnrollmentApi(); + const { handleSubmit, control, setError } = useForm({ resolver: zodResolver(formSchema), }); @@ -79,8 +138,6 @@ export const MfaSetupStep = () => { }, }); - const isLoading = startLoading || isPending; - const submitHandler: SubmitHandler = (data) => { const sendData = { code: data.code, @@ -92,93 +149,17 @@ export const MfaSetupStep = () => { // biome-ignore lint/correctness/useExhaustiveDependencies: sideEffect useEffect(() => { setStoreState({ - loading: startLoading || isPending, - }); - }, [startLoading, isPending]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: rxjs sub - useEffect(() => { - const sub = nextSubject.subscribe((direction) => { - switch (direction) { - case EnrollmentNavDirection.NEXT: - submitRef.current?.click(); - break; - case EnrollmentNavDirection.BACK: - setStoreState({ step: EnrollmentStepKey.MFA_CHOICE }); - break; - } + loading: isPending, }); - return () => { - sub.unsubscribe(); - }; - }, [nextSubject]); - - return ( - -
- -

Configure MFA

-
- {isLoading && ( -
- -
- )} - {!isLoading && isPresent(userInfo) && ( - <> - {mfaMethod === MfaMethod.TOTP && ( - <> - - {isPresent(startData?.totp_secret) && ( - - )} - - )} - {mfaMethod === MfaMethod.EMAIL && ( - -

- To setup your MFA, enter the code that was sent to your account email: -
- {userInfo.email} -

-
- )} -
- - - - - )} -
- ); -}; + }, [isPending]); -type TotpProps = { - email: string; - secret: string; -}; - -const TotpQr = ({ email, secret }: TotpProps) => { - const { writeToClipboard } = useClipboard(); return ( -
-
- -
-
+ + ); }; diff --git a/src/pages/enrollment/steps/MfaSetupStep/MfaSetupTotp.tsx b/src/pages/enrollment/steps/MfaSetupStep/MfaSetupTotp.tsx new file mode 100644 index 00000000..2c4f5073 --- /dev/null +++ b/src/pages/enrollment/steps/MfaSetupStep/MfaSetupTotp.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; +import QRCode from 'react-qr-code'; +import { Button } from '../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { MessageBox } from '../../../../shared/defguard-ui/components/Layout/MessageBox/MessageBox'; +import SvgIconCopy from '../../../../shared/defguard-ui/components/svg/IconCopy'; +import { useClipboard } from '../../../../shared/hooks/useClipboard'; + +type Props = { + children: ReactNode; + email: string; + secret: string; +}; + +export const MfaSetupTotp = ({ children, email, secret }: Props) => { + return ( +
+ + + {children} +
+ ); +}; + +type TotpProps = { + email: string; + secret: string; +}; + +const TotpQr = ({ email, secret }: TotpProps) => { + const { writeToClipboard } = useClipboard(); + return ( +
+
+ +
+
+ ); +}; diff --git a/src/pages/enrollment/steps/MfaSetupStep/style.scss b/src/pages/enrollment/steps/MfaSetupStep/style.scss index 445745fd..ad95f873 100644 --- a/src/pages/enrollment/steps/MfaSetupStep/style.scss +++ b/src/pages/enrollment/steps/MfaSetupStep/style.scss @@ -36,12 +36,30 @@ } } + .btn.resend { + width: 100%; + } + + .totp-setup, + .email-setup { + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + row-gap: var(--spacing-s); + } + + .resend { + width: 100%; + } + form { display: flex; flex-flow: column; align-items: center; justify-content: center; row-gap: var(--spacing-m); + width: 100%; & > .input { width: 100%;