Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion web/src/pages/auth/LoginMain/LoginMainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,22 @@ 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';
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()),
Expand All @@ -34,6 +39,19 @@ const defaults: FormFields = {
export const LoginMainPage = () => {
const [tooManyAttempts, setTooManyAttempts] = useState(false);
const attemptsTimeoutRef = useRef<number | null>(null);
const { authError } = useSearch({ from: '/auth/login' });
const navigate = useNavigate({ from: '/auth/login' });
const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(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(() => {
Comment thread
t-aleksander marked this conversation as resolved.
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,
Expand Down Expand Up @@ -99,6 +117,12 @@ export const LoginMainPage = () => {
<h1>{m.login_main_title()}</h1>
<h2>{m.login_main_subtitle()}</h2>
<SizedBox height={ThemeSize.Xl3} />
{isPresent(authErrorMessage) && (
<>
<InfoBanner variant="warning" text={authErrorMessage} icon="info-outlined" />
<SizedBox height={ThemeSpacing.Xl2} />
</>
)}
{tooManyAttempts && (
<>
<InfoBanner
Expand Down
52 changes: 35 additions & 17 deletions web/src/routes/auth/callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,46 @@ import type { AxiosError } from 'axios';
import z from 'zod';
import { LoginLoadingPage } from '../../pages/auth/LoginLoading/LoginLoadingPage';
import api from '../../shared/api/api';
import { getApiErrorMessage } from '../../shared/api/apiErrorMessages';
import { type ApiError, WebErrorCode } from '../../shared/api/types';
import { Snackbar } from '../../shared/defguard-ui/providers/snackbar/snackbar';
import { useAuth } from '../../shared/hooks/useAuth';

const searchSchema = z.object({
code: z.string(),
state: z.string(),
});
const getAuthErrorFromException = (e: unknown): WebErrorCode | undefined => {
const code = (e as AxiosError<ApiError>).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({
Expand All @@ -31,16 +54,11 @@ export const Route = createFileRoute('/auth/callback')({
useAuth.getState().authSubject.next(response.data);
}, 1000);
} catch (e) {
const code = (e as AxiosError<ApiError>).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,
Expand Down
6 changes: 6 additions & 0 deletions web/src/routes/auth/login.tsx
Original file line number Diff line number Diff line change
@@ -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,
});
Loading