diff --git a/package-lock.json b/package-lock.json index 8291946..8ca393d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,8 @@ "react-feather": "^2.0.10", "react-responsive": "^10.0.0", "react-router-dom": "^6.26.2", - "vite-express": "0.16.0" + "vite-express": "0.16.0", + "zustand": "^5.0.2" }, "devDependencies": { "@types/react": "^18.3.2", @@ -5728,6 +5729,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.2.tgz", + "integrity": "sha512-8qNdnJVJlHlrKXi50LDqqUNmUbuBjoKLrYQBnoChIbVph7vni+sY+YpvdjXG9YLd/Bxr6scMcR+rm5H3aSqPaw==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 9030925..1a88de4 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "react-feather": "^2.0.10", "react-responsive": "^10.0.0", "react-router-dom": "^6.26.2", - "vite-express": "0.16.0" + "vite-express": "0.16.0", + "zustand": "^5.0.2" }, "devDependencies": { "@types/react": "^18.3.2", diff --git a/src/App.tsx b/src/App.tsx index a65eef4..68fff38 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,12 +17,16 @@ const App = () => { }> } /> - } /> - } /> - } /> - } /> - } />} /> - } />} /> + }> + } /> + + }> + } /> + } /> + + }> + } /> + } /> diff --git a/src/components/ProtectedAuthorizedUserRoute.tsx b/src/components/ProtectedAuthorizedUserRoute.tsx index 7ff419f..1aa7a1d 100644 --- a/src/components/ProtectedAuthorizedUserRoute.tsx +++ b/src/components/ProtectedAuthorizedUserRoute.tsx @@ -1,29 +1,43 @@ -import React, { useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import NotFound from '../pages/NotFound/NotFound.tsx' import { User } from '../services/userProfile' import { isAuthorizedToCreateTeam } from '../services/createTeam' -import { Effect } from 'effect' +import { Effect, Option as O } from 'effect' import { isDaplaAdmin } from '../utils/services' +import { option } from '../utils/utils' +import { Skeleton } from '@mui/material' +import PageLayout from './PageLayout/PageLayout.tsx' +import { Outlet } from 'react-router-dom' +import { useUserProfileStore } from '../services/store.ts' -export interface Props { - component: React.ReactElement -} +const ProtectedAuthorizedUserRoute = () => { + // O.none() here represents the loading state + const [oIsAuthorized, setIsAuthorized] = useState>(O.none()) + const maybeUser: O.Option = useUserProfileStore((state) => state.loggedInUser) -const ProtectedAuthorizedUserRoute = ({ component }: Props) => { - const [isAuthorized, setIsAuthorized] = useState(false) useEffect(() => { - const userProfileItem = localStorage.getItem('userProfile') - if (!userProfileItem) return + Effect.gen(function* () { + const user: User = yield* O.match(maybeUser, { + onNone: () => Effect.fail(new Error('User not logged in!')), + onSome: (user) => Effect.succeed(user), + }) - const user = JSON.parse(userProfileItem) as User - if (!user) return + const daplaAdmin: boolean = yield* Effect.promise(() => isDaplaAdmin(user.principal_name)) - Effect.promise(() => isDaplaAdmin(user.principal_name)) - .pipe(Effect.runPromise) - .then((isDaplaAdmin: boolean) => setIsAuthorized(isAuthorizedToCreateTeam(isDaplaAdmin, user.job_title))) - }, []) + yield* Effect.sync(() => setIsAuthorized(O.some(isAuthorizedToCreateTeam(daplaAdmin, user.job_title)))) + }).pipe(Effect.runPromise) + }) - return isAuthorized ? component : + return option( + oIsAuthorized, + () => ( + } + /> + ), + (isAuthorized) => (isAuthorized ? : ) + ) } export default ProtectedAuthorizedUserRoute diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 104a90c..affa812 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -5,27 +5,28 @@ import { fetchUserInformationFromAuthToken } from '../utils/services' import { Cause, Effect, Option as O } from 'effect' import { customLogger } from '../utils/logger.ts' import { ApiError } from '../utils/services.ts' +import { useUserProfileStore } from '../services/store.ts' import { User } from '../services/userProfile.ts' const ProtectedRoute = () => { const [isAuthenticated, setIsAuthenticated] = useState(false) + const setLoggedInUser = useUserProfileStore((state) => state.setLoggedInUser) const navigate = useNavigate() const from = location.pathname - const fetchUserProfile = (): Effect.Effect => - Effect.gen(function* () { - const userProfileData = yield* Effect.promise(fetchUserInformationFromAuthToken) - const userProfile = yield* Effect.tryPromise(() => getUserProfile(userProfileData.email)).pipe( - Effect.flatMap((x) => (x instanceof ApiError ? Effect.fail(x) : Effect.succeed(x))), - Effect.map(JSON.stringify) - ) - yield* Effect.logInfo(`UserProfile set in localStorage: ${userProfile}`) - yield* Effect.sync(() => localStorage.setItem('userProfile', userProfile)) - yield* Effect.sync(() => setIsAuthenticated(true)) - }).pipe(Effect.provide(customLogger)) - useEffect(() => { + const fetchUserProfile = (): Effect.Effect => + Effect.gen(function* () { + const userProfileData = yield* Effect.promise(fetchUserInformationFromAuthToken) + const userProfile = yield* Effect.tryPromise(() => getUserProfile(userProfileData.email)).pipe( + Effect.flatMap((x) => (x instanceof ApiError ? Effect.fail(x) : Effect.succeed(x))) + ) + yield* Effect.sync(() => localStorage.setItem('userProfile', JSON.stringify(userProfile))) + yield* Effect.sync(() => setLoggedInUser(userProfile)) + yield* Effect.sync(() => setIsAuthenticated(true)) + }).pipe(Effect.provide(customLogger)) + const cachedUserProfile: O.Option = O.fromNullable(localStorage.getItem('userProfile')).pipe( O.flatMap(O.liftThrowable(JSON.parse)) ) @@ -34,13 +35,16 @@ const ProtectedRoute = () => { onSome: (userProfile) => // invalidate cached user profile if 'job_title' field is missing userProfile.job_title - ? Effect.sync(() => setIsAuthenticated(true)) + ? Effect.zip( + Effect.sync(() => setIsAuthenticated(true)), + Effect.sync(() => setLoggedInUser(userProfile)) + ) : Effect.zipRight( Effect.logInfo("'job_title' field missing, invalidating UserProfile cache"), fetchUserProfile() ).pipe(Effect.provide(customLogger)), }).pipe(Effect.runPromise) - }, [from, navigate]) + }, [from, navigate, setLoggedInUser]) return isAuthenticated ? : null } diff --git a/src/services/store.ts b/src/services/store.ts new file mode 100644 index 0000000..d0bb40c --- /dev/null +++ b/src/services/store.ts @@ -0,0 +1,28 @@ +import { create } from 'zustand' +import { subscribeWithSelector } from 'zustand/middleware' +import { User } from '../services/userProfile.ts' +import { Effect, Option as O } from 'effect' + +import { customLogger } from '../utils/logger.ts' + +type UserProfileStoreState = { + loggedInUser: O.Option +} + +type UserProfileStoreActions = { + setLoggedInUser: (user: User) => void +} + +export type UserProfileStore = UserProfileStoreState & UserProfileStoreActions + +export const useUserProfileStore = create()( + subscribeWithSelector((set) => ({ + loggedInUser: O.none(), + setLoggedInUser: (user: User) => set(() => ({ loggedInUser: O.some(user) })), + })) +) + +useUserProfileStore.subscribe( + (state) => state.loggedInUser, + (user) => Effect.log('USER LOGGED IN:', O.getOrNull(user)).pipe(Effect.provide(customLogger), Effect.runPromise) +)