Skip to content

Commit f4dc745

Browse files
Integrate AppBootstrap and split response interceptors based on flags (#1678)
<!-- Ensure the title clearly reflects what was changed. Provide a clear and concise description of the changes made. The PR should only contain the changes related to the issue, and no other unrelated changes. --> Part of OPS-3125 ## Additional Notes <!-- Why was it changed? Any relevant context or links? Add refactoring notes (if applicable), preferably as comments in the code (if you refactored code, explain what was moved/changed and why). --> ## Testing Checklist Check all that apply: - [x] I tested the feature thoroughly, including edge cases - [x] I verified all affected areas still work as expected - [x] Changes are backwards compatible with any existing data, otherwise a migration script is provided ## Visual Changes (if applicable) None <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * App shows a full-screen loading spinner while initializing on startup. * App initialization now fetches feature flags and completes bootstrap before rendering. * **Bug Fixes / Reliability** * Improved request/response handling to better manage authentication and error cases during runtime. * Startup errors are surfaced and prevent partial renders, reducing inconsistent states. * **Chores** * App render tree updated to initialize within a bootstrap and error boundary. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent bf5b658 commit f4dc745

10 files changed

Lines changed: 259 additions & 118 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { LoadingSpinner } from '@openops/components/ui';
2+
import { useEffect, useState } from 'react';
3+
import { QueryKeys } from './constants/query-keys';
4+
import { flagsApi, FlagsMap } from './lib/flags-api';
5+
import { initializeInternal } from './lib/initialize-internal';
6+
import { queryClient } from './lib/query-client';
7+
8+
type AppBootstrapProps = {
9+
children: React.ReactNode;
10+
};
11+
12+
type BootstrapState =
13+
| { status: 'loading' }
14+
| { status: 'ready' }
15+
| { status: 'error'; error: Error };
16+
17+
export function AppBootstrap({ children }: Readonly<AppBootstrapProps>) {
18+
const [state, setState] = useState<BootstrapState>({ status: 'loading' });
19+
20+
useEffect(() => {
21+
let mounted = true;
22+
23+
async function bootstrap() {
24+
try {
25+
const flags = await flagsApi.getAll();
26+
queryClient.setQueryData<FlagsMap>([QueryKeys.flags], flags);
27+
await initializeInternal();
28+
29+
if (mounted) {
30+
setState({ status: 'ready' });
31+
}
32+
} catch (error) {
33+
console.error('Bootstrap failed:', error);
34+
if (mounted) {
35+
setState({
36+
status: 'error',
37+
error:
38+
error instanceof Error
39+
? error
40+
: new Error('Failed to initialize application'),
41+
});
42+
}
43+
}
44+
}
45+
46+
bootstrap();
47+
48+
return () => {
49+
mounted = false;
50+
};
51+
}, []);
52+
53+
if (state.status === 'loading') {
54+
return (
55+
<div className="flex h-screen w-screen items-center justify-center">
56+
<LoadingSpinner size={50} />
57+
</div>
58+
);
59+
}
60+
61+
if (state.status === 'error') {
62+
throw state.error;
63+
}
64+
65+
return children;
66+
}

packages/react-ui/src/app/common/guards/allow-logged-in-user-only-guard.tsx

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import { SocketProvider } from '@/app/common/providers/socket-provider';
1313
// eslint-disable-next-line import/no-restricted-paths
1414
import { appConnectionsHooks } from '@/app/features/connections/lib/app-connections-hooks';
1515
import { authenticationSession } from '@/app/lib/authentication-session';
16+
import { getFronteggApp } from '@/app/lib/frontegg-setup';
1617
import { navigationUtil } from '@/app/lib/navigation-util';
18+
import { FlagId } from '@openops/shared';
1719
import { useQueryClient } from '@tanstack/react-query';
1820
import { userHooks } from '../hooks/user-hooks';
1921

@@ -32,6 +34,30 @@ function isJwtExpired(token: string): boolean {
3234
}
3335
}
3436

