diff --git a/web/src/pages/auth/LoginMain/LoginMainPage.tsx b/web/src/pages/auth/LoginMain/LoginMainPage.tsx index c5edcbff8f..f10402f5e5 100644 --- a/web/src/pages/auth/LoginMain/LoginMainPage.tsx +++ b/web/src/pages/auth/LoginMain/LoginMainPage.tsx @@ -8,10 +8,12 @@ import { useAppForm } from '../../../shared/form'; import './style.scss'; import { revalidateLogic } from '@tanstack/react-form'; import { useMutation, useQuery } from '@tanstack/react-query'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import type { AxiosError } from 'axios'; import { useEffect, useMemo, useRef, useState } from 'react'; import api from '../../../shared/api/api'; -import type { OpenIdAuthInfo } from '../../../shared/api/types'; +import { getApiErrorMessage } from '../../../shared/api/apiErrorMessages'; +import { type OpenIdAuthInfo, WebErrorCode } from '../../../shared/api/types'; import { Divider } from '../../../shared/defguard-ui/components/Divider/Divider'; import { InfoBanner } from '../../../shared/defguard-ui/components/InfoBanner/InfoBanner'; import { OIDCButton } from '../../../shared/defguard-ui/components/SSOButton/OIDCButton'; @@ -19,6 +21,9 @@ import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import { createZodIssue } from '../../../shared/defguard-ui/utils/zod'; import { useAuth } from '../../../shared/hooks/useAuth'; +const isWebErrorCode = (value: string): value is WebErrorCode => + (Object.values(WebErrorCode) as string[]).includes(value); + const formSchema = z.object({ username: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), password: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), @@ -34,6 +39,19 @@ const defaults: FormFields = { export const LoginMainPage = () => { const [tooManyAttempts, setTooManyAttempts] = useState(false); const attemptsTimeoutRef = useRef(null); + const { authError } = useSearch({ from: '/auth/login' }); + const navigate = useNavigate({ from: '/auth/login' }); + const [authErrorMessage, setAuthErrorMessage] = useState(null); + + // authError arrives via URL search param (redirect from external IdP after OpenID/Entra failure); + // consume it into local state then strip from URL so refresh/back-nav doesn't re-show stale error. + useEffect(() => { + if (!isPresent(authError)) return; + setAuthErrorMessage( + isWebErrorCode(authError) ? getApiErrorMessage(authError) : authError, + ); + void navigate({ search: {}, replace: true }); + }, [authError, navigate]); const { data: openIdAuthInfo } = useQuery({ queryFn: api.openid.authInfo, @@ -99,6 +117,12 @@ export const LoginMainPage = () => {

{m.login_main_title()}

{m.login_main_subtitle()}

+ {isPresent(authErrorMessage) && ( + <> + + + + )} {tooManyAttempts && ( <> { + const code = (e as AxiosError).response?.data?.code; + if ( + code === WebErrorCode.UserGroupsNotSynced || + code === WebErrorCode.LicenseLimitReached + ) { + return code; + } + return undefined; +}; + +const searchSchema = z.union([ + z.object({ + code: z.string(), + state: z.string(), + }), + z.object({ + error: z.string(), + error_description: z.string().optional(), + state: z.string().optional(), + }), +]); // This is used when someone wants to login through a provider export const Route = createFileRoute('/auth/callback')({ validateSearch: searchSchema, loaderDeps: ({ search }) => ({ search }), loader: async ({ deps, context }) => { + const search = deps.search; + if ('error' in search) { + throw redirect({ + to: '/auth/login', + replace: true, + search: { authError: search.error_description ?? search.error }, + }); + } try { - const search = deps.search; const response = await api.openid.callback(search); setTimeout(() => { void context.queryClient.invalidateQueries({ @@ -31,16 +54,11 @@ export const Route = createFileRoute('/auth/callback')({ useAuth.getState().authSubject.next(response.data); }, 1000); } catch (e) { - const code = (e as AxiosError).response?.data?.code; - if ( - code === WebErrorCode.UserGroupsNotSynced || - code === WebErrorCode.LicenseLimitReached - ) { - setTimeout(() => { - Snackbar.error(getApiErrorMessage(code)); - }, 1000); - } - throw redirect({ to: '/auth/login', replace: true }); + throw redirect({ + to: '/auth/login', + replace: true, + search: { authError: getAuthErrorFromException(e) }, + }); } }, component: LoginLoadingPage, diff --git a/web/src/routes/auth/login.tsx b/web/src/routes/auth/login.tsx index 54d103c9d7..bcb6c14184 100644 --- a/web/src/routes/auth/login.tsx +++ b/web/src/routes/auth/login.tsx @@ -1,6 +1,12 @@ import { createFileRoute } from '@tanstack/react-router'; +import z from 'zod'; import { LoginMainPage } from '../../pages/auth/LoginMain/LoginMainPage'; +const searchSchema = z.object({ + authError: z.string().optional(), +}); + export const Route = createFileRoute('/auth/login')({ + validateSearch: searchSchema, component: LoginMainPage, });