diff --git a/package.json b/package.json index 2a4ca784..dd3d1c7f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "typesafe-i18n": "typesafe-i18n", "generate-translation-types": "typesafe-i18n --no-watch", "fix": "biome check --fix && prettier src/**/*.scss -w --log-level silent", + "fix-unsafe": "biome check --fix --unsafe && prettier src/**/*.scss -w --log-level silent", "lint": "biome lint && pnpm run typecheck && prettier src/**/*.scss --check --log-level error", "lint-ci": "biome ci && pnpm run typecheck && prettier src/**/*.scss --check --log-level error", "vite": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4812a5a9..844fc46e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -67,7 +67,7 @@ tauri-plugin-clipboard-manager = "2" tauri-plugin-deep-link = "2" tauri-plugin-dialog = "2" tauri-plugin-fs = "2" -tauri-plugin-http = "2" +tauri-plugin-http = { version = "2", features = ["unsafe-headers"] } tauri-plugin-log = "2" tauri-plugin-notification = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } diff --git a/src-tauri/proto b/src-tauri/proto index dbb08b75..4688d4d5 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit dbb08b75e1b009c48a73bfc07cb25bea1838243a +Subproject commit 4688d4d587246e09d800c03963721b437f3a3eca diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index 81b50df7..82640aa2 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -490,6 +490,7 @@ If this will not change, please contact your administrator.`, password: 'Create password', vpn: 'Configure VPN', finish: 'Finish', + mfa: 'Configure MFA', }, appVersion: 'Application version', }, diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts index da6d2feb..ec907a19 100644 --- a/src/i18n/i18n-types.ts +++ b/src/i18n/i18n-types.ts @@ -1126,6 +1126,10 @@ type RootTranslation = { * F​i​n​i​s​h */ finish: string + /** + * C​o​n​f​i​g​u​r​e​ ​m​f​a + */ + mfa: string } /** * A​p​p​l​i​c​a​t​i​o​n​ ​v​e​r​s​i​o​n @@ -2790,6 +2794,10 @@ export type TranslationFunctions = { * Finish */ finish: () => LocalizedString + /** + * Configure mfa + */ + mfa: () => LocalizedString } /** * Application version diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx index 7ca1e1b9..cc6a6f15 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx @@ -26,10 +26,7 @@ import { useToaster } from '../../../../../../../../shared/defguard-ui/hooks/toa import { isPresent } from '../../../../../../../../shared/defguard-ui/utils/isPresent'; import { clientApi } from '../../../../../../clientAPI/clientApi'; import { useClientStore } from '../../../../../../hooks/useClientStore'; -import { - type DefguardInstance, - LocationMfaType, -} from '../../../../../../types'; +import { type DefguardInstance, LocationMfaType } from '../../../../../../types'; import { MfaMobileApprove } from './components/MfaMobileApprove/MfaMobileApprove'; import { BrowserErrorIcon, BrowserPendingIcon, GoToBrowserIcon } from './Icons'; import { useMFAModal } from './useMFAModal'; diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/components/MfaMobileApprove/MfaMobileApprove.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/components/MfaMobileApprove/MfaMobileApprove.tsx index 6cc4c115..ac098e12 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/components/MfaMobileApprove/MfaMobileApprove.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/components/MfaMobileApprove/MfaMobileApprove.tsx @@ -47,16 +47,10 @@ export const MfaMobileApprove = ({ [proxyUrl], ); - const { lastMessage, readyState } = useWebSocket(wsUrl, { + const { lastMessage } = useWebSocket(wsUrl, { queryParams: { token, }, - onOpen: () => { - console.log('Websocket connected'); - }, - onClose: () => { - console.log('Websocket closed'); - }, }); const qrString = useMemo(() => { @@ -71,10 +65,6 @@ export const MfaMobileApprove = ({ return fromUint8Array(encoded); }, [token, challenge, instanceUuid]); - useEffect(() => { - console.log(`Last msg: ${lastMessage}\nState ${readyState}`); - }, [lastMessage, readyState]); - // biome-ignore lint/correctness/useExhaustiveDependencies: Side effect useEffect(() => { if (lastMessage != null) { @@ -102,9 +92,90 @@ export const MfaMobileApprove = ({ return (
- + +

+ + {'Go to the mobile app, select this instance and click the Biometry button'} + + + {'in the botom right corner.'} +

+
); }; + +const ExampleButton = () => { + return ( +
+ +
+ ); +}; + +const QrIcon = () => { + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/components/MfaMobileApprove/style.scss b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/components/MfaMobileApprove/style.scss index 4ef30d7b..82ebca66 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/components/MfaMobileApprove/style.scss +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/components/MfaMobileApprove/style.scss @@ -6,4 +6,24 @@ box-sizing: border-box; padding: var(--spacing-m) var(--spacing-s); gap: var(--spacing-m); + + .message-box { + p { + display: inline-flex; + flex-flow: row wrap; + column-gap: 10px; + align-items: center; + } + } + + .example-mobile-button { + border-radius: 15px; + background-color: var(--surface-main-primary); + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + } } diff --git a/src/pages/enrollment/EnrollmentPage.tsx b/src/pages/enrollment/EnrollmentPage.tsx index 397ab51f..a4a94d8c 100644 --- a/src/pages/enrollment/EnrollmentPage.tsx +++ b/src/pages/enrollment/EnrollmentPage.tsx @@ -29,6 +29,8 @@ import { DataVerificationStep } from './steps/DataVerificationStep/DataVerificat import { DeviceStep } from './steps/DeviceStep/DeviceStep'; import { FinishStep } from './steps/FinishStep/FinishStep'; import { PasswordStep } from './steps/PasswordStep/PasswordStep'; +import { SendFinishStep } from './steps/SendFinishStep/SendFinishStep'; +import { TotpEnrollmentStep } from './steps/Totp/TotpEnrollmentStep'; import { WelcomeStep } from './steps/WelcomeStep/WelcomeStep'; export const EnrollmentPage = () => { @@ -51,10 +53,17 @@ export const EnrollmentPage = () => { // ensure number of steps is correct useEffect(() => { - if (stepsMax !== steps.length - 1) { - setEnrollmentState({ stepsMax: steps.length - 1 }); - } - }, [setEnrollmentState, stepsMax]); + const stepsIgnored: number[] = []; + steps.forEach((step, index) => { + if (step.ignoreCount) { + stepsIgnored.push(index); + } + }); + setEnrollmentState({ + stepsIgnored, + stepsMax: steps.length - 1, + }); + }, [setEnrollmentState]); useEffect(() => { if (!enrollmentFinished.current) { @@ -64,7 +73,7 @@ export const EnrollmentPage = () => { if (diff > 0) { const timeout = setTimeout(() => { if (!enrollmentFinished.current) { - debug('Enrollment session time ended, navigatig to timeout page.'); + debug('Enrollment session time ended, navigating to timeout page.'); navigate(routes.timeout, { replace: true }); } }, diff); @@ -72,11 +81,11 @@ export const EnrollmentPage = () => { clearTimeout(timeout); }; } else { - debug('Enrollment session time ended, navigatig to timeout page.'); + debug('Enrollment session time ended, navigating to timeout page.'); navigate(routes.timeout, { replace: true }); } } else { - error('Seesion end time not found, navigating to timeout page.'); + error('Session end time not found, navigating to timeout page.'); navigate(routes.timeout, { replace: true }); } } @@ -139,7 +148,18 @@ const steps: EnrollmentStep[] = [ }, { key: 4, - step: , + step: , + backDisabled: true, + }, + { + key: 5, + step: , + backDisabled: true, + ignoreCount: true, + }, + { + key: 6, + step: , backDisabled: true, }, ]; @@ -147,5 +167,6 @@ const steps: EnrollmentStep[] = [ type EnrollmentStep = { backDisabled?: boolean; key: string | number; + ignoreCount?: boolean; step: ReactNode; }; diff --git a/src/pages/enrollment/components/EnrollmentSideBar/EnrollmentSideBar.tsx b/src/pages/enrollment/components/EnrollmentSideBar/EnrollmentSideBar.tsx index 5f1df308..1dd2deda 100644 --- a/src/pages/enrollment/components/EnrollmentSideBar/EnrollmentSideBar.tsx +++ b/src/pages/enrollment/components/EnrollmentSideBar/EnrollmentSideBar.tsx @@ -2,14 +2,14 @@ import './style.scss'; import { getVersion } from '@tauri-apps/api/app'; import classNames from 'classnames'; +import dayjs from 'dayjs'; import { useEffect, useMemo, useState } from 'react'; -import type { LocalizedString } from 'typesafe-i18n'; - import { useI18nContext } from '../../../../i18n/i18n-react'; import { Divider } from '../../../../shared/defguard-ui/components/Layout/Divider/Divider.tsx'; import { useEnrollmentStore } from '../../hooks/store/useEnrollmentStore'; import { AdminInfo } from '../AdminInfo/AdminInfo'; import { TimeLeft } from '../TimeLeft/TimeLeft'; +import type { EnrollmentSideBarData } from '../types.ts'; export const EnrollmentSideBar = () => { const { LL } = useI18nContext(); @@ -23,15 +23,40 @@ export const EnrollmentSideBar = () => { const [appVersion, setAppVersion] = useState(undefined); - const steps = useMemo((): LocalizedString[] => { + const steps = useMemo((): EnrollmentSideBarData[] => { const steps = LL.pages.enrollment.sideBar.steps; const vpnStep = vpnOptional ? `${steps.vpn()}*` : steps.vpn(); return [ - steps.welcome(), - steps.verification(), - steps.password(), - vpnStep as LocalizedString, - steps.finish(), + { + label: steps.welcome(), + stepDisplayNumber: 1, + stepIndex: 0, + }, + { + label: steps.verification(), + stepDisplayNumber: 2, + stepIndex: 1, + }, + { + label: steps.password(), + stepDisplayNumber: 3, + stepIndex: 2, + }, + { + label: vpnStep, + stepDisplayNumber: 4, + stepIndex: 3, + }, + { + label: `${steps.mfa()}*`, + stepDisplayNumber: 5, + stepIndex: [4, 5], + }, + { + label: steps.finish(), + stepDisplayNumber: 6, + stepIndex: 6, + }, ]; }, [LL.pages.enrollment.sideBar.steps, vpnOptional]); @@ -53,8 +78,11 @@ export const EnrollmentSideBar = () => {
- {steps.map((text, index) => ( - + {steps.map((data) => ( + ))}
{currentStep !== stepsMax && ( @@ -68,9 +96,9 @@ export const EnrollmentSideBar = () => {

- Copyright © 2023{' '} - - teonite + Copyright © {`${dayjs().year}`} + + defguard

@@ -82,14 +110,15 @@ export const EnrollmentSideBar = () => { }; type StepProps = { - text: LocalizedString; - index: number; + data: EnrollmentSideBarData; }; -const Step = ({ index, text }: StepProps) => { +const Step = ({ data: { label, stepIndex, stepDisplayNumber } }: StepProps) => { const currentStep = useEnrollmentStore((state) => state.step); - const active = currentStep === index; + const active = Array.isArray(stepIndex) + ? stepIndex.includes(currentStep) + : stepIndex === currentStep; const cn = classNames('step', { active, @@ -98,8 +127,8 @@ const Step = ({ index, text }: StepProps) => { return (

- {index + 1}.{' '} - {text} + {stepDisplayNumber}.{' '} + {label}

{active &&
}
diff --git a/src/pages/enrollment/components/EnrollmentStepIndicator/EnrollmentStepIndicator.tsx b/src/pages/enrollment/components/EnrollmentStepIndicator/EnrollmentStepIndicator.tsx index 241e72f5..1ca4c669 100644 --- a/src/pages/enrollment/components/EnrollmentStepIndicator/EnrollmentStepIndicator.tsx +++ b/src/pages/enrollment/components/EnrollmentStepIndicator/EnrollmentStepIndicator.tsx @@ -1,24 +1,41 @@ import './style.scss'; -import { shallow } from 'zustand/shallow'; - +import { useMemo } from 'react'; import { useI18nContext } from '../../../../i18n/i18n-react'; import { useEnrollmentStore } from '../../hooks/store/useEnrollmentStore'; +function getDisplayStep( + currentIndex: number, + stepsMax: number, + ignoredSteps: number[], +): number { + const ignored = [...new Set(ignoredSteps)] + .filter((s) => s >= 0 && s < stepsMax) + .sort((a, b) => a - b); + const ignoredBefore = ignored.filter((step) => step <= currentIndex).length; + return currentIndex + 1 - ignoredBefore; +} + export const EnrollmentStepIndicator = () => { const { LL } = useI18nContext(); - const [step, maxStep] = useEnrollmentStore( - (state) => [state.step, state.stepsMax], - shallow, + const [stateStep, maxStep, ignoredSteps] = useEnrollmentStore((state) => [ + state.step, + state.stepsMax, + state.stepsIgnored, + ]); + + const step = useMemo( + () => getDisplayStep(stateStep, maxStep, ignoredSteps), + [ignoredSteps, stateStep, maxStep], ); return (

- {LL.pages.enrollment.stepsIndicator.step()} {step + 1}{' '} + {LL.pages.enrollment.stepsIndicator.step()} {step}{' '} - {LL.pages.enrollment.stepsIndicator.of()} {maxStep + 1} + {LL.pages.enrollment.stepsIndicator.of()} {maxStep + 1 - ignoredSteps.length}

diff --git a/src/pages/enrollment/components/types.ts b/src/pages/enrollment/components/types.ts new file mode 100644 index 00000000..3ee16633 --- /dev/null +++ b/src/pages/enrollment/components/types.ts @@ -0,0 +1,5 @@ +export type EnrollmentSideBarData = { + stepIndex: number | number[]; + label: string; + stepDisplayNumber: number; +}; diff --git a/src/pages/enrollment/hooks/store/useEnrollmentStore.tsx b/src/pages/enrollment/hooks/store/useEnrollmentStore.tsx index 9c5f49f3..05f0b4eb 100644 --- a/src/pages/enrollment/hooks/store/useEnrollmentStore.tsx +++ b/src/pages/enrollment/hooks/store/useEnrollmentStore.tsx @@ -2,15 +2,19 @@ import { pick } from 'lodash-es'; import { Subject } from 'rxjs'; import { createJSONStorage, persist } from 'zustand/middleware'; import { createWithEqualityFn } from 'zustand/traditional'; - -import type { AdminInfo, UserInfo } from '../../../../shared/hooks/api/types'; +import type { + AdminInfo, + CreateDeviceResponse, + UserInfo, +} from '../../../../shared/hooks/api/types'; const defaultValues: StoreValues = { // assume default dev proxy_url: '/api/v1/', loading: false, step: 0, - stepsMax: 4, + stepsMax: 6, + stepsIgnored: [], sessionStart: undefined, sessionEnd: undefined, userInfo: undefined, @@ -19,10 +23,14 @@ const defaultValues: StoreValues = { userPassword: undefined, cookie: undefined, nextSubject: new Subject(), + deviceKeys: undefined, + deviceResponse: undefined, }; const persistKeys: Array = [ 'step', + 'stepsMax', + 'stepsIgnored', 'userInfo', 'userPassword', 'sessionEnd', @@ -31,6 +39,8 @@ const persistKeys: Array = [ 'deviceName', 'endContent', 'vpnOptional', + 'deviceKeys', + 'deviceResponse', ]; export const useEnrollmentStore = createWithEqualityFn()( @@ -73,6 +83,7 @@ type StoreValues = { loading: boolean; step: number; stepsMax: number; + stepsIgnored: number[]; nextSubject: Subject; // Date proxy_url: string; @@ -86,6 +97,11 @@ type StoreValues = { endContent?: string; deviceName?: string; cookie?: string; + deviceKeys?: { + public: string; + private: string; + }; + deviceResponse?: CreateDeviceResponse; }; type StoreMethods = { diff --git a/src/pages/enrollment/hooks/useEnrollmentApi.tsx b/src/pages/enrollment/hooks/useEnrollmentApi.tsx index dece7d54..913304c6 100644 --- a/src/pages/enrollment/hooks/useEnrollmentApi.tsx +++ b/src/pages/enrollment/hooks/useEnrollmentApi.tsx @@ -2,6 +2,7 @@ import { fetch } from '@tauri-apps/plugin-http'; import { useEnrollmentStore } from '../../../pages/enrollment/hooks/store/useEnrollmentStore'; import type { UseApi } from '../../../shared/hooks/api/types'; +import { MfaMethod } from '../../../shared/types'; export const useEnrollmentApi = (): UseApi => { const [proxyUrl, cookie] = useEnrollmentStore((state) => [ @@ -9,6 +10,36 @@ export const useEnrollmentApi = (): UseApi => { state.cookie, ]); + const registerCodeMfaStart: UseApi['enrollment']['registerCodeMfaStart'] = async ( + method, + ) => { + const response = await fetch(`${proxyUrl}/enrollment/register-mfa/code/start`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: cookie, + } as Record, + body: JSON.stringify({ + method: method.valueOf(), + }), + }); + return await response.json(); + }; + + const registerCodeMfaFinish: UseApi['enrollment']['registerCodeMfaFinish'] = async ( + data, + ) => { + const response = await fetch(`${proxyUrl}/enrollment/register-mfa/code/finish`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: cookie, + } as Record, + body: JSON.stringify({ ...data, method: MfaMethod.TOTP.valueOf() }), + }); + return await response.json(); + }; + const start: UseApi['enrollment']['start'] = async (data) => { const response = await fetch(`${proxyUrl}/enrollment/start`, { method: 'POST', @@ -64,6 +95,8 @@ export const useEnrollmentApi = (): UseApi => { start, activateUser, createDevice, + registerCodeMfaStart, + registerCodeMfaFinish, }, getAppInfo, }; diff --git a/src/pages/enrollment/steps/DeviceStep/components/DesktopSetup/DesktopSetup.tsx b/src/pages/enrollment/steps/DeviceStep/components/DesktopSetup/DesktopSetup.tsx index 783debc0..fc0e0636 100644 --- a/src/pages/enrollment/steps/DeviceStep/components/DesktopSetup/DesktopSetup.tsx +++ b/src/pages/enrollment/steps/DeviceStep/components/DesktopSetup/DesktopSetup.tsx @@ -1,8 +1,8 @@ import './style.scss'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { debug, error, info } from '@tauri-apps/plugin-log'; +import { useMutation } from '@tanstack/react-query'; +import { error } from '@tauri-apps/plugin-log'; import { isUndefined } from 'lodash-es'; import { useMemo, useState } from 'react'; import { type SubmitHandler, useForm } from 'react-hook-form'; @@ -19,24 +19,20 @@ import { Card } from '../../../../../../shared/defguard-ui/components/Layout/Car import { useToaster } from '../../../../../../shared/defguard-ui/hooks/toasts/useToaster'; import type { CreateDeviceResponse } from '../../../../../../shared/hooks/api/types'; import { generateWGKeys } from '../../../../../../shared/utils/generateWGKeys'; -import { clientApi } from '../../../../../client/clientAPI/clientApi'; -import { clientQueryKeys } from '../../../../../client/query'; +import { EnrollmentStepIndicator } from '../../../../components/EnrollmentStepIndicator/EnrollmentStepIndicator'; import { useEnrollmentStore } from '../../../../hooks/store/useEnrollmentStore'; import { useEnrollmentApi } from '../../../../hooks/useEnrollmentApi'; -const { saveConfig } = clientApi; - type FormFields = { name: string; }; export const DesktopSetup = () => { - const queryClient = useQueryClient(); const { LL } = useI18nContext(); const toaster = useToaster(); const stepLL = LL.pages.enrollment.steps.deviceSetup; const { - enrollment: { createDevice, activateUser }, + enrollment: { createDevice }, } = useEnrollmentApi(); const deviceName = useEnrollmentStore((state) => state.deviceName); const [userInfo, userPassword] = useEnrollmentStore((state) => [ @@ -47,21 +43,6 @@ export const DesktopSetup = () => { const next = useEnrollmentStore((state) => state.nextStep); const [isLoading, setIsLoading] = useState(false); - const { mutateAsync: mutateUserActivation, isPending: activationPending } = useMutation( - { - mutationFn: activateUser, - onError: (e) => { - toaster.error( - LL.common.messages.errorWithMessage({ - message: String(e), - }), - ); - console.error(e); - error(String(e)); - }, - }, - ); - const { mutateAsync: createDeviceMutation, isPending: createDevicePending } = useMutation({ mutationFn: createDevice, @@ -89,7 +70,7 @@ export const DesktopSetup = () => { const handleValidSubmit: SubmitHandler = async (values) => { if (!userInfo || !userPassword) return; const { publicKey, privateKey } = generateWGKeys(); - const deviceResponse = await createDeviceMutation({ + const deviceResponse = (await createDeviceMutation({ name: values.name, pubkey: publicKey, }).then(async (res) => { @@ -100,63 +81,24 @@ export const DesktopSetup = () => { )} Error status code: ${res.status} `, ); } - return res; - }); - mutateUserActivation({ - password: userPassword, - phone_number: userInfo.phone_number, - }).then(async (res) => { - if (!res.ok) { - error( - `Failed to activate user during the enrollment.Error details: ${JSON.stringify( - await res.json(), - )} Error status code: ${res.status} `, - ); - throw Error('Failed to activate user'); - } - info('User activated'); - setIsLoading(true); - debug('Invoking save_device_config'); - const response = (await deviceResponse.json()) as CreateDeviceResponse; - saveConfig({ - privateKey, - response, - }) - .then(() => { - debug('Config saved'); - setIsLoading(false); - setEnrollmentStore({ deviceName: values.name }); - toaster.success(stepLL.desktopSetup.messages.deviceConfigured()); - const invalidate = [clientQueryKeys.getInstances, clientQueryKeys.getLocations]; - invalidate.forEach((key) => { - queryClient.invalidateQueries({ - queryKey: [key], - }); - }); - next(); - }) - .catch((e) => { - setIsLoading(false); - - if (typeof e === 'string') { - if (e.includes('Network Error')) { - toaster.error(LL.common.messages.networkError()); - return; - } - toaster.error(LL.common.messages.errorWithMessage({ message: String(e) })); - } else { - toaster.error( - LL.common.messages.errorWithMessage({ - message: String(e), - }), - ); - } - }); + return await res.json(); + })) as CreateDeviceResponse; + toaster.success(stepLL.desktopSetup.messages.deviceConfigured()); + setEnrollmentStore({ + deviceName: values.name, + deviceKeys: { + private: privateKey, + public: publicKey, + }, + deviceResponse, }); + setIsLoading(false); + next(); }; return ( +

{stepLL.desktopSetup.title()}

{ : stepLL.desktopSetup.controls.create() } disabled={!isUndefined(deviceName)} - loading={isLoading || activationPending || createDevicePending} + loading={isLoading || createDevicePending} />
diff --git a/src/pages/enrollment/steps/PasswordStep/PasswordStep.tsx b/src/pages/enrollment/steps/PasswordStep/PasswordStep.tsx index 2c330d10..1fb5b751 100644 --- a/src/pages/enrollment/steps/PasswordStep/PasswordStep.tsx +++ b/src/pages/enrollment/steps/PasswordStep/PasswordStep.tsx @@ -38,9 +38,14 @@ export const PasswordStep = () => { password: passwordValidator(LL), repeat: z.string().min(1, LL.form.errors.required()), }) - .refine((values) => values.password === values.repeat, { - message: pageLL.form.fields.repeat.errors.matching(), - path: ['repeat'], + .superRefine((values, ctx) => { + if (values.password !== values.repeat && values.repeat.length >= 1) { + ctx.addIssue({ + path: ['repeat'], + message: pageLL.form.fields.repeat.errors.matching(), + code: 'custom', + }); + } }), [LL, pageLL.form.fields.repeat.errors], ); @@ -85,7 +90,13 @@ export const PasswordStep = () => { > { /> diff --git a/src/pages/enrollment/steps/SendFinishStep/SendFinishStep.tsx b/src/pages/enrollment/steps/SendFinishStep/SendFinishStep.tsx new file mode 100644 index 00000000..dfd67530 --- /dev/null +++ b/src/pages/enrollment/steps/SendFinishStep/SendFinishStep.tsx @@ -0,0 +1,131 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { debug, error, info } from '@tauri-apps/plugin-log'; +import { useCallback } from 'react'; +import { shallow } from 'zustand/shallow'; +import { useI18nContext } from '../../../../i18n/i18n-react'; +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 type { + ActivateUserRequest, + CreateDeviceResponse, +} from '../../../../shared/hooks/api/types'; +import { clientApi } from '../../../client/clientAPI/clientApi'; +import { clientQueryKeys } from '../../../client/query'; +import { useEnrollmentStore } from '../../hooks/store/useEnrollmentStore'; +import { useEnrollmentApi } from '../../hooks/useEnrollmentApi'; +import './style.scss'; +import useEffectOnce from '../../../../shared/defguard-ui/utils/useEffectOnce'; + +const { saveConfig } = clientApi; + +export const SendFinishStep = () => { + const { LL } = useI18nContext(); + const toaster = useToaster(); + const { + enrollment: { activateUser }, + } = useEnrollmentApi(); + const queryClient = useQueryClient(); + + const finishData = useEnrollmentStore((s) => ({ + phone_number: s.userInfo?.phone_number as string, + password: s.userPassword as string, + })); + + const deviceKeys = useEnrollmentStore((s) => s.deviceKeys); + const deviceResponse = useEnrollmentStore((s) => s.deviceResponse); + + const [setEnrollmentStore, next] = useEnrollmentStore( + (state) => [state.setState, state.nextStep], + shallow, + ); + + const queryFn = useCallback( + async ( + finishData: ActivateUserRequest, + deviceResponse: CreateDeviceResponse, + privateKey: string, + ) => { + await activateUser(finishData); + info('User activated'); + debug('Invoking save_device_config'); + saveConfig({ + privateKey, + response: deviceResponse, + }) + .then(() => { + debug('Config saved'); + setEnrollmentStore({ deviceName: deviceResponse.device.name }); + const invalidate = [clientQueryKeys.getInstances, clientQueryKeys.getLocations]; + invalidate.forEach((key) => { + queryClient.invalidateQueries({ + queryKey: [key], + }); + }); + next(); + }) + .catch((e) => { + if (typeof e === 'string') { + if (e.includes('Network Error')) { + toaster.error(LL.common.messages.networkError()); + return; + } + toaster.error(LL.common.messages.errorWithMessage({ message: String(e) })); + } else { + toaster.error( + LL.common.messages.errorWithMessage({ + message: String(e), + }), + ); + } + }); + }, + [ + LL.common.messages.errorWithMessage, + LL.common.messages.networkError, + activateUser, + next, + queryClient.invalidateQueries, + setEnrollmentStore, + toaster.error, + ], + ); + + const { mutate } = useMutation({ + mutationFn: () => + queryFn( + finishData, + deviceResponse as CreateDeviceResponse, + deviceKeys?.private as string, + ), + onError: (e) => { + setEnrollmentStore({ loading: false }); + toaster.error( + LL.common.messages.errorWithMessage({ + message: String(e), + }), + ); + console.error(e); + error(String(e)); + }, + onSuccess: () => { + setEnrollmentStore({ loading: false }); + next(); + }, + }); + + useEffectOnce(() => { + setEnrollmentStore({ + loading: true, + }); + setTimeout(() => { + mutate(); + }, 250); + }); + + return ( + + + + ); +}; diff --git a/src/pages/enrollment/steps/SendFinishStep/style.scss b/src/pages/enrollment/steps/SendFinishStep/style.scss new file mode 100644 index 00000000..ff74a618 --- /dev/null +++ b/src/pages/enrollment/steps/SendFinishStep/style.scss @@ -0,0 +1,9 @@ +#enrollment-finish-request-step { + width: 100%; + max-width: 650px; + min-height: 25dvh; + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; +} diff --git a/src/pages/enrollment/steps/Totp/TotpEnrollmentStep.tsx b/src/pages/enrollment/steps/Totp/TotpEnrollmentStep.tsx new file mode 100644 index 00000000..21f881ff --- /dev/null +++ b/src/pages/enrollment/steps/Totp/TotpEnrollmentStep.tsx @@ -0,0 +1,144 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { 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 { 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'; + +const formSchema = z.object({ + code: z.string().min(6, 'Enter valid code').max(6, 'Enter valid code'), +}); + +type FormFields = z.infer; + +export const TotpEnrollmentStep = () => { + const toaster = useToaster(); + const submitRef = useRef(null); + const { writeToClipboard } = useClipboard(); + const userInfo = useEnrollmentStore((s) => s.userInfo); + const [nextSubject, setStoreState, nextStep] = useEnrollmentStore( + (s) => [s.nextSubject, s.setState, s.nextStep], + shallow, + ); + + const { + enrollment: { registerCodeMfaFinish, registerCodeMfaStart }, + } = useEnrollmentApi(); + + const { data: startData, isLoading: startLoading } = useQuery({ + queryFn: () => registerCodeMfaStart(MfaMethod.TOTP), + queryKey: ['enrollment', 'register-mfa', 'start'], + refetchOnWindowFocus: false, + }); + + const { handleSubmit, control, setError } = useForm({ + resolver: zodResolver(formSchema), + }); + + const { mutate, isPending } = useMutation({ + mutationFn: registerCodeMfaFinish, + onSuccess: () => { + toaster.success('MFA configured'); + setStoreState({ loading: false }); + nextStep(); + }, + onError: (err) => { + setError( + 'code', + { + message: 'Enter valid code', + type: 'value', + }, + { + shouldFocus: true, + }, + ); + error(`MFA configuration failed! \nReason: ${err.message}`); + console.error(err); + }, + }); + + const isLoading = startLoading || isPending; + + const submitHandler: SubmitHandler = (data) => { + mutate(data); + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: sideEffect + useEffect(() => { + setStoreState({ + loading: startLoading || isPending, + }); + }, [startLoading, isPending]); + + useEffect(() => { + const sub = nextSubject.subscribe(() => { + submitRef.current?.click(); + }); + return () => { + sub.unsubscribe(); + }; + }, [nextSubject]); + + return ( + + {isLoading && ( +
+ +
+ )} + {!isLoading && ( + <> +
+ +

Configure MFA

+
+ +
+ {!isLoading && + isPresent(startData) && + isPresent(startData.totp_secret) && + isPresent(userInfo) && ( + <> +
+ +
+