37+
const LoggedIn = ({ children }: { children: React.ReactNode }) => {
38+
const queryClient = useQueryClient();
39+
40+
projectHooks.prefetchProject();
41+
platformHooks.prefetchPlatform();
42+
platformHooks.prefetchNewerVersionInfo(queryClient);
43+
44+
userSettingsHooks.useUserSettings();
45+
userHooks.useUserMeta();
46+
appConnectionsHooks.useConnectionsMetadata();
47+
48+
return (
49+
<Suspense
50+
fallback={
51+
<div className=" flex h-screen w-screen items-center justify-center ">
52+
<LoadingSpinner size={50}></LoadingSpinner>
53+
</div>
54+
}
55+
>
56+
<SocketProvider>{children}</SocketProvider>
57+
</Suspense>
58+
);
59+
};
60+
3561
type AllowOnlyLoggedInUserOnlyGuardProps = {
3662
children: React.ReactNode;
3763
};
@@ -40,30 +66,32 @@ export const AllowOnlyLoggedInUserOnlyGuard = ({
4066
}: AllowOnlyLoggedInUserOnlyGuardProps) => {
4167
const location = useLocation();
4268
const navigate = useNavigate();
43-
const queryClient = useQueryClient();
69+
const { data: flags } = flagsHooks.useFlags();
70+
const { data: isFederatedLogin } = flagsHooks.useFlag<boolean | undefined>(
71+
FlagId.FEDERATED_LOGIN_ENABLED,
72+
);
4473

4574
const token = authenticationSession.getToken();
46-
const isLoggedIn = authenticationSession.isLoggedIn();
75+
const fronteggToken =
76+
getFronteggApp()?.store.getState().auth.user?.accessToken;
77+
const isLoggedIn = isFederatedLogin
78+
? fronteggToken
79+
: authenticationSession.isLoggedIn();
4780
const expired = !token || isJwtExpired(token);
4881

49-
projectHooks.prefetchProject();
50-
platformHooks.prefetchPlatform();
51-
platformHooks.prefetchNewerVersionInfo(queryClient);
52-
53-
const { data: flags } = flagsHooks.useFlags();
54-
userSettingsHooks.useUserSettings();
55-
userHooks.useUserMeta();
56-
appConnectionsHooks.useConnectionsMetadata();
57-
5882
useEffect(() => {
5983
let isMounted = true;
6084
async function doLogout() {
6185
try {
62-
await authenticationSession.logOut({
63-
userInitiated: false,
64-
navigate,
65-
federatedLoginUrl: getFederatedUrlBasedOnFlags(flags),
66-
});
86+
if (isFederatedLogin) {
87+
getFronteggApp()?.logout();
88+
} else {
89+
await authenticationSession.logOut({
90+
userInitiated: false,
91+
navigate,
92+
federatedLoginUrl: getFederatedUrlBasedOnFlags(flags),
93+
});
94+
}
6795
} catch (e) {
6896
if (isMounted) {
6997
console.error('Logout failed:', e);
@@ -84,21 +112,14 @@ export const AllowOnlyLoggedInUserOnlyGuard = ({
84112
location.search,
85113
navigate,
86114
flags,
115+
isFederatedLogin,
87116
]);
88117

89-
if (!isLoggedIn || expired) {
118+
if ((!isLoggedIn || expired) && !isFederatedLogin) {
90119
return <Navigate to="/sign-in" replace />;
120+
} else if (!isLoggedIn && isFederatedLogin) {
121+
return <Navigate to="/" replace />;
91122
}
92123

93-
return (
94-
<Suspense
95-
fallback={
96-
<div className=" flex h-screen w-screen items-center justify-center ">
97-
<LoadingSpinner size={50}></LoadingSpinner>
98-
</div>
99-
}
100-
>
101-
<SocketProvider>{children}</SocketProvider>
102-
</Suspense>
103-
);
124+
return <LoggedIn>{children}</LoggedIn>;
104125
};

packages/react-ui/src/app/common/guards/intial-data-guard.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import { LoadingSpinner } from '@openops/components/ui';
2-
import { Suspense } from 'react';
2+
import { Suspense, useEffect } from 'react';
33

44
import { flagsHooks } from '@/app/common/hooks/flags-hooks';
5+
import { setupResponseInterceptor } from '@/app/interceptors';
56

67
type InitialDataGuardProps = {
78
children: React.ReactNode;
89
};
9-
export const InitialDataGuard = ({ children }: InitialDataGuardProps) => {
10-
flagsHooks.prefetchFlags();
10+
export const InitialDataGuard = ({
11+
children,
12+
}: Readonly<InitialDataGuardProps>) => {
13+
const { data: flags } = flagsHooks.useFlags();
14+
15+
useEffect(() => {
16+
if (!flags) {
17+
console.error('Missing flags for response interceptor configuration');
18+
}
19+
setupResponseInterceptor({
20+
isFederatedAuth: Boolean(flags?.FEDERATED_LOGIN_ENABLED),
21+
});
22+
}, [flags]);
1123

1224
return (
1325
<Suspense

packages/react-ui/src/app/common/hooks/flags-hooks.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { usePrefetchQuery, useSuspenseQuery } from '@tanstack/react-query';
1+
import { useSuspenseQuery } from '@tanstack/react-query';
22

33
import { QueryKeys } from '@/app/constants/query-keys';
44
import { flagsApi, FlagsMap } from '@/app/lib/flags-api';
@@ -29,14 +29,6 @@ type WebsiteBrand = {
2929
};
3030

3131
export const flagsHooks = {
32-
prefetchFlags: () => {
33-
// eslint-disable-next-line react-hooks/rules-of-hooks
34-
usePrefetchQuery<FlagsMap, Error>({
35-
queryKey: [QueryKeys.flags],
36-
queryFn: flagsApi.getAll,
37-
staleTime: Infinity,
38-
});
39-
},
4032
useFlags: () => {
4133
return useSuspenseQuery<FlagsMap, Error>({
4234
queryKey: [QueryKeys.flags],

packages/react-ui/src/app/interceptors.ts

Lines changed: 0 additions & 77 deletions
This file was deleted.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import axios from 'axios';
2+
3+
import { createRequestInterceptor } from './request-interceptor';
4+
import {
5+
createFederatedResponseInterceptor,
6+
createResponseInterceptor,
7+
} from './response-interceptor';
8+
9+
let requestInterceptorId: number | null = null;
10+
let responseInterceptorId: number | null = null;
11+
12+
function setupRequestInterceptor(): void {
13+
if (requestInterceptorId === null) {
14+
const requestInterceptor = createRequestInterceptor();
15+
requestInterceptorId = axios.interceptors.request.use(requestInterceptor);
16+
}
17+
}
18+
19+
setupRequestInterceptor();
20+
21+
type ResponseInterceptorOptions = {
22+
isFederatedAuth: boolean;
23+
};
24+
25+
export function setupResponseInterceptor({
26+
isFederatedAuth,
27+
}: ResponseInterceptorOptions): void {
28+
if (responseInterceptorId === null) {
29+
const responseInterceptor = isFederatedAuth
30+
? createFederatedResponseInterceptor()
31+
: createResponseInterceptor();
32+
responseInterceptorId = axios.interceptors.response.use(
33+
responseInterceptor.onFulfilled,
34+
responseInterceptor.onRejected,
35+
);
36+
}
37+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { API_URL, isUrlRelative } from '@/app/lib/api';
2+
import { authenticationSession } from '@/app/lib/authentication-session';
3+
import { InternalAxiosRequestConfig } from 'axios';
4+
5+
const unauthenticatedRoutes = [
6+
'/v1/authentication/sign-in',
7+
'/v1/authentication/sign-up',
8+
'/v1/authn/local/verify-email',
9+
'/v1/flags',
10+
'/v1/forms/',
11+
'/v1/user-invitations/accept',
12+
];
13+
14+
const needsAuthHeader = (url: string): boolean => {
15+
const resolvedUrl = !isUrlRelative(url) ? url : `${API_URL}${url}`;
16+
const isLocalUrl = resolvedUrl.includes(API_URL);
17+
const isUnauthenticatedRoute = unauthenticatedRoutes.some((route) =>
18+
url.startsWith(route),
19+
);
20+
21+
return !isUnauthenticatedRoute && isLocalUrl;
22+
};
23+
24+
export function createRequestInterceptor(): (
25+
config: InternalAxiosRequestConfig,
26+
) => InternalAxiosRequestConfig {
27+
return (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
28+
const token = authenticationSession.getToken();
29+
if (token && config.url && needsAuthHeader(config.url)) {
30+
config.headers.Authorization = `Bearer ${token}`;
31+
}
32+
return config;
33+
};
34+
}

0 commit comments

Comments
 (0)