From e12973741aec9d77307653d775dd8dd5472785a4 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Thu, 14 Nov 2024 15:56:52 +0100 Subject: [PATCH] add frontend --- proto | 2 +- src/handlers/mod.rs | 7 + src/handlers/openid_login.rs | 34 ++-- src/http.rs | 4 +- web/src/components/App/App.tsx | 5 + .../pages/openidCallback/OpenIDCallback.tsx | 15 ++ .../components/OpenIDCallbackCard.tsx | 176 +++++++++++++++++ .../openidCallback/components/style.scss | 107 ++++++++++ web/src/pages/openidCallback/style.scss | 20 ++ .../pages/token/components/OIDCButtons.tsx | 183 ++++++++++++++++++ web/src/pages/token/components/TokenCard.tsx | 107 ++++++---- web/src/pages/token/components/style.scss | 153 +++++++++++++-- web/src/pages/token/style.scss | 6 - .../layout/BigInfoBox/BigInfoBox.tsx | 35 ++++ .../components/layout/BigInfoBox/style.scss | 46 +++++ web/src/shared/hooks/api/types.ts | 5 + web/src/shared/hooks/api/useApi.tsx | 15 ++ web/src/shared/routes.ts | 1 + web/src/shared/scss/helpers/_typography.scss | 21 ++ 19 files changed, 872 insertions(+), 70 deletions(-) create mode 100644 web/src/pages/openidCallback/OpenIDCallback.tsx create mode 100644 web/src/pages/openidCallback/components/OpenIDCallbackCard.tsx create mode 100644 web/src/pages/openidCallback/components/style.scss create mode 100644 web/src/pages/openidCallback/style.scss create mode 100644 web/src/pages/token/components/OIDCButtons.tsx create mode 100644 web/src/shared/components/layout/BigInfoBox/BigInfoBox.tsx create mode 100644 web/src/shared/components/layout/BigInfoBox/style.scss diff --git a/proto b/proto index 8309982..b9adb0b 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 8309982b94e82a7cbe39dd529967f43e49b3ef1d +Subproject commit b9adb0bc87228c88c42f144caa47c8b69a3fb98c diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e2a1c9c..cb22988 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -4,6 +4,7 @@ use axum::{extract::FromRequestParts, http::request::Parts}; use axum_client_ip::{InsecureClientIp, LeftmostXForwardedFor}; use axum_extra::{headers::UserAgent, TypedHeader}; use tokio::{sync::oneshot::Receiver, time::timeout}; +use tonic::Code; use super::proto::DeviceInfo; use crate::{error::ApiError, proto::core_response::Payload}; @@ -53,6 +54,12 @@ async fn get_core_response(rx: Receiver) -> Result { if let Ok(core_response) = timeout(CORE_RESPONSE_TIMEOUT, rx).await { debug!("Got gRPC response from Defguard core: {core_response:?}"); if let Ok(Payload::CoreError(core_error)) = core_response { + if core_error.status_code == Code::FailedPrecondition as i32 + && core_error.message == "no valid license" + { + debug!("Tried to get core response related to an enterprise feature but the enterprise is not enabled, ignoring it..."); + return Err(ApiError::EnterpriseNotEnabled); + } error!( "Received an error response from the core service. | status code: {} message: {}", core_error.status_code, core_error.message diff --git a/src/handlers/openid_login.rs b/src/handlers/openid_login.rs index c000e02..cdf07d8 100644 --- a/src/handlers/openid_login.rs +++ b/src/handlers/openid_login.rs @@ -14,7 +14,9 @@ use crate::{ error::ApiError, handlers::get_core_response, http::AppState, - proto::{core_request, core_response, AuthCallbackRequest, AuthInfoRequest}, + proto::{ + core_request, core_response, AuthCallbackRequest, AuthCallbackResponse, AuthInfoRequest, + }, }; const COOKIE_MAX_AGE: Duration = Duration::days(1); @@ -30,12 +32,16 @@ pub(crate) fn router() -> Router { #[derive(Serialize)] struct AuthInfo { url: String, + button_display_name: Option, } impl AuthInfo { #[must_use] - fn new(url: String) -> Self { - Self { url } + fn new(url: String, button_display_name: Option) -> Self { + Self { + url, + button_display_name, + } } } @@ -76,7 +82,7 @@ async fn auth_info( .build(); let private_cookies = private_cookies.add(nonce_cookie).add(csrf_cookie); - let auth_info = AuthInfo::new(response.url); + let auth_info = AuthInfo::new(response.url, response.button_display_name); Ok((private_cookies, Json(auth_info))) } else { error!("Received invalid gRPC response type: {payload:#?}"); @@ -86,16 +92,22 @@ async fn auth_info( #[derive(Debug, Deserialize)] pub struct AuthenticationResponse { - id_token: String, + code: String, state: String, } +#[derive(Serialize)] +struct CallbackResponseData { + url: String, + token: String, +} + #[instrument(level = "debug", skip(state))] async fn auth_callback( State(state): State, mut private_cookies: PrivateCookieJar, Json(payload): Json, -) -> Result { +) -> Result<(PrivateCookieJar, Json), ApiError> { let nonce = private_cookies .get(NONCE_COOKIE_NAME) .ok_or(ApiError::Unauthorized("Nonce cookie not found".into()))? @@ -116,7 +128,7 @@ async fn auth_callback( .remove(Cookie::from(CSRF_COOKIE_NAME)); let request = AuthCallbackRequest { - id_token: payload.id_token, + code: payload.code, nonce, callback_url: state.callback_url().to_string(), }; @@ -125,11 +137,11 @@ async fn auth_callback( .grpc_server .send(Some(core_request::Payload::AuthCallback(request)), None)?; let payload = get_core_response(rx).await?; - if let core_response::Payload::Empty(()) = payload { - debug!("Received auth callback response (empty message)"); - Ok(private_cookies) + if let core_response::Payload::AuthCallback(AuthCallbackResponse { url, token }) = payload { + debug!("Received auth callback response {url:?} {token:?}"); + Ok((private_cookies, Json(CallbackResponseData { url, token }))) } else { - error!("Received invalid gRPC response type: {payload:#?}"); + error!("Received invalid gRPC response type during handling the OpenID authentication callback: {payload:#?}"); Err(ApiError::InvalidResponseType) } } diff --git a/src/http.rs b/src/http.rs index 7278221..b910d61 100644 --- a/src/http.rs +++ b/src/http.rs @@ -50,9 +50,9 @@ impl AppState { #[must_use] pub(crate) fn callback_url(&self) -> Url { let mut url = self.url.clone(); - // Append "/api/v1/openid/callback" to the URL. + // Append "/openid/callback" to the URL. if let Ok(mut path_segments) = url.path_segments_mut() { - path_segments.extend(&["api", "v1", "openid", "callback"]); + path_segments.extend(&["openid", "callback"]); } url } diff --git a/web/src/components/App/App.tsx b/web/src/components/App/App.tsx index e7d0289..6ecea23 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -22,6 +22,7 @@ import { PasswordResetPage } from '../../pages/passwordReset/PasswordResetPage'; import { SessionTimeoutPage } from '../../pages/sessionTimeout/SessionTimeoutPage'; import { TokenPage } from '../../pages/token/TokenPage'; import { routes } from '../../shared/routes'; +import { OpenIDCallbackPage } from '../../pages/openidCallback/OpenIDCallback'; dayjs.extend(duration); dayjs.extend(utc); @@ -52,6 +53,10 @@ const router = createBrowserRouter([ path: routes.passwordReset, element: , }, + { + path: routes.openidCallback, + element: , + }, { path: '/*', element: , diff --git a/web/src/pages/openidCallback/OpenIDCallback.tsx b/web/src/pages/openidCallback/OpenIDCallback.tsx new file mode 100644 index 0000000..0097de5 --- /dev/null +++ b/web/src/pages/openidCallback/OpenIDCallback.tsx @@ -0,0 +1,15 @@ +import './style.scss'; + +import { LogoContainer } from '../../components/LogoContainer/LogoContainer'; +import { PageContainer } from '../../shared/components/layout/PageContainer/PageContainer'; +import { OpenIDCallbackCard } from './components/OpenIDCallbackCard'; +// import { TokenCard } from './components/TokenCard'; + +export const OpenIDCallbackPage = () => { + return ( + + + + + ); +}; diff --git a/web/src/pages/openidCallback/components/OpenIDCallbackCard.tsx b/web/src/pages/openidCallback/components/OpenIDCallbackCard.tsx new file mode 100644 index 0000000..962313b --- /dev/null +++ b/web/src/pages/openidCallback/components/OpenIDCallbackCard.tsx @@ -0,0 +1,176 @@ +import './style.scss'; + +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { useBreakpoint } from 'use-breakpoint'; + +import { AxiosError } from 'axios'; +import { useI18nContext } from '../../../i18n/i18n-react'; +import { ActionButton } from '../../../shared/components/layout/ActionButton/ActionButton'; +import { ActionButtonVariant } from '../../../shared/components/layout/ActionButton/types'; +import { BigInfoBox } from '../../../shared/components/layout/BigInfoBox/BigInfoBox'; +import { Button } from '../../../shared/components/layout/Button/Button'; +import { ButtonStyleVariant } from '../../../shared/components/layout/Button/types'; +import { Card } from '../../../shared/components/layout/Card/Card'; +import { Input } from '../../../shared/components/layout/Input/Input'; +import { LoaderSpinner } from '../../../shared/components/layout/LoaderSpinner/LoaderSpinner'; +import { MessageBox } from '../../../shared/components/layout/MessageBox/MessageBox'; +import { MessageBoxType } from '../../../shared/components/layout/MessageBox/types'; +import SvgIconDownload from '../../../shared/components/svg/IconDownload'; +import { deviceBreakpoints } from '../../../shared/constants'; +import { useApi } from '../../../shared/hooks/api/useApi'; + +type ErrorResponse = { + error: string; +}; + +export const OpenIDCallbackCard = () => { + const { openIDCallback } = useApi(); + const { breakpoint } = useBreakpoint(deviceBreakpoints); + const { LL } = useI18nContext(); + const [error, setError] = useState(null); + + const { isLoading, data } = useQuery( + [], + () => { + const hashFragment = window.location.hash.substring(1); + const params = new URLSearchParams(hashFragment); + const error = params.get('error'); + if (error) { + setError(error); + return; + } + const id_token = params.get('id_token'); + const state = params.get('state'); + if (!id_token || !state) { + setError( + "Missing id_token or state in the callback's URL. The provider might not be configured correctly.", + ); + return; + } + if (id_token && state) { + return openIDCallback({ + id_token, + state, + }); + } + }, + { + retry: false, + refetchOnWindowFocus: false, + refetchOnMount: false, + onError: (error: AxiosError) => { + console.error(error); + const errorResponse = error.response?.data as ErrorResponse; + console.log('errorResponse', errorResponse); + if (errorResponse.error) { + setError(errorResponse.error); + } else { + setError(String(error)); + } + }, + onSuccess(data) { + if (!data?.token || !data?.url) { + setError("The server's response is missing the token or url."); + } + }, + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + + + ); +}; + +const CustomButton = ({ url }: { url: string }) => { + return ( + + ); +}; diff --git a/web/src/pages/token/components/TokenCard.tsx b/web/src/pages/token/components/TokenCard.tsx index b6d38d4..3531d94 100644 --- a/web/src/pages/token/components/TokenCard.tsx +++ b/web/src/pages/token/components/TokenCard.tsx @@ -1,9 +1,9 @@ import './style.scss'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { useBreakpoint } from 'use-breakpoint'; @@ -28,6 +28,9 @@ import { deviceBreakpoints } from '../../../shared/constants'; import { useApi } from '../../../shared/hooks/api/useApi'; import { routes } from '../../../shared/routes'; import { useEnrollmentStore } from '../../enrollment/hooks/store/useEnrollmentStore'; +import { BigInfoBox } from '../../../shared/components/layout/BigInfoBox/BigInfoBox'; +import { LoaderSpinner } from '../../../shared/components/layout/LoaderSpinner/LoaderSpinner'; +import { OpenIdLoginButton } from './OIDCButtons'; type FormFields = { token: string; @@ -37,6 +40,7 @@ export const TokenCard = () => { const navigate = useNavigate(); const { enrollment: { start: startEnrollment }, + getOpenIDAuthInfo, } = useApi(); const { breakpoint } = useBreakpoint(deviceBreakpoints); const { LL } = useI18nContext(); @@ -53,6 +57,7 @@ export const TokenCard = () => { .required(), [LL.pages.token.card.form.errors.token], ); + const [openIDUrl, setOpenIDUrl] = useState(null); const { control, handleSubmit, setError } = useForm({ mode: 'all', @@ -62,6 +67,23 @@ export const TokenCard = () => { resolver: zodResolver(schema), }); + // useEffect(() => { + // getOpenIDAuthInfo().then((res) => { + // console.log(res); + // }); + // }); + + const { isLoading: openidLoading, data: openidData } = useQuery( + [], + () => getOpenIDAuthInfo(), + { + refetchOnMount: true, + refetchOnWindowFocus: false, + }, + ); + + console.log(openidLoading); + const { isLoading, mutate } = useMutation({ mutationFn: startEnrollment, onSuccess: (res) => { @@ -100,42 +122,19 @@ export const TokenCard = () => { } }; + if (openidLoading) { + return ( +
+ +
+ ); + } + return ( <> -
-

{LL.pages.token.card.title()}

- +
{ required />
+ {openidData?.url && ( + <> +

Or Sign In with External SSO

+ +
+ +
+ + )} +
+
); diff --git a/web/src/pages/token/components/style.scss b/web/src/pages/token/components/style.scss index 94ba251..442abf9 100644 --- a/web/src/pages/token/components/style.scss +++ b/web/src/pages/token/components/style.scss @@ -16,22 +16,143 @@ & > form { margin-top: 40px; - .controls { - width: 100%; - margin-top: 15px; - .btn { - width: 100%; - svg { - g { - fill: var(--surface-icon-secondary); - } - } - .arrow-single { - width: 100%; - height: 100%; - } - } - } } } + + .loader-container { + min-height: 300px; + display: flex; + justify-content: center; + align-items: center; + } + + .openid-button { + display: flex; + justify-content: center; + padding: 50px 0; + } + + .openid-heading { + margin-top: 20px; + } +} + +.gsi-material-button { + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + -webkit-appearance: none; + background-color: WHITE; + background-image: none; + border: 1px solid #747775; + -webkit-border-radius: 4px; + border-radius: 4px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + color: #1f1f1f; + cursor: pointer; + font-family: 'Roboto', arial, sans-serif; + font-size: 14px; + letter-spacing: 0.25px; + outline: none; + overflow: hidden; + padding: 10px 30px; + position: relative; + text-align: center; + -webkit-transition: + background-color 0.218s, + border-color 0.218s, + box-shadow 0.218s; + transition: + background-color 0.218s, + border-color 0.218s, + box-shadow 0.218s; + vertical-align: middle; + white-space: nowrap; + width: auto; + max-width: 400px; + min-width: min-content; +} + +.gsi-material-button .gsi-material-button-icon { + height: 20px; + margin-right: 12px; + min-width: 20px; + width: 20px; +} + +.gsi-material-button .gsi-material-button-content-wrapper { + -webkit-align-items: center; + align-items: center; + display: flex; + -webkit-flex-direction: row; + flex-direction: row; + -webkit-flex-wrap: nowrap; + flex-wrap: nowrap; + height: 100%; + justify-content: space-between; + position: relative; + width: 100%; +} + +.gsi-material-button .gsi-material-button-contents { + -webkit-flex-grow: 1; + flex-grow: 1; + font-family: 'Roboto', arial, sans-serif; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; +} + +.gsi-material-button .gsi-material-button-state { + -webkit-transition: opacity 0.218s; + transition: opacity 0.218s; + bottom: 0; + left: 0; + opacity: 0; + position: absolute; + right: 0; + top: 0; +} + +.gsi-material-button:disabled { + cursor: default; + background-color: #ffffff61; + border-color: #1f1f1f1f; +} + +.gsi-material-button:disabled .gsi-material-button-contents { + opacity: 38%; +} + +.gsi-material-button:disabled .gsi-material-button-icon { + opacity: 38%; +} + +.gsi-material-button:not(:disabled):active .gsi-material-button-state, +.gsi-material-button:not(:disabled):focus .gsi-material-button-state { + background-color: #303030; + opacity: 12%; +} + +.gsi-material-button:not(:disabled):hover { + -webkit-box-shadow: + 0 1px 2px 0 rgba(60, 64, 67, 0.3), + 0 1px 3px 1px rgba(60, 64, 67, 0.15); + box-shadow: + 0 1px 2px 0 rgba(60, 64, 67, 0.3), + 0 1px 3px 1px rgba(60, 64, 67, 0.15); +} + +.gsi-material-button:not(:disabled):hover .gsi-material-button-state { + background-color: #303030; + opacity: 8%; +} + +.ms-button { + background-color: transparent; + border: none; + cursor: pointer; + padding: 0; } diff --git a/web/src/pages/token/style.scss b/web/src/pages/token/style.scss index 96b809d..03ad499 100644 --- a/web/src/pages/token/style.scss +++ b/web/src/pages/token/style.scss @@ -27,12 +27,6 @@ column-gap: 32px; width: 100%; box-sizing: border-box; - padding: 0 20px; - margin-bottom: 50px; - - @include media-breakpoint-up(md) { - padding: 0 135px; - } &.single { grid-template-columns: 1fr; diff --git a/web/src/shared/components/layout/BigInfoBox/BigInfoBox.tsx b/web/src/shared/components/layout/BigInfoBox/BigInfoBox.tsx new file mode 100644 index 0000000..2b264bc --- /dev/null +++ b/web/src/shared/components/layout/BigInfoBox/BigInfoBox.tsx @@ -0,0 +1,35 @@ +import './style.scss'; + +import { isUndefined } from 'lodash-es'; +import { HTMLProps, ReactNode, useMemo } from 'react'; + +import SvgIconInfo from '../../svg/IconInfo'; + +interface Props extends HTMLProps { + message?: string | ReactNode; + children?: ReactNode; +} + +/** + * Big infobox with a message. + */ +export const BigInfoBox = ({ message, className, children, ...props }: Props) => { + const renderMessage = useMemo(() => { + if (!isUndefined(children)) { + return children; + } + if (typeof message === 'string') { + return

{message}

; + } + return message; + }, [message, children]); + + return ( +
+
+ +
+
{renderMessage}
+
+ ); +}; diff --git a/web/src/shared/components/layout/BigInfoBox/style.scss b/web/src/shared/components/layout/BigInfoBox/style.scss new file mode 100644 index 0000000..7f39d7e --- /dev/null +++ b/web/src/shared/components/layout/BigInfoBox/style.scss @@ -0,0 +1,46 @@ +@use '../../../scss/helpers' as *; + +.big-info-box { + height: auto; + width: 100%; + display: flex; + align-items: center; + justify-items: center; + gap: 20px; + border-radius: 10px; + border: 1px solid var(--surface-tag-modal); + box-sizing: border-box; + padding: 20px; + + & > .icon-container { + display: flex; + align-items: center; + + & > svg { + width: 42px; + height: 42px; + } + } + + .message { + box-sizing: border-box; + p, + span, + b, + strong { + @include typography(infobox-message); + } + + b, + strong { + font-weight: 700; + } + + p, + span { + color: inherit; + max-width: 100%; + white-space: normal; + } + } +} diff --git a/web/src/shared/hooks/api/types.ts b/web/src/shared/hooks/api/types.ts index 170e086..edf1a1d 100644 --- a/web/src/shared/hooks/api/types.ts +++ b/web/src/shared/hooks/api/types.ts @@ -93,4 +93,9 @@ export type UseApi = { reset: (data: PasswordResetRequest) => Promise; }; getAppInfo: () => Promise; + getOpenIDAuthInfo: () => Promise<{ url: string }>; + openIDCallback: (data: { id_token: string; state: string }) => Promise<{ + token: string; + url: string; + }>; }; diff --git a/web/src/shared/hooks/api/useApi.tsx b/web/src/shared/hooks/api/useApi.tsx index 2c2c89f..53403d2 100644 --- a/web/src/shared/hooks/api/useApi.tsx +++ b/web/src/shared/hooks/api/useApi.tsx @@ -33,6 +33,19 @@ export const useApi = (): UseApi => { const resetPassword: UseApi['passwordReset']['reset'] = (data) => client.post('/password-reset/reset', data).then(unpackRequest); + const getOpenIDAuthInfo: UseApi['getOpenIDAuthInfo'] = () => + client + .get('/openid/auth_info') + .then((res) => res.data) + .catch((_) => { + return { + url: null, + }; + }); + + const openIDCallback: UseApi['openIDCallback'] = (data) => + client.post('/openid/callback', data).then(unpackRequest); + return { enrollment: { start: startEnrollment, @@ -45,5 +58,7 @@ export const useApi = (): UseApi => { reset: resetPassword, }, getAppInfo, + getOpenIDAuthInfo, + openIDCallback, }; }; diff --git a/web/src/shared/routes.ts b/web/src/shared/routes.ts index 26fcd98..97c5e53 100644 --- a/web/src/shared/routes.ts +++ b/web/src/shared/routes.ts @@ -4,4 +4,5 @@ export const routes = { token: '/token', timeout: '/timeout', passwordReset: '/password-reset', + openidCallback: '/openid/callback', }; diff --git a/web/src/shared/scss/helpers/_typography.scss b/web/src/shared/scss/helpers/_typography.scss index aac40a5..9daa510 100644 --- a/web/src/shared/scss/helpers/_typography.scss +++ b/web/src/shared/scss/helpers/_typography.scss @@ -219,4 +219,25 @@ text-decoration: underline; font-weight: 500; } + + @if $value ==infobox-message { + font-family: 'Poppins'; + font-size: 15px; + line-height: 23px; + font-weight: 400; + } + + @if $value ==openidcallback-steps { + font-family: 'Poppins'; + font-size: 15px; + line-height: 23px; + font-weight: 400; + } + + @if $value ==client-download-button { + font-family: 'Poppins'; + font-size: 15px; + line-height: normal; + font-weight: 600; + } }