From 5eeaf88244b9ed79fcb578c062df62195821b9a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20=C5=9Al=C4=99zak?= Date: Mon, 14 Sep 2026 13:31:05 +0200 Subject: [PATCH 1/8] post enrollment configure factors --- .../full/AddInstancePage/AddInstancePage.tsx | 2 +- .../src/pages/full/AddInstancePage/style.scss | 9 - new-ui/src/pages/full/AddPage/AddPage.tsx | 31 + .../AddPage/components/AddCard/AddCard.tsx | 32 +- .../components/AddCard/assets/image_4.png | Bin 0 -> 22582 bytes .../AddPage/components/AddCard/style.scss | 17 +- .../AddPage/hooks/useStartMfaConfiguration.ts | 41 ++ .../full/AddTunnelPage/AddTunnelPage.tsx | 2 +- .../src/pages/full/AddTunnelPage/styles.scss | 4 - .../ConfigureMfaPage/ConfigureMfaPage.tsx | 33 + .../ConfigureMfaTimeoutProvider.tsx | 34 + .../hooks/useConfigureMfaStore.tsx | 196 ++++++ .../hooks/useMfaConfigErrorHandler.ts | 55 ++ .../src/pages/full/ConfigureMfaPage/types.ts | 69 ++ .../verify/ConfigureMfaVerify.tsx | 43 ++ .../ConfigureSelectMethodsStep.tsx | 135 ++++ .../ConfigureSelectMethodsStep/style.scss | 22 + .../ConfigureVerifyEmailStep.tsx | 144 ++++ .../ConfigureVerifyTotpStep.tsx | 125 ++++ .../full/ConfigureMfaPage/verify/style.scss | 26 + .../ConfigureFactorStep.tsx | 203 ++++++ .../wizard/ConfigureFactorStep/style.scss | 39 ++ .../ConfigureFido2Step/ConfigureFido2Step.tsx | 162 +++++ .../wizard/ConfigureFido2Step/style.scss | 10 + .../ConfigureFinishStep.tsx | 42 ++ .../ConfigureFinishStep/assets/banner.png | Bin 0 -> 106456 bytes .../wizard/ConfigureFinishStep/style.scss | 18 + .../wizard/ConfigureMfaWizard.tsx | 75 +++ .../ConfigureRecoveryCodesStep.tsx | 86 +++ .../ConfigureRecoveryCodesStep/style.scss | 38 ++ .../MfaConfigurationStep.tsx | 13 +- .../LocationDetailsPage.tsx | 2 +- .../pages/full/LocationDetailsPage/style.scss | 1 - .../ConnectModalMfaEmail.tsx | 11 +- .../ConnectModalMfaFido2.tsx | 36 +- .../ConnectModalMfaTotp.tsx | 11 +- .../components/OverviewSelection/style.scss | 4 + .../SelectMfaInstancePage.tsx | 82 +++ .../full/SelectMfaInstancePage/style.scss | 45 ++ new-ui/src/routeTree.gen.ts | 42 ++ new-ui/src/routes/full/_default/add/mfa.tsx | 17 + new-ui/src/routes/full/configure-mfa.tsx | 18 + .../src/shared/components/Button/style.scss | 13 +- .../shared/components/CodeInput/CodeInput.tsx | 14 + .../LocationCard/hooks/useMfaFido2Connect.ts | 20 +- .../LocationCardMfaEmailView.tsx | 11 +- .../LocationCardMfaFido2View.tsx | 38 +- .../LocationCardMfaTotpView.tsx | 11 +- .../components/wizard/WizardPage/style.scss | 8 +- .../src/shared/layouts/FullPage/FullPage.tsx | 11 +- new-ui/src/shared/layouts/FullPage/style.scss | 16 + new-ui/src/shared/rust-api/api.ts | 62 +- new-ui/src/shared/rust-api/fido2.ts | 8 + new-ui/src/shared/rust-api/mfaError.ts | 25 + new-ui/src/shared/rust-api/query.ts | 8 + new-ui/src/shared/rust-api/types.ts | 35 +- new-ui/src/shared/scss/_shared_tokens.scss | 6 +- .../src/shared/scss/global/_surface-row.scss | 21 + new-ui/src/shared/scss/global/index.scss | 1 + ...ae9515952d077106d303f398167caf68a8a70.json | 20 - ...7f1f5ac113740d3e2b420eeaad3473f8c678.json} | 10 +- ...50f9f86ce7a8140d667433e402f0a62d096f.json} | 6 +- ...1423f9534478fc23752a4e219bbff9cbec99.json} | 6 +- ...2b8378f7e414be257ec0a2614808b4daaec9.json} | 10 +- ...bedcb246c3741ff77d33e7fb04452e15e633.json} | 10 +- ...bbfa5d0ec541b947f44a1c6bf5a49e50bdb3.json} | 10 +- src-tauri/Cargo.lock | 19 +- src-tauri/Cargo.toml | 4 +- src-tauri/cli/src/bin/dg.rs | 2 +- src-tauri/client-cli/src/commands/instance.rs | 1 + src-tauri/client-cli/src/commands/list.rs | 1 + src-tauri/client-cli/src/resolve.rs | 1 + src-tauri/client-cli/src/tests_daemon.rs | 1 + src-tauri/client-proto/build.rs | 21 + .../core/src/database/models/connection.rs | 1 + .../core/src/database/models/instance.rs | 137 +++- .../core/src/database/models/location.rs | 18 +- .../src/database/models/location_stats.rs | 1 + src-tauri/core/src/events.rs | 2 + src-tauri/core/src/lib.rs | 1 + src-tauri/core/src/mfa_config.rs | 280 ++++++++ src-tauri/core/src/mfa_config/tests.rs | 530 +++++++++++++++ src-tauri/core/src/proxy.rs | 15 + .../enterprise/config-sync/src/commands.rs | 8 +- src-tauri/enterprise/config-sync/src/lib.rs | 2 + src-tauri/fido2/Cargo.toml | 37 ++ src-tauri/fido2/src/hid.rs | 225 +++++++ src-tauri/fido2/src/lib.rs | 149 +++++ src-tauri/fido2/src/protocol.rs | 384 +++++++++++ src-tauri/fido2/src/protocol/tests.rs | 217 ++++++ src-tauri/fido2/src/windows/api.rs | 156 +++++ src-tauri/fido2/src/windows/convert.rs | 245 +++++++ src-tauri/fido2/src/windows/mod.rs | 627 ++++++++++++++++++ ...00_add_instance_mfa_configured_methods.sql | 3 + src-tauri/permissions/default.toml | 7 + src-tauri/proto | 2 +- src-tauri/src/appstate.rs | 6 + src-tauri/src/commands.rs | 596 ++++++++++++----- src-tauri/src/fido2_window.rs | 67 ++ src-tauri/src/gui.rs | 7 + src-tauri/src/lib.rs | 1 + 101 files changed, 5808 insertions(+), 321 deletions(-) create mode 100644 new-ui/src/pages/full/AddPage/components/AddCard/assets/image_4.png create mode 100644 new-ui/src/pages/full/AddPage/hooks/useStartMfaConfiguration.ts create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/ConfigureMfaPage.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaTimeoutProvider.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/types.ts create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyTotpStep/ConfigureVerifyTotpStep.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/verify/style.scss create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFactorStep/ConfigureFactorStep.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFactorStep/style.scss create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFido2Step/ConfigureFido2Step.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFido2Step/style.scss create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFinishStep/ConfigureFinishStep.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFinishStep/assets/banner.png create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureFinishStep/style.scss create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureMfaWizard.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureRecoveryCodesStep/ConfigureRecoveryCodesStep.tsx create mode 100644 new-ui/src/pages/full/ConfigureMfaPage/wizard/ConfigureRecoveryCodesStep/style.scss create mode 100644 new-ui/src/pages/full/SelectMfaInstancePage/SelectMfaInstancePage.tsx create mode 100644 new-ui/src/pages/full/SelectMfaInstancePage/style.scss create mode 100644 new-ui/src/routes/full/_default/add/mfa.tsx create mode 100644 new-ui/src/routes/full/configure-mfa.tsx create mode 100644 new-ui/src/shared/layouts/FullPage/style.scss create mode 100644 new-ui/src/shared/rust-api/fido2.ts create mode 100644 new-ui/src/shared/scss/global/_surface-row.scss delete mode 100644 src-tauri/.sqlx/query-2d6a90bf82aa92118b36d90bf16ae9515952d077106d303f398167caf68a8a70.json rename src-tauri/.sqlx/{query-c598f6e45f80389d4ceaf24a1d2fc854f048ef3679e8d07964cd7acefd8709d0.json => query-43bfb3fcdf996a7009ee23bd0a0e7f1f5ac113740d3e2b420eeaad3473f8c678.json} (81%) rename src-tauri/.sqlx/{query-8d99d5b737d20d54afb6e0708d7d1747a306bcd437b9adbad38b3627910a6185.json => query-4bad70df5cb3d65e0f10c5125c8650f9f86ce7a8140d667433e402f0a62d096f.json} (65%) rename src-tauri/.sqlx/{query-d72c4c19cf9ed8247137d760a64c2c182ff2958180096fc0c85425fe6279138d.json => query-582ac3590e4ce9c580b1849a95101423f9534478fc23752a4e219bbff9cbec99.json} (64%) rename src-tauri/.sqlx/{query-76adc350233d50db611cc827bd0f7537a9ed4111c1401021fe3813cb3cafc6ee.json => query-714e5a3371be2563aa5c77071c222b8378f7e414be257ec0a2614808b4daaec9.json} (81%) rename src-tauri/.sqlx/{query-eaac354b13bef778b251addee9a8b91fce670a426d535f10de9f308a7db95484.json => query-7387f9763fa13b500aed26f00f18bedcb246c3741ff77d33e7fb04452e15e633.json} (81%) rename src-tauri/.sqlx/{query-fb2ca29fd032be5e176379bd4da7fbab71aaa4c42fe8d8cde418708077622408.json => query-881b9e283868c50ba854bc884283bbfa5d0ec541b947f44a1c6bf5a49e50bdb3.json} (81%) create mode 100644 src-tauri/core/src/mfa_config.rs create mode 100644 src-tauri/core/src/mfa_config/tests.rs create mode 100644 src-tauri/fido2/Cargo.toml create mode 100644 src-tauri/fido2/src/hid.rs create mode 100644 src-tauri/fido2/src/lib.rs create mode 100644 src-tauri/fido2/src/protocol.rs create mode 100644 src-tauri/fido2/src/protocol/tests.rs create mode 100644 src-tauri/fido2/src/windows/api.rs create mode 100644 src-tauri/fido2/src/windows/convert.rs create mode 100644 src-tauri/fido2/src/windows/mod.rs create mode 100644 src-tauri/migrations/20260917120000_add_instance_mfa_configured_methods.sql create mode 100644 src-tauri/src/fido2_window.rs diff --git a/new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx b/new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx index b63cd1ecd..eadd6f505 100644 --- a/new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx +++ b/new-ui/src/pages/full/AddInstancePage/AddInstancePage.tsx @@ -101,7 +101,7 @@ export const AddInstancePage = () => { }); return ( - +

{`To add an instance, provide the instance URL along with a valid provisioning token. These credentials are issued by your administrator and are required to initiate the setup.`}

.scroll-container { - display: flex; - flex-flow: column; - } - .page-description { font: var(--t-body-sm-400); color: var(--fg-white-70); @@ -13,8 +8,4 @@ form { display: contents; } - - .controls { - margin-top: auto; - } } diff --git a/new-ui/src/pages/full/AddPage/AddPage.tsx b/new-ui/src/pages/full/AddPage/AddPage.tsx index e7e0baf00..4993005ea 100644 --- a/new-ui/src/pages/full/AddPage/AddPage.tsx +++ b/new-ui/src/pages/full/AddPage/AddPage.tsx @@ -1,18 +1,30 @@ import './style.scss'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; +import { useMemo } from 'react'; import { FullPageTitle } from '../../../shared/components/FullPageTitle/FullPageTitle'; import { FullPage } from '../../../shared/layouts/FullPage/FullPage'; import { getInstancesQueryOptions, + mfaConfigurableInstances, tunnelsDisabled, } from '../../../shared/rust-api/query'; import { ThemeSpacing } from '../../../shared/types'; import { AddCard } from './components/AddCard/AddCard'; +import { useStartMfaConfiguration } from './hooks/useStartMfaConfiguration'; export const AddPage = () => { const navigate = useNavigate(); const { data: instances } = useQuery(getInstancesQueryOptions); + + const mfaInstances = useMemo( + () => mfaConfigurableInstances(instances ?? []), + [instances], + ); + + const { mutate: startConfigureMfa, isPending: configureMfaPending } = + useStartMfaConfiguration(); + return ( @@ -41,6 +53,25 @@ export const AddPage = () => { description={`Add and configure a WireGuard tunnel to securely route traffic through an encrypted connection using predefined configuration.`} /> )} + {mfaInstances.length > 0 && ( + { + // Below two instances there is nothing to pick, so the flow starts here. + if (mfaInstances.length > 1) { + navigate({ + to: '/full/add/mfa', + }); + return; + } + startConfigureMfa(mfaInstances[0]); + }} + loading={configureMfaPending} + /> + )} ); diff --git a/new-ui/src/pages/full/AddPage/components/AddCard/AddCard.tsx b/new-ui/src/pages/full/AddPage/components/AddCard/AddCard.tsx index 9db24bd33..123752a31 100644 --- a/new-ui/src/pages/full/AddPage/components/AddCard/AddCard.tsx +++ b/new-ui/src/pages/full/AddPage/components/AddCard/AddCard.tsx @@ -1,19 +1,29 @@ import './style.scss'; import clsx from 'clsx'; import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonSize, ButtonVariant } from '../../../../../shared/components/Button/types'; import defaultImage from './assets/image_1.png'; import yubiImage from './assets/image_2.png'; import wireguardImage from './assets/image_3.png'; +import lockImage from './assets/image_4.png'; interface Props { actionText: string; title: string; description: string; - image: 'default' | 'yubi' | 'wireguard'; + image: 'default' | 'yubi' | 'wireguard' | 'lock'; + loading?: boolean; onClick?: () => void; } -export const AddCard = ({ actionText, description, title, image, onClick }: Props) => { +export const AddCard = ({ + actionText, + description, + title, + image, + loading = false, + onClick, +}: Props) => { const renderImage = () => { switch (image) { case 'default': @@ -46,6 +56,16 @@ export const AddCard = ({ actionText, description, title, image, onClick }: Prop loading="eager" /> ); + case 'lock': + return ( + + ); } }; @@ -56,7 +76,13 @@ export const AddCard = ({ actionText, description, title, image, onClick }: Prop

{title}

{description}

-
diff --git a/new-ui/src/pages/full/AddPage/components/AddCard/assets/image_4.png b/new-ui/src/pages/full/AddPage/components/AddCard/assets/image_4.png new file mode 100644 index 0000000000000000000000000000000000000000..a6cceae01894474d277c311406a949b23f6d901e GIT binary patch literal 22582 zcmYIvRajeH*Ddap;8NUzySq~?xJ!ZJ9-LArMFX_BTXA>S;!wO0q&TF-o#J}(e*eX} z$dhF6C3E?hW6Tw;1yaVvqQF8xK)_Xbr=Wv?fT#igv}2&bpY+$YxWT_LJ>D66As~=2 z{r5ps(P2D?KScD>QIo?Ywvyy8ROUz5KbH8>@ZfcI#T^pYKn=!K)%Gr zRw#Mi)rCtXx6&=N7*xeD2K{Z6%D8J=TE%6Zcc~R+W0Km!+Vv(hWpRQDds0eI-$0fA z9T>F%1reY|JJ7TF8t@4*YVgCw`ANs)Ek7~?f_3!p^V#MLd8n0+U2uObL?>v2;6`y! zhzoPD9Lvh^;&UrNf2M5-=bkRSf#bE!ZuN4pK5eS>Ign22jAKKyF+g)2f=<4Y$*i zb<3mV15|hpR_P~?*Us)QMwJ=1uC`1*4Ohr8(SXapi6Z;HHOw?eE3lW-mzmyY?sRwT z$cryEW1JJ-sW>Y9$G{o(DF<9$u< z((lrKNP(s4I1C*QDn}1<6d@VkjjyRG59vk_Eeab+WHQt{@F8yxaUNk`o(g^ffhShR zD}VA?T!fdHnFd6nEH>Ig{AfP;259p{L@Fj@PClV`8FCIjDKx{(I9)r z9Ze@0z#*@ljUesK)m~eQP(meCc>064?wI(nwEBU@-57Ml3y@J>imTxT=sxEN9bLG1 zA;6p`A`G4i(+u3@eI89TQFTWNR@-S@%!@5V*d9W+Oz-&(w~q=u%&Cu=$aS6Oxb3q% zcIYyX=jL`A`?h6xLV}|h0iNQAO-ncnW~t^BOBEq!mcOI{jf4Uuv}QWAuW*dA-&9U- zbDj7|GjCVSO%$VEY>z9?u;&y(@u{3LVPLFbI%lwOjOEFMMqhYlCDKyt&o>{bjxHR( zAY;4Vn}2ZvrQn@8geUdelm`d-I<13~E{4!3x9ojSIo~m0`g&AzX!>7G7SAve>F^#N z$rREbL%HUT*4h`m3$bgftkK{%)>vvp#@xQ^K1}bHI_g-YDNMPpF;_0qN0$y>OmC?;#7BDZunX>E=eMkox&~OZc?7d?DdA@$MXw?@Ey(R_g-ksP`9UGBjG7`!dYH4uCm2qGv9P!`W+Xc~tP6NaK)dr31rDj&4TJkVBtz-oEaR zlVqSKWVy+HA&6w!WuPzi}I>QR#z+XepVFbcZUT6f1TqvLAuI8;&L$wWHxavi+;j8YU5C@v*= zqHa8X&KCEu@x!TwGCPZ?h>+0f*dpljt9QV=oQDhO157Ri5ok~pa4_`tvv$DC_2!cjk4fKYKxF+}CUwI)tP#@oR{ITq zPUxu^f!Q4bWVJPKVxXffy%t*uTO=?Ke=E&2vXL*7x=-VMhu`~-Ze!F26UJ_{e(*%*LOe^oAW57ALUxY%vBDuC^DKrOnyAk+jg^ zoqN+KFfXCQG`fKpLM+jK(} zb-jL%e?_ym*A$8%Wanckio0XZ3$uc-=V)u9lW2VF52ooiu(K&gnLVmgnebAu!G$mn z5einMkny>KPoCn_YcZI$;Z=-~lzhix)}a;qS*>XRf>dwg*!mL43m|R)V7ee2ChXYK zQcIr*PM21CYa~%v=>2~Q>6B=5nwj|6 z^L>+H+4VyDUr7cfl~6{15W?-%bs2n$wzNh+@@Xu8vVbxsZ0D0rIU~ihDKKVcSnlj$Y4pYxNFJ z5Zm4@Z8blh2^p( ze2nC)9V20qurNhkek~`xD}55ju)nHlSX+iP5-#a<-!wH@g?nu3v#CirvO2X_CUPtb zr770gjB3>6y&V_0e!LMT0Qwkc86U)w@IwL}UNhevw}2N9^~q$1dqi zjZXNX5R$t%Iz`fb4Jcp@WZVXv_40M^O{wKd8hCiQQm`P}BtqjOQ>&wRW`96^;m~9WFMSEPI>I zJH@6uZ=8ks4_t0+}JlI;4P zbU=_B1LF84l{BlZ@B z5Q{`3w;no`!=PqiYE0bO=D>6dIwfoAVd5w@;fU- z7)*-b&}7o~t}q<@HBUW*vic2m=%Kf3kAq(T+*5Y=cY{ zxzyX7`jw;(kN>1xs(OmJ6a=wmVxpZq7c&`J@}e1 zASk8lMf<)H$GLdUH|CD{V~2j4$8!*U?* zhf>#6XPl|D9j!*5m~$cUB$>^8JtAPxMO^o)p^%hnqgF5GY%xKx?|U)QZakD&yMAqC zem)o5PU9zszpakc#YwbtJ;8*b2Q5NO`lo7mZ{R8VyC)yzwk76{YFL98 znIGmlwj{|ovQW{006XhlTt>(o0Cf9Kh^qhbWFxbdMSCtZLH{LQ3k&)8RrL+#5mh3sSl7XiOm9b5P(0*2Rw)o}2Gh)iy0qRZlbshc%9?j> z>_!m(!Q2Xwx+WYSsHPblDxt#PEQ`)lCl%1;9tw4+l?%gjm9W%ykb1uv%YF|*=}W-L z4TL^dYn-Z_;l|p@j*WAjiST6aL==}Y$R>=Gm4(NZKVR2iK3|b_0DIuMO{@$R<%nh_ zfh6t}f=$cn97S;(-sZ`2*;USmzKuGL(n_sDU2w~_R$`~HV%QW_S&C}CbTGWoyP@k= zuAcYW_C^Ak1EF6(>NJV?fUr8sx(LmVH^@n*LUsOTl)jyWHAghCSfi!o)aJ^uNXt7j zQ8SQs-NKP3t3-(%;dOSys4%|pe2XrGKY13_qFq<43Uhe7{65Qnj|+B6 zM^yv7_0;p`>k6Y_S;}JJ92bQB&2V5zhUWoI&WIE+7Nzq><`VP4kp2Bd9m9MlYYL7( z$Vbqdv|l2--~9US?eR8*$B?s`q)!A4lY(3VD}BvA()ag$^Ki|)wF+HDE?myNqI0UAd9=x zX-yj)R7*rTivKGDUBxK7GnHn+?{>ag2KhyYb1_9k**`nnmfh@cRzOxOV#!BbHIIz+$h)fX^qq;M(U^S_(~EF2Y;ipfmA>=!1$?9SP}$Wn0a{ zr*~z+-i-pvqa7B6IGD@?fVLF3yU>LYTgN|YDY`uZ@M8sjokw=@i~r6 z!0|cnSUa<6*5uaHR#*=z(5=D@d=L@)`lY?Zdsw+`(HWZ~V|}?+k`(Xdgqr{4FS`=?gqj-j_Gq#_7)2t=afLxto>Y;=4$e+Xw|Ob3WJ zKVQHwVQJ&UXJJY`T&;`3;kLwGAi~1?{VMmBi|O&T9Fcz`M<@RF*P7|g4Q}%ZuncqW z+{S$Ywp#N~?hsXNzrv~ZZS^-|)$ljA0*r$6iaL~`T`(Nj~x6W6s5OQ-f?_QgPFADaERepGR2BH)YudvIpkz)Y8%^0!Yk^HxRc;MCCmWdrEi zlgxNszF}Hr`)nfP5Wfm+~!0e{bX7+-53mKEm==o5j)mVU>6ofh3?=v9>oY`Vh_#1O} zc8bMJNpu!|EgE=}>f7zr=m|@DdRP~L<=R1CTJ-s>kCY7~PUo0PuL&LJL>wS|3QZE~ zq_34Mw8wdm9!7G-CN!%|3gX$>skBTAfQhLEL6S*`Eb|i8nZiIZ)fq>+v?kw1$A185 z;9iY@loC>0E-h^E~f@5n;Wa@_jj4zYi|}FSfo}18}!#VS6^e{4fLRAV7Is; zC~W^S9Gx(Fb<8TLgL?sD&DReo=(PL6^dUF0J@~b0qG3_D)rg4qbVj@IUaRc@j?kGWF_mIJb#3GHfHi_i0xV?oa zE}j0xbgZ;Rr*#JGS;X9Tu=Phh?!QV1=Aq&!D!s;j2Je*4qjRa1-p&+hp$w6UfO$G2{<@dbP6oBi3Q0)zS#-k34)lR+TA!3Wo zz=J|Y@p{tz_=Mpf3fZXkQkK52QzNqG>jRgrABZ#suh=b?7C`{ z)~|t2T7Np{JGobF7+Q>N*jvo9_bp0cBl=GjkuO@x#{(s)I)m=7i@oRT+DE1~EfNwBz*W z`oIN2$#t)5{>n?sszMj}4%@lTeIlAd0(!R~4z$_0{N%Cx43*UtMm4C$tO+Z>yW>Eo zOWNTavER7ldFjA)<=I(VEExZD|5!*%0|a|pO3DL4Ec0B_XW&@%sBzf=qtu^vv7@P_ zhO2ViHN9c_qJouN9%nA8!g{v>=4NFxM+YrjW@C7uRLje`uwuWY-Q2%>#wg4#t-y+? za7MXR9vK~Ka;K@cB+0R0*q`og@ouq!_=$HM^HnzC&`5&3f98|ss(0=A9?0M>g``5( z`PNaAWzcdum3}g@IkemX=K>AAFm?a+ivtT~5>$EXZ&j ziJV5Sj^8@U7>U%-ETck+fOHAE5UYRREv52d2hBA5AZaY4bUX!Hdw78c-J8gpRaSeQ zF-6b%BXTTG-CGVdgjm<-3+PQ=CdZsJN-ojN{mDu0X1u0GO#3!Kr_)A9z;*Bm(`~FK zj(|-zvpUBC7`v6^y5w|t*5L1p2ILP%F}4qh&El`+l)nCCuJp@%eR++DBkLZRUC8=P zV{IgWZ%)qx81}IkMKNZt5`x_R`R}+L<`5^ZA_n2Sc5Xf_?b2t(%zVc6h_%HA_Uzea z!;Cm80A=zj6n$-aV47%TH;Q=cXO9y6If@|VW~nHAt+yj~kDstjZj7W_O)PP?h(<9- zl-*zX7n5v&w>r;3qNEzMv8uh`OSy^+7r7A9bWZSc52|*Mq-r|VXY3u_)ANwHyJ1c+ z-B>dF26xoxXlq872WSY_(6?A^R{`2M{5?NJQR%?rB)EO-4A8C!@lV(6<@*Ztx-1b<}W}GrU0Av&njKV?X=}?6$by| zq{A>}2|jMc1lheMX{z6IIGAtNQXe;|qh(uFwdnnpwfFWpqhbhiGMemXTK}X4aL|pj zDN;|R3BlpkcATu-d^w6@>c%^4-b4SgE=o2hu~fjYzSA&|ai}7{bvN(NMUo*Svl|gu z?)-rqSnVk|1pr=a6jmY0_4*R{vEE@MM0`Uvn_Z>E3A)60S8LMk;Mx$ z^oqv!2B)wDOcXp2Yi7wQtaEq_TNx`YKxc-K{ZLELW9^Xqow>|S0SxhkA14Yr3tIU| z%C8fx3PTk6Ws{u7uGph4Jx>ESw+ZMY$q`ORRmlhh+D}d^wp54c-<;}-65-DJ6tz-} z*I6B}=ga4imqO}GG6{kO(^Z;nU}pum@uoE7c05J`y3Awv-hdA#g8g8%NOBw!4cvF0 zC{(dbgW+KxvM6LPKrym9-OvN!Jp#hWS;STW!EoyYqB8|$d@27Ow83IL(<};kQ^1&{ zp()^f&1IT;qzXQX#uQz;dCiaYH8GvSzlNX3c+|V$EHH}u6w{E`=|F(}G}EX!IWw40>NpwXLJ8Wx}FzM$wj#vw!^naT-696a?0-Vj@%P{GZ!;}Iz} z1~{NlEt+Zt0n;j6-CaDoldX{Y3XN&E8b3Hv*g1_zhQ^Hn$t-aJ;@oVTdhN5iLqrl6XB_tls^g;l$$Y6F`QFF$)&dDABGq+z>9Qd;@5O_=jZK{lJ7A{ z{cw$AjWhEd*#(AJDe1|>rs~e(eOsDT-H#qUV$=}DWKpCcLg%0WcuBa7MC%vlP43-7 z?r-omd*Dt(SpcafiN*-&keK#n^lxUq5|3r=P0aL3jteVYZwJ&C&F3@yCbJi_n7z=S zte>v<%`e`{Mp@BPp4lMO*vOhumizm|9S~*`>BNBm;rI&>iAE`KUFlcD#I)8k_mp_w z1ut9`p`_6t692c`Xs<1h8m;>mw$gn5R(6y#6KA&K#jxWT)cr460%DJ*2=!2C>Kkrx zL{2}RUGhskdb4%`iY)LKi3U>9@Yi*B=fiTz{g#&{?a@M&fARL5*XHL*`KS(yn{qv_ zd%1H@#o`PUj)jMs@wtq9ciF&!_fIZyXw6HTn3U8@0LhXYsY<*VY70YGZS9Pal1L6CMH%a5MDXy#Wd48?7yn5 zWd!0=^Sj64a|^xqVUxeE*Fd&gw=7K^?mTL*agj$#wVU$6#ZFXRm+tFcqq^D3*Z%{6 z5Be)y;&Am83!uq}^hs6gx2?TcZGP~5ORV8soL{2@Uj;1A4+%bh+AnJJeHUIWG^&`A z<5iWgpG^4Rqb~1FDJ#rhq?!UhWv>Vm0dTfXC_xRaO){2Y|J*>njGYaBfnc>@tv~RC!2;r~y8)n3K;jI&Ea9 zTx?I7w5i_`cWO~k=+IGg-)>}WJg=KKbaa-AeJuyeVc9P<4$a-uaGT@PSI6&c%Ob4EtX({HOwxf}X#vJMbYN{JKPr_WII*Dka#uHi z19zKj`0$1Z8$i^uq@49^Rs~b$B6SwJ-g}l0VirfuWaKo2(5A9^S7{lM?b;>3TRj5& znKB76dUaE?w|XK`D1(1d+K=x-6yY3D!J5z~`1-BDaW2X`>F-h5ZijiDXhrcWd{F!m z`Ies3e(h7BcSfAOegg+&14UYn0@ zraa(&**gZ#{%{`=@IVK`8@3FTaHuO-4q^pHtJ$-eweeI*uh<%wj75eBycm$k5)Z(Y zAYERoAEKGUwPM*df_O~u#CBpMBxe2Ogn>x~%XAH^5tIQRKEYt!{=|6* z!^Xk0sWCzwVl~%%SDLc&!XKqIoo_a3j(D>k9cx^4q5evks!0|<0dM5q0Bl|G5awXV zUGBLbvZS_#2KsFTITz5UY-s$tq0s?i7oed-C-y3O)pNfDbA!*y&;$fn8ipxt1=0io zeW7)4)lr2}8p0lBILB!}&_9?v&z+5(q)xVqB7Y@ifc5op%0il^<}0u-UaI=D8+IVI z;ULQdbvHWx7gB%s#|R00r^8{}Mm&&eTfTM`^RP_Oi>XI|b}J#1fA@Sc4UvSq#AgDa zg!`3y8B^NdnA>dp_?W~7US1)=WeUi13ttlB+2-riPWF0moT4seCreMQhd@@$Fh5Nn zRW2N${CEXT;#fpLOUZjoV_bd^RC)?rbjT_Y4up@&I4c6$hR)tK?GngEj}o`M-QYeK z@^~2>S9&6OchdcIPQ$% zKP@@&Udi-Q9NSZ@uHm7_afH*V zqrg)}xxrg^bJY-2CTG;fRTXS@-Xw%=UPd0(PG`U`C0No3)?Rw$9SaiyRx*_4B&MHNTgb$c5`r8)X<A}jUq;MTP}x9*v( zzXpZ=`y1moa8ei_r&(?~NRs|j(SCLw1>U1HIn-zN%GL?S9I~;_A3u^fP>K*^7bSxZ z5-XP}iPJrVy$V^UY<%Xo`zGI>`jah0^DakNCsanv5}ys3V6OMXbf*AtvGiryk2Ew) zpQ1a7$p-q znwon|AwB&^xD&zfGr}nCGp9(`o+uJK`FTIQN4BVC%hqlXm>kUHh;Ukf4v!JPH`h<@ zdpNR08K+Zm)Gz&)EU(zK=^Jc>yQSZ8uwFp1TbI0I#HmLvrjMbnC{!KC?ZY_8ai%xu z7RvJzrzgi(oEagUQ#tZLQV^r>aueRS{OoEG?7qlxTm8`1t`^QW#Tb9V%<}VFf^sDT z`3yHk0v`}EH90y55Noi(wKkSXQ6_v;e$PxT&tQcSk=Z?vymw|%U)EZB-SP&gT~hTp zFG?Zvj*sQ+nc}43v_+f85oR*h$B4(lL?MfgsOU>+>^UTmS!8KN(E+ovnuU8{CRYSq zZ3sH(h%a~_`n}aghB}hIs8FvYsf5Mj$7G7|PJuPRMsm&U$&#|Lv~|l_oG%~ci?dPY zr%7n4=1l9W3IES5lAdi5N@{Y)Z+`62^hjUUme;)HwDIfE;dS&sB$u5$VsH&s4%IkP zOKV+>1g!g;{~T0B8$l$sqxfh>LCu!qq0_@;b^#9nfgqDiA?xanA z+G;_{1--b^Gi^W~Do6os>}tl<+OGUk-09;G#ye!fx&YX8H+~bh;7mJ-16mdM=1#8Jsg`*IBcDWHQu%gFdz*?XGS@EKSmjvds}{Ex7CC&G6U}B z57df+r(_A4LDG|JZuwCVVHrAUl=TnWR}rVTTy;wOg^ojDi>f+?Jgl9q|2o1n^s(Jl zh+{OTjn3ju>7ezPHsRPERwOsbgXgaOG5XPNtk)Zv=^dQxMxEZ+-qUlnM>A)pmQCmM z$brij96g1xhe2V6#s5O$!=5`Oq}vF_An3BrvpKIGejANy()f`XZ3@px^>1pj^8dWv z`rXW@I(4-&4SQ+^ZE!LcUM=kT^Wm6cI;i~c+)bc!sj6UhIeWKcrNzTUfi#cjLw?8Q zOf(5hr;=y*1De%yD`xE=B>YiwRu zp&Yy?9**j%Yfh%ZFcN^fyTmQA1_ppY{nSw<+xhKf$j5w+lls69h2aK#=kxy0{f}X@ zo+85MKMv?YvZZBZ(L#loRdXaI&IW^dWp&$AYXLthICvqpWc;}?v-t6YC-rI&mwV^! z5`n|kPMYwsWPZNnwhC3+6k@Ck0bG0qY1Xe>KC@OGh(uY?*KHa1`8RrXU4f;ATyX6F z+~0uaOgQ>x8r?-e<86*~Jr{Hwf==f%8*b|;Tx^8($i^YuUC{jdr=%zg$GB=Ljh_D- z{D}SX=NTRENOdE%NP?-bpe|dLo6*UGCfL~J(xpA;tR96GwtUC==rFV)MA<~{fTfut z<`6u4L6Lx3|MEB5`^yaVW7jDapGTW_OzaJOcPO4HXfZa}lQS8#*@_t~n&Wg^{x+{b z7)=G23+pLhlB|5hdxvezLWWC(0f^v)YUyR&5`GZ}c(4#lWthVxz9vRlqg0^Tlj_{C zDDPvE1ON}+pO$*XVa58RuM#!U(f9+3f8IESxUu#OA+KsLa@ye`!rE=Yb|ZnZ^m&id zk)>iCg{ogS;f|4u&mn4DHe7bm#h7DY?>U^ISa{%t)fyY0zeh+hAOJ_)~Vsd%rCl2NGVpn1)A)kS~P;PD0)eVKm6Mr{sz`0M4A7TaWsK+$aU~p13)yh~y&QkgXvN zN!dU`Ny8`N%7S!XJA_Z31$03$*yC^FW3UG zdk5XGjccQsa?Ql6JX`GrOQ?Ia2cQZY!w-ZSKsVzDg&*GO3PZm}B!}dZNwnQFHKi{= z2)sd83p8;-&pU_Cm*2>V2a8^8RiClSRzBx`3CPXe^0)ukC#akbuy`yt^R8^0Y-B$L zX5D`FT86AiDr0IlS&g@sv=_3CnkEcG+Lg?i)%4bFtJY`nFhV|j%c`N6nDs3XX3{Lx z%+(=b_5C6vlf174PQPcla2Xa4Ol9qVpd_a;Y|s$SuBx*3|C>{9@%v;bIU7{lN!si& z7e}vu#r-}V`}H(1({ZLaP5tZRxI6~EnpUm{5!Xi&_;Ey#S)#(Ea9R7+RIJvYQZOB$ z%c%MbNr_1g1tkNPpkl<~&(SV->+ExS)(x2X(NiWz!0;!T?FEz0C+yQ3)x2&4U>+&j zI2xTAVanPsCzCioW^`kH9vL)nS(BOEe6$g#hC8NqjxpG&fDwN17K6x5B+zV$NBm*i zV9Q?7@UaFlweiEX&FeD`3i9s&{<0ZJLxrQ2cg&7Zt`Tb`70aT^piF4!5=(5M%5|=Y zC0vC@gD{cP$tonPLJEHa^N8i5?Znx+5{UgGRr1kz$3%*{-mRN6D{x5+aky2Xo-(4u zR8P-h%IEEa>=V;32z#)9(Y5=`Q=Baf|65Q-?#+`0vK$DxMstN!9?1R};mEs?EhJ^+ z$}3qMWg)-Ak)0~Bh0N@%w}Bg7C4wqPKqZcO?AUsdNVEw6-7=Y;>gr#<=pgl6yt{Rx zSg+C#;yWq^A-?L|EbW`iYX*Pz;l~rijj3An94T?s%|#gR^{(Y3Zj|!4Nio8@jR&Cp*wUiar*2cxmhum1hbVL@N5M+e7K}_tg zLH)U@`n0Pn(ZeHO4Fwo}m`DSsO+rTH&%W!1ju_a_Z?y~`eXSK*1tu1imbtrEc}dPh zQ`7U!dLMF4y}sX|idYRdGS&SfTe?0(Xy8a0aYf`sm79}rI-M{Mk>@`N;ZD{ab!uL7 z0Xi#79=~k5SHAjnsfo0g*@38|3kq$C>#&D%mG-`n&Isow2A02bFh2J0=_YZ^+7c`X zTb8^E$Ha>>>|W9cz!7*Gh0YF_1l0TJIk6HUc2!!>glo#mGb2#@%xL=rf0z^IEp$2E zldUxwZy*f+8DTMp|KcZxKo?<3NlWysm;V~XC&@#O7HIRd=*lZkHv1WL9C7d1u^05IK9p>ls08AnNZMM+z1^JPDr-)1Kz1T%gq-vQhr(gzM%B5wFV zpiIflZ6l|ju1}~kry}sUfiLj7{X*PkY1iUMHW2NB#1!eE=xSz4k{Djtkx@Q)#ST0~ z{kVd;@EUAcr48mUdoOhv1PVOl*Pm1HslLn8HZ)ES{7CRTIo{*GDwr4Sy&9MzgU7Vuq3uR3FfSU4l|tVRlifdZ(SpuL$f#&@MRIOceqo#llYY{UMon!WYGJ{V zdxi%pRING}i% z$8N}ljS#w2{cUfMmQ-wN2}Ux$#Inu^$O6gxq}c=wJNk&7B;2NWSZkYVZ4V&;AjqXa zS(%DkDlds1{~8tH`bjpgrXTT|qd~l~`S{-5O#GBC#=d1`4%U^O-4!sKQHMqeq{Iu8 zTsTd;2KL>_mno*p0hH$tU^<`e93Cl`0xQVML~9$5-EuHBa%-kQ1Iq_zcDQCw)7>v7 zn=^@4p}+Krccp*#IAK4s!7OA=?;0bQ4izK-4UOfTB=5J6b+ey4A+$O2gUO>N!mM#C z(ZLb?huF$GGRGH`+l^k`$2mh)9I?E>7K4GX)7g>`Whw zOUR^0k+}};BN-2(gdTNbuX|5V85Qm00S@Lt_vuo_Y;&MDC3(%lsNlV@6PsO(Nl!E( zP=O@>0W=g0#_@{Qu`iE}bNUtw+Bz2P*g3VeQAFhaRaAGzb}^(9)lcfNRD>p2>n~61 zEz1GouJmo-gXXcFeSJ9O7Q%L}HvRlDa6g7(&`%_Z+gSyvn;FiS7WTE>uBp6OiWh6` zuqdKff58zS>R-O`SIza;#+xjWrL1?L%WCG3i!cE&osRGG%^K{iH(08k;6?TxU%loiZC zKz1y?6ZnEXLAik0IW_vVODcT~II56bqyCv?T4j*K60CHRCns|8h+gsMYEDh0AzqRr zxWUcmi}8=oF0@#JMAcIMzgr$zaJS$JZFIXJoEVS&Q@CtWH({^~)|)8swr3?lBjr9c zk-?at^~nh?@5t$TsXQ2R7&M*;<`Rp0>+W1?3ZZS*TPex*hY3bEm86ZKD=Tx(9s;H{0R!?J;=M|gBfiFNz<6h(&&`9T8^bq``r0+PNw-!A0a8reXbrt5Lq zVo$j$%N8d=Th~W7ua4VpW0NpnP3GXZ9P(D}2UfQLVKsM;IUrNc&aB74vSW+{f$7F5 zZo?I}WVvV2dJjXG%z4mh9G$ytPgdn-%}=Qt)VZ)C>m~Q@=?l;N77B zDbwZoEYONR(V*+fz%=?)%?EK^9F$y38Uwh_Hsk&SwoiFJo13_}OT=r4Y?CBM2uq0h zF9Bqj&0WW@{14}4|N4y02Jc4@NCu0re|xO^Ftx(-pCAGA3CThF|896kFva zG71|jf3%&;wfI}~KFZSfs2Gzz$CwIjfMGx8o0wBWo7sjQV1KXrert#|XWD|Mj0h9D zIrxjS=oFQ+*uuf}9q@q|MCo2{rvF^}c4U694o2P%7CateQb6{!^`WzU1V3a5&-U+mqt50$O8iyzKzb?k1!1`B3k>T`ND@ z;F0h>ErItsW5g>Ua+0#D-G<6k1#65#`v@bHn4tvG+12&Ye=?qcmQ2|j)QVzjhTq7= z6jr@1UDI0KUZ0$Fs-*)c4zx&>xaY|&v5R6%TLp@c2UKdAL8awbCF{`zp1G^ z#2t^P&3VRR#zpQgel5gBCjXwd>u*2JyGQDBp!+-h9CLAvZx#_e2zX-?y~kw%Ic6-} zaRf{<{>gq0!RB6J9AqTwiY^sS!)miw3igH1Jzy!BR5|QT=%xBq-%4mgUr=_Jhg9R& z2L2^zw0qh(JrTq`68vvHe}ctDGq4O(74WWiQSGX!Yl-hDv`b;E?o;d-b7}oC+o`~t zm^K_K%(X#wS{D8?%~%$XNJeRc+X;s4Lt)vVx?7y-;DdQ6HOd(Z)5^i=8_jlbo$caO zPZ>6I*YSN{vr7Tro|zL7&HQp0A-5c#guD!Jc1>HrXDJ)82D4hJe zXumb0(HLb}sCjsn+G|;C@*Z1H8iQq;*T<-#nv1NGay$7CCl5+pm#+yLz1Qm%)qC?k zk|;I%^u!SzeiKZp?lEo;p}+?ZDYMIC`nvtFGGzj{dddw9b@M593|nHBzX0WJ{OK3? znie=+NG^U#0UGKotQShj-dN!e=PRz~=PC4tR+LdCSc(nG#4Xm|9P));@9$wM_SCVe z@Ck&*DMIB}=bAZ!>LvpQq9;y7$gd#;@_LMKc$7e-!t3M=!bN!udF%@og&d=g-CaK9 z+qZWek9RK?kLx~B%D-&!bn~d=9Gs4)|Kg5+5^v~DS0LIyDiue4@vn7zf*XqTyMnVt zm&#!g1p-fv4*idv44^dA5pm|vnIj*%D0FI>pVhd1%ZvWB>qGxxgA;_g{q@6ZH^@V+ z^o7zS>o^Y>6Idx)-N3@ZaN9jm-*cedc@Qk44i8s8XvxO1K8j2AwT4R?E3P2I8^@?1 zl6-%lY3_mCUa_!SoSLL*7OQNoB+=$qBoiYN#o54rG74!#mmlD9azFPV=5d;uH!q@& z5!VDDqndq0;A}B`xgZ$;D=`|yC;#)-C0#N}PAr6s>Eq;}(brsf#NuJ%3Y6H@?4!{R7}($bw*oi;Cu(&GSx63W zT5#V-qQu68-_2)s7$DyBU)*I&$B@p1Val$l6a&J`G z4ensV6uoR_0%a-_eqIEWKd0VgvQb%vtr*Z@fp-G@^6pk_Gbnja#X5lOIHGuw^T66+4GWu6 zn-V!JjjDqdg(vZSWRlg^$=eCEmv3asN!mpmmX4&G;^fP3ZO-Rq^ePq?JGW)OIZ zTu)tRG>*))?9gX^fYRDS4VgjEIqPlLqbue&f;{S)Y4n<}oxdqz^ zslU{1w8pgbYBL^h?~SujhP+N@+sw^lA8M1HZc{Q7Ry)fB5SxW~x{-MWsNtS_2*l=zX8<+YNQvw{m^>vbWdsvybh_w&u?s z51`!;QIuq-Kmc=$9tZWH?JV$z3*9r# zKa2fBcYR=jVE}6~$flVlH-rJt6Fl&kg2qNZf~HxR0kiZsUf<3rZPk)^0}LV(A4Z3_ z6&_XS)6r>{aq*;;j5&KOY!JP)J8QW46MLc(W89asX9>|jAenL(rjd$KxlQ|rJuQzm zQVCWZ)}oS+@o{HEo=HIL$}S3V0n4g0=-i}ufmM%s!T^c=h~?iLiPD534e{4JT3;om zciJY|guua6{(^6B3v1QfZ}JrqF*GI)?LGjFQyeH-wF<|`y@SAY9p464);4}?wE;m9 zYlTc*Od2fXn`V8K*Nuhm&+tpDtAq(7r325k zc7J&2P)LajE4;d)@fPcb8yV6li{coJRq}C}mKoF1H)r69!U|!yHK&^qnD4AYN*n_t z%XME{xtdrW`&Xf$E$5A;x4i0lIqm3QEMpl>gYHK=J_#x3X6*hh_ciu5o1LYZEI^K8 zsnH$Iv|fY!}@+p=p*f#wpMFP%$R5#o6*xasQtYw5#^ zPet-mq;IC-;`?c0gz4|`QO*x<{Liy5gHiRT`dH#iWPvM@3Ov7(A^!Q z|C!?Wvjxv^evLXj-dWHMS})+pOmDK@X$;Cmy6#L=Fv|eVvwX--O;{{lA5^?Wf-i26 zQI~PU;uDvS%{~8cevmKs2Bm45a7%HZF{|71S#+J5?>AE^-ZYM%|Hz4QiitF_#odz? zyd*}#wS;N2_SweeKK(kQM?L$h_AO=*|C(0^;*2&W@__o+Cqxe0s0*+_v0?Gc?QQR* z8-Rtb*kX0rgWZ}_4JTZDqarYkij-AVFDYth=i~79&rIe&S+VUJeA|5QXnXjC%QP?v zQWXN^u~ote)=m-U@GvJ z>nHA(zm6pnGo_vWU&i*PO)Cq$r~mHicG9dU#`6G^hSUCE4`&_M)c5~!{3zYsJwT8W zMvRn{7E~G}q+_InG>mRWju56GD%}!NLs|qB=@<=je1?RegYV_{c|3mqZoB8c&$+R4 z?|D6+27+rGiksd`9qCIT!qJ{KS(4`6FDamk4uP8GNVqdlC{jcfS(H^_8>qPW4$E*f zF$kot{&3J6&bn1Se)-M&xxG*cY`i9^7=Y>{lNEE<#Z}4ZsmfymY;6K1LRr*?onCFa z7S1L^SB!;3YJ?c$U&knh8X35sl>7tPX~Q@`bmYHb^^aXeu^ zshBlJORC=|X^@R8BQg2;1sB^<*`6m&;g?8<)FUxritth>86g%6q$aZos=2ihA_ND2 z@-S{H?(Or=u?%#~!qSjvJpLelnxZU>>n=jY+Rwv_#d_8l7^!qMP7ykkQThJ5gtz)Xb`mTb;hz| zxcp`V8KH&dG7JpBm~m`DwTG-Ocz7_y1y%VFkA{vKc=}Mo?G!|`-kDsgx7eJ3 ziRh`VV!u5)ldtP=?nSwKYi}U=RmcLxln_my+A>{WjBvH6R#ydpcu0HgiBLxPU7?Eph2xX+kPi00=v=MC^OGm{f$#OXaIoOeU!P zo66p~ncqoaVH}tLT!&0+8wqcP(DoBh+m6ftStq;Bves1Ybg{AZozgd9(<>BG>x^bh z%FlYgvnqV`x$8iK^qn4^;ozi}J2n^s&;I(FnOzC=yff>WMVV3hs)*zKduHWYQ~J1k zp8lM}HU)m~b!}iphuP1!aD;VP4>m-m{dsUIxkZK!SncEAD-5g%`Se}sK>YM6{S6zY z^Uehx-O36UhE+5ud{Kb0@IoriVury#i^*074K{|Sp7iW|)#=bIuYK*&7HxX9ci454 zw<<0Zsh<0@_(k&^EL7!+FHexU{eegZ=2y_)G29&=FwN;1;3Z|VpIvEUGfRE!(jfrf zPyA%?aInGN`}5-T40%bPLVd(fSb%z+JZX}Z61Ic;n+BH%Ys(=hPQ7dIk`3_?n2L&f zVVb0e8{#{WXO+=9Bntx;un!6NP4^h4kD_^LeI79ub7p3ht$8&cwUoCUwExObu^j}- zw}gpH5sH5W4qx}YHo^2AFzR_zj?tuGv_Mw8pFKl6?0e2!zWPbllYNuWzy_=F%hkG0 zPF8XscNatXTbbq0|L(2*2%^O4NcT(W*u8uVVr|DHHp~f<9{sBW33N3!1Fk+`1aLF$ zZttmhHL4>|TE;T_$^~@%a?F;Sm>J*{0ojUy(}|iafnUncHE6ZJ#($Dzf<3V048AZy z%n8DD3)CnYRP5v5(6(cSR*gP9P}a18+ngNF7m~yom8v<60O`wFr)f;$1LV8ZR??X^ z*%d$i6;w`irLNy(|DjCjE|UT)=$J6G{GNEsVPasTEU%S2M?-_S8FuA|AasHJ{SVE? zYkSqBj^7UC%K>i@K&F5(uMhbS-um%s%iU2x2ivjzWv*?64D|9fJkEA~WJZ(`O#6*m zDYnjn;_16OV5hwLNDj^24rhtQd~`H6bhD2(X2ZYHLx#BjCQmAHJGshe&ZR^^$1=(K zEEd3G6fF1>MK5%uJ`mCvWu4|5ltC6tW5W1eG|3WMrcYBQNus3g`He%%=jiN1hIzyq zPJUH?r_fgeKgr&v4a^@xE~Li*xU*+K)_0c34j|@CikWTUqj5pgt}A?62i=d&5ce{W zf+0Rt0JRy*F(*v6woZH&3!`1s^6BB)F&=Rc(~^==MnPgT#0H&6Uocgh_O?abK5rzI zGUTC%1I6FH$^E!vQ8~U{dj>C2GOBcEZc=$El4 zz;WDSN)Ypvs01@f`T#*(N@PZ#v(Teh-2|O4|I9Y54tXPf$*QSF?1!dVpCNxfqyD`L z1bew8xn;e&>6tvO;4LY7AhO-E_IDbjkKf+&Dcj6nAz`0&LdR==C48~K+hRkHP|7Zf z2bj}27s@%8OWlgM4^J0Xloa}=SLTOHH_u|zNGb-juw(YC%hpL_f@KCW`Lb2{p36AVku@-Am- zQr}f;nz;OayywQKKR;6vQj69MXM}E+1?4UtI+9d9Dk1M}o~If#`*-yn!de)qb&?;* zH&a8YmvvVdHt&KE5Em_cxIr)0)rW*dzyCh*SEb5m>m5``7zEuMlMg#fvp;dnA}?}R zNKF1t5gVj$y{X2~wQV<@d>8H?6@1D#*?p^x^ z@<=*^I70X+X`?Ap0s!h$S57b*%-wPLtkQcxpw%d;H6romqwzNN0`^8XDG0CG&E_;# zPP0Y*fV-!9318|f6N4#}4&^=5#tmg^L&=vqNaZ510~b%jW{cJ-@=7-b9f~%D6U@cXuhp>EGx@!_qR)vu?0w{M-Z03czzaqHdNkVFBQr+O~6mY z^n{geSPmXfL<3v`(eH^Hm}N;(KGJZ0I@J=>o3EY>k{K&#@?QT*Y-#RFN#(&`g5Msu5F==!@qj6zlkal;XqJY8*Kv#{g-QYeeWT7k34#f`)a!7m(Q-5o?C;djRj?ejLhwr>1P>f8Q^P=1zq1n z>J(2N#|EOY>nN9vw8Y~=`r?|Svx2YvT3+E}w1&?|t^#;5!vYsqCE?p?A=|A-Cp}~q zt+ma-xsdjaQBNQNF&TXKwMu}(3SK=F$j3MQPDiWJiB0p{Ro^mKjw6tY}Q6#B}Ogu zso7Wi*2hJBU07wOrEqBP;feCG$X_=n|GiF#7guudr@$+u|AK=vBCfSlI_=DD_dx}N zE}5$*kr277#mEd00y?rDkh`rMS6-+F!KKZ|wj@0}@}_Z*Rc&RGNx zM;hR(NKy;=K{edO&vdJ)qteiA=NF#jf4E~UilAvb{jaYtf9L-Ru|!1|TdncSVfX!Ww5!!@ge$3PVMni%;~K2{urKUAM~#Ejp&sj2Ccc zcE5M2*2BRV!?0azCg=(D=$?_gTlMC(Gct#2K=*c!lU+vw zp2F7TSk&gc*i+V9ymiw_WmmK@mIsA@=S$-b8v~UhGI{u zRBEVn0}S6D*SWp@`Ab~XCx<{UId#f9|ApeY4>Nc>(9M%fnQtYv))@f>Yr`AUO?j22 zVyb1^z?tU*hE*?RpF(m%eJxvD`~Yl2Cg?Q0uQ*eVD=ebTb1I^jn3z!XQ@-_8SO3Z} z-iO(z48sX#%m}$Udp#|;f-_Pg0wVwJQc@~{Rj!XXKSsmDuw@i3>}V>e^pCwNvi0IQ z=L1u7A(dCY4|(o2w;k7Po_E~&l^r-qjX@fRm#P;c( zVD|YYFGgn=xr10{!7JO!uIp3H?IdEW*B@gD$;dWkZ$1(cy9Je|L@QHT1iBwX)w?xLqqWik&=)}|&V2kK!0W3LmMXC;u?Bm+Lpt2Jgipp_{dUn% zhH9r*Cp>%VN$2#Lr9C7rG&IGq`!Soj=woJiajL4J3m?yeH(fK?>hh1l`^$72;0aY*Y9MmTsu7_ zr)^(meP&%md(SV3BQ*l&8uzI$*jO`%-fq4sx_=~gN{{1Uj$mgk^F#lkPoDi$?pkG$sC7VdW z5;28>k5|~-}`#^sTO?jpkL~K5YpVwV)HU1a94jOL? z=_LGh_cP~`6w@3R1wMGcf*X?Z#-z&TXVg{Ki7mFlw_EyUlOW>Ry+*q%4z^;1l8v`m z?f9wCqap0sm&w}Bj%i)dr6_ZTJF4UYAA~H4`*RGxnf9 zK8Fye*&|R@L7!xuKk@m`-Y_#o7lwD*5=<=2`wz`RW3sLZTh+5;W=D;bZR}Z)eWRBu zlZkH=Pnid7X`wk0f7)iz=KbryN0pUp&u~gHauszDm#Cb0pPAEEE8r^~U$8&Pc8F&t zm&6gY2Y}9dZLZ=;Zb`g_ZU~iih*Yw2YjhLw|62{y;eoo7@23<*J##Yewv%6FrC8CN zEndMA`}mXijn}59FaBy>oLmgT@Al*Ic^DA%}Pexeb;_pTO z!COd%#PMb%Jy923Q75$!-RxI<#p6Rs?V$4nd>?eX6|RPwsfX#LY@-hbhi?moUS4*^ zlHR#2pR9^7wGu7ZdRw3n!A+&mos1Qp-DsSMYE76Yn}26iq9jw2-_HY6)x zDhwQAc3wWdH1EAM!e|qvx{&NJWAp+#a6;O%h;~Xp-A{$48vrHuGJJsvo#DVf0+Yzc z%rJ}Qj>eu(vAyqoIB4q|C zO|tZjT1X_*3pAU&i0W~t6x%%Ojc{6~Pm}fSp5+yu1{Ej(Sh*quB;ngSxwvzB9eNmu z=JW-WG_z>p>qZ>?pvNlkXWFbg4Yy$^P=-#YAz?Z#W7w-*ReZo3RvZsNyQL)}KPzt1 z-3)hzKqAG`)C%?vj~zjLNK_;q2KIN0Z=kwSv%d;@)(7ZOKHb;?umAqljGD6vxQbYB5Y z4}bI7NalN`@l`ilJ~%#TqUJ-ywW)CFQ#rf&GXwS;iX@Lw(Y#23sgUoiL=~2R;O^V` znHCeN2*N=x$!4@-!kYttx(c(l1XdU7-ChP`PR1LzjJ4&cZW`2V0r2OyQ?y4DkM&(7 zrYWSAZFlLAH})t4m=k1m!4OPbS{4a{fHw21cvtQIx`7&1aPl|pu@u@>+cg>dG2d!> z?zBFJ_h}7Uz-!KSJ%7vHWTwu*z+{+^oGm(}xPnZ!Y{YUZ;{Cf}I?LiRwdXIydCACt z!cl2sP1HrIl_5ek9Y514f1*o4h^bEz%+60N7 zDFTiTg+XIlwJ^HP}q?ZZ71Z1%VlK)r$Ky0Qot91>W5_K2i#m!?bH9wUwIP{wtkj(qsr% zbpiQ^p_vj2F5nMX7#;KC0>dCr{qty&54F;*g8+S#gOobQjdMl+V9;<{?PQ784Wg{^2ogLK4JJ!n_5#Gz=&20AA zeZbZ_=v4Usf4$<0(Y{zO@6<~ymiCs`EyW#cBj8EPpt%)^1SAk9H+zE+poK*})ylq_ zUtUCt^RjUOV?0XhyA&_H<85-8?QCJH!D4!;Dx5o>;Jw=Dy(yczz5Z81(&&ZD*imsS Qp!Frt)iTnoQ@4-(A2J1x$p8QV literal 0 HcmV?d00001 diff --git a/new-ui/src/pages/full/AddPage/components/AddCard/style.scss b/new-ui/src/pages/full/AddPage/components/AddCard/style.scss index e8d89154f..22674bda7 100644 --- a/new-ui/src/pages/full/AddPage/components/AddCard/style.scss +++ b/new-ui/src/pages/full/AddPage/components/AddCard/style.scss @@ -40,6 +40,16 @@ } } + &.image-lock { + .images { + img { + position: absolute; + top: -10px; + left: -10px; + } + } + } + .title { font: var(--t-body-sm-500); color: var(--fg-white-100); @@ -49,7 +59,7 @@ .description { color: var(--color); font: var(--t-body-xs-400); - padding-bottom: var(--spacing-xl); + padding-bottom: var(--spacing-2xl); transition-duration: 160ms; transition-timing-function: ease-out; transition-property: color; @@ -72,11 +82,6 @@ background: var(--bg-dark-blue-20); box-sizing: border-box; padding: var(--spacing-md) var(--spacing-md) var(--spacing-md) var(--spacing-xl); - - .btn-wrap, - .btn { - width: 100%; - } } } } diff --git a/new-ui/src/pages/full/AddPage/hooks/useStartMfaConfiguration.ts b/new-ui/src/pages/full/AddPage/hooks/useStartMfaConfiguration.ts new file mode 100644 index 000000000..f8ce17751 --- /dev/null +++ b/new-ui/src/pages/full/AddPage/hooks/useStartMfaConfiguration.ts @@ -0,0 +1,41 @@ +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { error as logError } from '@tauri-apps/plugin-log'; +import { Snackbar } from '../../../../shared/providers/snackbar/snackbar'; +import { + isMfaConfigMissingToken, + isMfaConfigUnsupported, +} from '../../../../shared/rust-api/mfaError'; +import type { InstanceInfo } from '../../../../shared/rust-api/types'; +import { startMfaConfiguration } from '../../ConfigureMfaPage/hooks/useConfigureMfaStore'; + +/** Opens a session and enters the wizard. On failure it stays put, so another instance + * can be tried. */ +export const useStartMfaConfiguration = () => { + const navigate = useNavigate(); + + return useMutation({ + mutationFn: (instance: InstanceInfo) => startMfaConfiguration(instance), + onSuccess: () => { + navigate({ + to: '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/full/configure-mfa', + }); + }, + onError: (err) => { + void logError(`MFA configuration start failed: ${err}`); + if (isMfaConfigUnsupported(err)) { + Snackbar.error( + 'This Defguard instance does not support configuring MFA from the client.', + ); + return; + } + if (isMfaConfigMissingToken(err)) { + Snackbar.error( + 'This device has no polling token; update the instance and try again.', + ); + return; + } + Snackbar.error('Could not start MFA configuration.'); + }, + }); +}; diff --git a/new-ui/src/pages/full/AddTunnelPage/AddTunnelPage.tsx b/new-ui/src/pages/full/AddTunnelPage/AddTunnelPage.tsx index 4b27747f7..7acb6508f 100644 --- a/new-ui/src/pages/full/AddTunnelPage/AddTunnelPage.tsx +++ b/new-ui/src/pages/full/AddTunnelPage/AddTunnelPage.tsx @@ -10,7 +10,7 @@ import { useTunnelWizardStore } from '../TunnelWizardPage/hooks/useTunnelWizardS export const AddTunnelPage = () => { const navigate = useNavigate(); return ( - +

{`A WireGuard tunnel is a secure, encrypted connection that allows your device or network to communicate safely over the internet.It ensures that your data is protected and transmitted through a private, trusted channel.`}

diff --git a/new-ui/src/pages/full/AddTunnelPage/styles.scss b/new-ui/src/pages/full/AddTunnelPage/styles.scss index 1ef9f59b9..44dc80847 100644 --- a/new-ui/src/pages/full/AddTunnelPage/styles.scss +++ b/new-ui/src/pages/full/AddTunnelPage/styles.scss @@ -10,8 +10,4 @@ color: var(--fg-white-70); } } - - .controls { - margin-top: auto; - } } diff --git a/new-ui/src/pages/full/ConfigureMfaPage/ConfigureMfaPage.tsx b/new-ui/src/pages/full/ConfigureMfaPage/ConfigureMfaPage.tsx new file mode 100644 index 000000000..862898d1b --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/ConfigureMfaPage.tsx @@ -0,0 +1,33 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useCallback } from 'react'; +import { ConfigureMfaTimeoutProvider } from './components/ConfigureMfaTimeoutProvider'; +import { + discardMfaConfiguration, + useConfigureMfaStore, +} from './hooks/useConfigureMfaStore'; +import { ConfigureMfaVerify } from './verify/ConfigureMfaVerify'; +import { ConfigureMfaWizard } from './wizard/ConfigureMfaWizard'; + +export const ConfigureMfaPage = () => { + const navigate = useNavigate(); + const authorized = useConfigureMfaStore((s) => s.authorized); + + const leave = useCallback(() => { + navigate({ to: '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/full/add' }); + }, [navigate]); + + const handleSessionExpired = useCallback(() => { + void discardMfaConfiguration(); + leave(); + }, [leave]); + + return ( + + {authorized ? ( + + ) : ( + + )} + + ); +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaTimeoutProvider.tsx b/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaTimeoutProvider.tsx new file mode 100644 index 000000000..c3c5c3c83 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/components/ConfigureMfaTimeoutProvider.tsx @@ -0,0 +1,34 @@ +import { useNavigate } from '@tanstack/react-router'; +import { type PropsWithChildren, useEffect } from 'react'; +import { Snackbar } from '../../../../shared/providers/snackbar/snackbar'; +import { + discardMfaConfiguration, + useConfigureMfaStore, +} from '../hooks/useConfigureMfaStore'; + +/** Recover at the deadline rather than let the user type a code that cannot land. */ +export const ConfigureMfaTimeoutProvider = ({ children }: PropsWithChildren) => { + const navigate = useNavigate(); + const deadline = useConfigureMfaStore((s) => s.deadline); + + useEffect(() => { + if (!deadline) return; + + const expire = () => { + void discardMfaConfiguration(); + Snackbar.error('MFA configuration session expired, start again.'); + void navigate({ to: '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/full/add', replace: true }); + }; + + const ms = new Date(deadline).getTime() - Date.now(); + if (ms <= 0) { + expire(); + return; + } + + const timer = setTimeout(expire, ms); + return () => clearTimeout(timer); + }, [deadline, navigate]); + + return children; +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx new file mode 100644 index 000000000..c22b7903a --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useConfigureMfaStore.tsx @@ -0,0 +1,196 @@ +import { error as logError } from '@tauri-apps/plugin-log'; +import dayjs from 'dayjs'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { api } from '../../../../shared/rust-api/api'; +import { + type InstanceInfo, + type MfaConfigAuthorizeResult, + type MfaConfigStartResult, + MfaMethod, + type MfaMethodValue, +} from '../../../../shared/rust-api/types'; +import { isPresent } from '../../../../shared/utils/isPresent'; +import { + ConfigureMfaStep, + type ConfigureMfaStepValue, + isCodeMfaMethod, + isMfaSetupStep, + MFA_WIZARD_STEPS, + mfaFactorStep, +} from '../types'; + +type StoreValues = { + activeStep: ConfigureMfaStepValue; + instance: InstanceInfo | null; + sessionId: string | null; + /** Code factors come from the session, the rest from the instance snapshot. */ + configuredMethods: MfaMethodValue[]; + /** Null until the selection step is done. Empty is valid, the email fallback configures + * a factor on its own. */ + selectedMethods: MfaMethodValue[] | null; + /** No factor was configured, so an emailed code was the only way in. */ + emailFallback: boolean; + /** ISO timestamp the session dies at, null once nothing in the flow needs it. */ + deadline: string | null; + authorized: boolean; + /** Issued for the account's first factor only, so an empty list is an ordinary success. */ + recoveryCodes: string[]; +}; + +type FlowState = Pick< + StoreValues, + 'configuredMethods' | 'selectedMethods' | 'recoveryCodes' +>; + +/** Picked factors still to set up, in wizard order. */ +const pendingMethods = (state: FlowState): MfaMethodValue[] => + state.selectedMethods?.filter((method) => !state.configuredMethods.includes(method)) ?? + []; + +/** Setup steps with a factor still pending, plus the closing steps that have something to show. */ +const remainingSteps = (state: FlowState): ConfigureMfaStepValue[] => { + const pending = pendingMethods(state); + return MFA_WIZARD_STEPS.filter((step) => { + switch (step) { + case ConfigureMfaStep.RecoveryCodes: + return state.recoveryCodes.length > 0; + case ConfigureMfaStep.Finish: + return true; + default: + return pending.some((method) => mfaFactorStep(method) === step); + } + }); +}; + +/** Picking only the email fallback leaves no setup step, so the wizard opens on what follows. */ +const firstStep = (state: FlowState): ConfigureMfaStepValue => + remainingSteps(state)[0] ?? ConfigureMfaStep.Finish; + +const defaults: StoreValues = { + activeStep: ConfigureMfaStep.Configuration, + instance: null, + sessionId: null, + configuredMethods: [], + selectedMethods: null, + emailFallback: false, + deadline: null, + authorized: false, + recoveryCodes: [], +}; + +interface Store extends StoreValues { + start: (instance: InstanceInfo, response: MfaConfigStartResult) => void; + selectMethods: (methods: MfaMethodValue[]) => void; + /** The fresh deadline bounds every setup in the session, not just the next one. */ + authorize: (response: MfaConfigAuthorizeResult) => void; + factorConfigured: (method: MfaMethodValue, recoveryCodes: string[]) => void; + next: () => void; + back: () => void; + reset: () => void; +} + +export const useConfigureMfaStore = create()( + persist( + (set, get) => ({ + ...defaults, + start: (instance, response) => { + // The fallback mails a code to the address on file, registering email along the way. + const codeFactors = response.email_fallback + ? [MfaMethod.Email] + : response.available_methods; + const configuredMethods = [ + ...codeFactors, + ...(instance.mfa_configured_methods ?? []).filter( + (method) => !isCodeMfaMethod(method), + ), + ]; + set({ + ...defaults, + instance, + sessionId: response.session_id, + configuredMethods, + emailFallback: response.email_fallback, + deadline: dayjs.unix(response.deadline_timestamp).toISOString(), + }); + }, + selectMethods: (methods) => { + set((current) => ({ + selectedMethods: methods, + activeStep: firstStep({ ...current, selectedMethods: methods }), + })); + }, + authorize: (response) => { + set((current) => ({ + authorized: true, + deadline: dayjs.unix(response.deadline_timestamp).toISOString(), + // The fallback enables email as it verifies, so only this authorization issues codes. + recoveryCodes: response.recovery_codes, + activeStep: firstStep({ ...current, recoveryCodes: response.recovery_codes }), + })); + }, + factorConfigured: (method, recoveryCodes) => { + set((current) => ({ + configuredMethods: current.configuredMethods.includes(method) + ? current.configuredMethods + : [...current.configuredMethods, method], + recoveryCodes: recoveryCodes.length > 0 ? recoveryCodes : current.recoveryCodes, + // Last step that needs the session, so nothing is left to expire. + deadline: method === MfaMethod.Fido2 ? null : current.deadline, + })); + }, + next: () => { + const current = get(); + const from = MFA_WIZARD_STEPS.indexOf(current.activeStep); + // A setup step holds the flow while it still has a picked factor to configure. + const next = remainingSteps(current).find( + (step) => + MFA_WIZARD_STEPS.indexOf(step) > from || + (step === current.activeStep && isMfaSetupStep(step)), + ); + if (isPresent(next)) set({ activeStep: next }); + }, + back: () => { + const current = get(); + const from = MFA_WIZARD_STEPS.indexOf(current.activeStep); + // A step with nothing left to do is not one to go back to. + const previous = remainingSteps(current) + .filter((step) => MFA_WIZARD_STEPS.indexOf(step) < from) + .at(-1); + if (isPresent(previous)) set({ activeStep: previous }); + }, + reset: () => { + set({ ...defaults }); + }, + }), + { + name: 'configure-mfa-store', + storage: createJSONStorage(() => sessionStorage), + // Bumped when the picks became one list, so older sessions resume with no selection. + version: 6, + }, + ), +); + +/** The factor a setup step is currently working on. */ +export const selectPendingMethod = + (step: ConfigureMfaStepValue) => + (state: Store): MfaMethodValue | undefined => + pendingMethods(state).find((method) => mfaFactorStep(method) === step); + +export const startMfaConfiguration = async (instance: InstanceInfo): Promise => { + const response = await api.mfaConfigStart(instance.id); + useConfigureMfaStore.getState().start(instance, response); +}; + +/** A copy the proxy still holds expires on its own, so a failed cancel is not worth raising. */ +export const discardMfaConfiguration = async (): Promise => { + const { sessionId } = useConfigureMfaStore.getState(); + useConfigureMfaStore.getState().reset(); + if (!isPresent(sessionId)) return; + try { + await api.mfaConfigCancel(sessionId); + } catch (err) { + void logError(`Failed to cancel MFA configuration session: ${err}`); + } +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts new file mode 100644 index 000000000..ee48c5a87 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/hooks/useMfaConfigErrorHandler.ts @@ -0,0 +1,55 @@ +import { error as logError } from '@tauri-apps/plugin-log'; +import { useCallback } from 'react'; +import { + isMfaConfigCancelled, + isMfaConfigInvalidCode, + isMfaConfigProxyError, + isMfaConfigSecurityKeyError, + isMfaConfigSessionExpired, + mfaErrorMessage, +} from '../../../../shared/rust-api/mfaError'; + +type Options = { + context: string; + setError: (message: string) => void; + onSessionExpired: () => void; + /** Copy for anything untagged, never the raw string, which may be a Rust message. */ + fallback: string; +}; + +/** Shared `MfaConfigError` classification, so every step of the flow reacts the same way. */ +export const useMfaConfigErrorHandler = ({ + context, + setError, + onSessionExpired, + fallback, +}: Options) => + useCallback( + (err: unknown) => { + // A cancel is the user's own doing, so it is neither logged nor shown. + if (isMfaConfigCancelled(err)) { + return; + } + void logError(`${context}: ${err}`); + if (isMfaConfigInvalidCode(err)) { + setError('Invalid code'); + return; + } + if (isMfaConfigSessionExpired(err)) { + setError('Configuration session expired, start again.'); + onSessionExpired(); + return; + } + // The backend writes these for the user (no key, wrong PIN, no touch), so show as is. + if (isMfaConfigSecurityKeyError(err)) { + setError(mfaErrorMessage(err)); + return; + } + if (isMfaConfigProxyError(err)) { + setError('Service temporarily unavailable, try again.'); + return; + } + setError(fallback); + }, + [context, setError, onSessionExpired, fallback], + ); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/types.ts b/new-ui/src/pages/full/ConfigureMfaPage/types.ts new file mode 100644 index 000000000..5e88c19c0 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/types.ts @@ -0,0 +1,69 @@ +import { MfaMethod, type MfaMethodValue } from '../../../shared/rust-api/types'; + +/** Wizard steps, in the order they run. Setup steps are the ones a factor can claim. */ +export const ConfigureMfaStep = { + Configuration: 'configuration', + Fido2: 'fido2', + RecoveryCodes: 'recovery-codes', + Finish: 'finish', +} as const; + +export type ConfigureMfaStepValue = + (typeof ConfigureMfaStep)[keyof typeof ConfigureMfaStep]; + +export const MFA_WIZARD_STEPS: ConfigureMfaStepValue[] = [ + ConfigureMfaStep.Configuration, + ConfigureMfaStep.Fido2, + ConfigureMfaStep.RecoveryCodes, + ConfigureMfaStep.Finish, +]; + +type MfaFactor = { + method: MfaMethodValue; + /** Factors sharing a step are configured one after another on it. */ + step: ConfigureMfaStepValue; + /** Whether the account can hold several of these, so it stays offerable once configured. */ + repeatable: boolean; +}; + +/** Every factor this client can set up, in selection and wizard order. */ +export const MFA_CONFIGURABLE_FACTORS: MfaFactor[] = [ + { method: MfaMethod.Totp, step: ConfigureMfaStep.Configuration, repeatable: false }, + { method: MfaMethod.Email, step: ConfigureMfaStep.Configuration, repeatable: false }, + { method: MfaMethod.Fido2, step: ConfigureMfaStep.Fido2, repeatable: true }, +]; + +export const mfaFactor = (method: MfaMethodValue): MfaFactor | undefined => + MFA_CONFIGURABLE_FACTORS.find((factor) => factor.method === method); + +export const mfaFactorStep = ( + method: MfaMethodValue, +): ConfigureMfaStepValue | undefined => mfaFactor(method)?.step; + +/** Whether the step sets a factor up, as opposed to closing the flow. */ +export const isMfaSetupStep = (step: ConfigureMfaStepValue): boolean => + MFA_CONFIGURABLE_FACTORS.some((factor) => factor.step === step); + +/** The steps the given factors are set up on, in wizard order and without repeats. */ +export const mfaStepsOf = (methods: MfaMethodValue[]): ConfigureMfaStepValue[] => + MFA_WIZARD_STEPS.filter((step) => + methods.some((method) => mfaFactorStep(method) === step), + ); + +export const isMfaFactorOfferable = ( + method: MfaMethodValue, + configuredMethods: MfaMethodValue[], +): boolean => { + const factor = mfaFactor(method); + if (!factor) return false; + return factor.repeatable || !configuredMethods.includes(method); +}; + +/** Factors that can authorize a session, most preferred first. Core only accepts code factors. */ +export const MFA_VERIFICATION_METHODS = [MfaMethod.Totp, MfaMethod.Email] as const; + +export type MfaVerificationMethod = (typeof MFA_VERIFICATION_METHODS)[number]; + +/** Code factors are the only ones a session reports, the rest come from the instance snapshot. */ +export const isCodeMfaMethod = (method: MfaMethodValue): boolean => + MFA_VERIFICATION_METHODS.some((code) => code === method); diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx new file mode 100644 index 000000000..9df6ff016 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureMfaVerify.tsx @@ -0,0 +1,43 @@ +import { MfaMethod } from '../../../../shared/rust-api/types'; +import { isPresent } from '../../../../shared/utils/isPresent'; +import { useConfigureMfaStore } from '../hooks/useConfigureMfaStore'; +import { MFA_VERIFICATION_METHODS, type MfaVerificationMethod } from '../types'; +import { ConfigureSelectMethodsStep } from './ConfigureSelectMethodsStep/ConfigureSelectMethodsStep'; +import { ConfigureVerifyEmailStep } from './ConfigureVerifyEmailStep/ConfigureVerifyEmailStep'; +import { ConfigureVerifyTotpStep } from './ConfigureVerifyTotpStep/ConfigureVerifyTotpStep'; + +type Props = { + onCancel: () => void; + onSessionExpired: () => void; +}; + +/** Picks what the wizard sets up, then verifies the session with an existing factor. */ +export const ConfigureMfaVerify = ({ onCancel, onSessionExpired }: Props) => { + const configuredMethods = useConfigureMfaStore((s) => s.configuredMethods); + const methodsSelected = useConfigureMfaStore((s) => isPresent(s.selectedMethods)); + + const method: MfaVerificationMethod = + MFA_VERIFICATION_METHODS.find((candidate) => configuredMethods.includes(candidate)) ?? + MfaMethod.Email; + + if (!methodsSelected) { + return ; + } + + switch (method) { + case MfaMethod.Totp: + return ( + + ); + case MfaMethod.Email: + return ( + + ); + } +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx new file mode 100644 index 000000000..b86e7b04e --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/ConfigureSelectMethodsStep.tsx @@ -0,0 +1,135 @@ +import { useMutation } from '@tanstack/react-query'; +import { useCallback, useState } from 'react'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { Checkbox } from '../../../../../shared/components/Checkbox/Checkbox'; +import { Controls } from '../../../../../shared/components/Controls/Controls'; +import { FieldError } from '../../../../../shared/components/FieldError/FieldError'; +import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/FullPageTitle'; +import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage'; +import { TooltipContent } from '../../../../../shared/providers/tooltip/TooltipContent'; +import { TooltipProvider } from '../../../../../shared/providers/tooltip/TooltipContext'; +import { TooltipTrigger } from '../../../../../shared/providers/tooltip/TooltipTrigger'; +import { MfaMethod, type MfaMethodValue } from '../../../../../shared/rust-api/types'; +import { mfaToText } from '../../../../../shared/utils/mfa'; +import { + discardMfaConfiguration, + useConfigureMfaStore, +} from '../../hooks/useConfigureMfaStore'; +import { isMfaFactorOfferable, MFA_CONFIGURABLE_FACTORS } from '../../types'; +import '../style.scss'; +import './style.scss'; + +interface Props { + onCancel: () => void; +} + +const EMAIL_REQUIRED_TOOLTIP = + 'Email is required: it is the factor this session is being verified with.'; + +/** Opens the flow, the picks here are what the wizard sets up afterwards. */ +export const ConfigureSelectMethodsStep = ({ onCancel }: Props) => { + const configuredMethods = useConfigureMfaStore((s) => s.configuredMethods); + const emailFallback = useConfigureMfaStore((s) => s.emailFallback); + + const [selected, setSelected] = useState([]); + const [error, setError] = useState(null); + + const { mutate: cancel, isPending: isCancelling } = useMutation({ + mutationFn: discardMfaConfiguration, + onSettled: onCancel, + }); + + const toggle = useCallback((method: MfaMethodValue) => { + setError(null); + // Listing order is the order the wizard sets them up in. + setSelected((current) => + current.includes(method) + ? current.filter((picked) => picked !== method) + : MFA_CONFIGURABLE_FACTORS.map((factor) => factor.method).filter( + (candidate) => candidate === method || current.includes(candidate), + ), + ); + }, []); + + const handleSubmit = useCallback(() => { + // The fallback configures email on its own, so it satisfies the one-factor minimum. + if (selected.length === 0 && !emailFallback) { + setError('Select at least one method'); + return; + } + useConfigureMfaStore.getState().selectMethods(selected); + }, [selected, emailFallback]); + + return ( + + +

+ {`Pick the multi-factor authentication methods you want to set up.`} + {`Methods already configured on your account are listed for reference.`} +

+
+ {MFA_CONFIGURABLE_FACTORS.map(({ method }) => { + // Not a pick the user can drop, the fallback registers email as it verifies. + const isRequiredEmail = emailFallback && method === MfaMethod.Email; + if (isRequiredEmail) { + return ( + + +
+ +
+
+ +

{EMAIL_REQUIRED_TOOLTIP}

+
+
+ ); + } + if (!isMfaFactorOfferable(method, configuredMethods)) { + return ( +
+

{mfaToText(method)}

+ Configured +
+ ); + } + return ( +
+ { + toggle(method); + }} + /> +
+ ); + })} +
+ + +
+ +
+ ); +}; diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss new file mode 100644 index 000000000..725cc0ed9 --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureSelectMethodsStep/style.scss @@ -0,0 +1,22 @@ +#configure-select-methods-step { + .methods { + @include surface-row-stack; + } + + .method-row { + @include surface-row; + + &.configured { + .name { + font: var(--t-body-sm-400); + color: var(--fg-white-70); + } + + .tag { + font: var(--t-body-xs-400); + color: var(--fg-white-70); + white-space: nowrap; + } + } + } +} diff --git a/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx new file mode 100644 index 000000000..f70fc0dfc --- /dev/null +++ b/new-ui/src/pages/full/ConfigureMfaPage/verify/ConfigureVerifyEmailStep/ConfigureVerifyEmailStep.tsx @@ -0,0 +1,144 @@ +import { useMutation } from '@tanstack/react-query'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { ButtonVariant } from '../../../../../shared/components/Button/types'; +import { CodeInput } from '../../../../../shared/components/CodeInput/CodeInput'; +import { Controls } from '../../../../../shared/components/Controls/Controls'; +import { FullPageTitle } from '../../../../../shared/components/FullPageTitle/FullPageTitle'; +import { FullPage } from '../../../../../shared/layouts/FullPage/FullPage'; +import { api } from '../../../../../shared/rust-api/api'; +import { MfaMethod } from '../../../../../shared/rust-api/types'; +import { isPresent } from '../../../../../shared/utils/isPresent'; +import { + discardMfaConfiguration, + useConfigureMfaStore, +} from '../../hooks/useConfigureMfaStore'; +import { useMfaConfigErrorHandler } from '../../hooks/useMfaConfigErrorHandler'; +import '../style.scss'; + +const CODE_LENGTH = 6; + +interface Props { + onCancel: () => void; + /** The session outlived its deadline, so the whole flow has to start over. */ + onSessionExpired: () => void; +} + +/** The fallback when no factor exists, or email picked from the configured code factors. */ +export const ConfigureVerifyEmailStep = ({ onCancel, onSessionExpired }: Props) => { + const sessionId = useConfigureMfaStore((s) => s.sessionId); + + const [code, setCode] = useState(null); + const [error, setError] = useState(null); + + const handleApiError = useMfaConfigErrorHandler({ + context: 'Email MFA configuration verification failed', + setError, + onSessionExpired, + fallback: 'Verification failed', + }); + + const { mutate: requestCode, isPending: isRequestingCode } = useMutation({ + mutationFn: async () => { + if (!isPresent(sessionId)) { + throw new Error('No MFA configuration session'); + } + await api.mfaConfigSendCode(sessionId); + }, + onError: handleApiError, + }); + + // A second call invalidates the code already sent, so fire once, StrictMode included. + const requestedFor = useRef(null); + useEffect(() => { + if (!isPresent(sessionId)) return; + if (requestedFor.current === sessionId) return; + requestedFor.current = sessionId; + requestCode(); + }, [sessionId, requestCode]); + + const { mutate: submitCode, isPending: isSubmitting } = useMutation({ + mutationFn: async (value: string) => { + if (!isPresent(sessionId)) { + throw new Error('No MFA configuration session'); + } + const result = await api.mfaConfigAuthorize(sessionId, MfaMethod.Email, value); + useConfigureMfaStore.getState().authorize(result); + }, + onError: handleApiError, + }); + + const { mutate: cancel, isPending: isCancelling } = useMutation({ + mutationFn: discardMfaConfiguration, + onSettled: onCancel, + }); + + const handleSubmit = useCallback( + (pastedCode?: string) => { + const toSubmit = (pastedCode ?? code)?.trim(); + if (toSubmit?.length !== CODE_LENGTH) { + setError('Enter a valid code'); + return; + } + submitCode(toSubmit); + }, + [code, submitCode], + ); + + // Only real input clears the error, CodeInput's own reset passes ''. + const handleCodeChange = useCallback((value: string) => { + setCode(value); + if (value.length > 0) setError(null); + }, []); + + return ( + + +

+ {`We've sent a verification code to your email address.`} + {`Please check your inbox and enter the 6-digit code from the email to continue.`} +

+
{ + if (e.key === 'Enter') handleSubmit(); + }} + > + { + handleSubmit(value); + }} + /> +
+ + + + ); + })} + + +