diff --git a/backend b/backend index 69450f82..b83328d0 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 69450f82253f675cf923779396f46cb2e809dcf8 +Subproject commit b83328d0dcb3c1d037b92af0e45b1c98e3d76248 diff --git a/src/App/Auth.tsx b/src/App/Auth.tsx new file mode 100644 index 00000000..0cbc1ee7 --- /dev/null +++ b/src/App/Auth.tsx @@ -0,0 +1,45 @@ +import { + Fragment, + type ReactElement, +} from 'react'; +import { Navigate } from 'react-router-dom'; + +import useAuth from '#hooks/domain/useAuth'; + +import { type ExtendedProps } from './routes/common'; + +interface Props { + children: ReactElement, + context: ExtendedProps, + absolutePath: string, +} +function Auth(props: Props) { + const { + context, + children, + absolutePath, + } = props; + + const { isAuthenticated } = useAuth(); + + if (context.visibility === 'is-authenticated' && !isAuthenticated) { + return ( + + ); + } + if (context.visibility === 'is-not-authenticated' && isAuthenticated) { + return ( + + ); + } + + return ( + + {children} + + ); +} + +export default Auth; diff --git a/src/App/index.tsx b/src/App/index.tsx index 8e16d45e..e703de75 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -10,6 +10,10 @@ import { createBrowserRouter, RouterProvider, } from 'react-router-dom'; +import { + gql, + useQuery, +} from '@apollo/client'; import { AlertContext, AlertContextProps, @@ -27,6 +31,11 @@ import mapboxgl from 'mapbox-gl'; import { mapboxToken } from '#config'; import RouteContext from '#contexts/route'; +import UserContext, { + UserAuth, + UserContextProps, +} from '#contexts/user'; +import { MeQuery } from '#generated/types/graphql'; import { KEY_LANGUAGE_STORAGE } from '#utils/constants'; import { getFromStorage, @@ -35,6 +44,25 @@ import { import wrappedRoutes, { unwrappedRoutes } from './routes'; +import styles from './styles.module.css'; + +const ME = gql` + query Me { + public { + me { + city + country + displayName + email + firstName + id + lastName + phoneNumber + } + } + } +`; + const router = createBrowserRouter(unwrappedRoutes); mapboxgl.accessToken = mapboxToken; mapboxgl.setRTLTextPlugin( @@ -66,13 +94,51 @@ function App() { }, []); const setAndStoreCurrentLanguage = useCallback( - (newLanugage: Language) => { - setCurrentLanguage(newLanugage); - setToStorage(KEY_LANGUAGE_STORAGE, newLanugage); + (newLanguage: Language) => { + setCurrentLanguage(newLanguage); + setToStorage(KEY_LANGUAGE_STORAGE, newLanguage); }, [], ); + // AUTH + + const [userAuth, setUserAuth] = useState(); + + const removeUserAuth = useCallback(() => { + setUserAuth(undefined); + }, []); + + // Hydration + useEffect(() => { + const language = getFromStorage(KEY_LANGUAGE_STORAGE); + setCurrentLanguage(language ?? 'en'); + }, []); + + const { + loading: meLoading, + } = useQuery( + ME, + { + onCompleted: (response) => { + if (response.public.me) { + setUserAuth(response.public.me); + } else { + removeUserAuth(); + } + }, + }, + ); + + const userContextValue = useMemo( + () => ({ + userAuth, + setUserAuth, + removeUserAuth, + }), + [userAuth, removeUserAuth], + ); + const registerLanguageNamespace = useCallback( (namespace: string, fallbackStrings: Record) => { setStrings( @@ -185,13 +251,24 @@ function App() { removeAlert, }), [alerts, addAlert, updateAlert, removeAlert]); + if (meLoading) { + return ( + // FIXME: Use translation +
+ Checking user session... +
+ ); + } + return ( - - - - - + + + + + + + ); } diff --git a/src/App/routes/common.tsx b/src/App/routes/common.tsx new file mode 100644 index 00000000..bfad45ea --- /dev/null +++ b/src/App/routes/common.tsx @@ -0,0 +1,47 @@ +import { + type MyInputIndexRouteObject, + type MyInputNonIndexRouteObject, + type MyOutputIndexRouteObject, + type MyOutputNonIndexRouteObject, + wrapRoute, +} from '#utils/routes'; +import { Component as RootLayout } from '#views/RootLayout'; + +import Auth from '../Auth'; +import PageError from '../PageError'; + +export type ExtendedProps = { + title: string, + visibility: 'is-authenticated' | 'is-not-authenticated' | 'anything', + permissions?: ( + params: Record | undefined | null, + ) => boolean; +}; + +interface CustomWrapRoute { + ( + myRouteOptions: MyInputIndexRouteObject + ): MyOutputIndexRouteObject + ( + myRouteOptions: MyInputNonIndexRouteObject + ): MyOutputNonIndexRouteObject +} + +export const customWrapRoute: CustomWrapRoute = wrapRoute; + +// NOTE: We should not use layout or index routes in links + +export const rootLayout = customWrapRoute({ + path: '/', + errorElement: , + component: { + eagerLoad: true, + render: RootLayout, + props: {}, + }, + wrapperComponent: Auth, + context: { + title: 'IFRC Alert Hub', + visibility: 'anything', + }, +}); diff --git a/src/App/routes/index.tsx b/src/App/routes/index.tsx index e8d8987c..e194c249 100644 --- a/src/App/routes/index.tsx +++ b/src/App/routes/index.tsx @@ -6,11 +6,13 @@ import { MyOutputIndexRouteObject, MyOutputNonIndexRouteObject, unwrapRoute, - wrapRoute, } from '#utils/routes'; -import { Component as RootLayout } from '#views/RootLayout'; -import PageError from '../PageError'; +import Auth from '../Auth'; +import { + customWrapRoute, + rootLayout, +} from './common'; // NOTE: setting default ExtendedProps export type ExtendedProps = { @@ -27,22 +29,6 @@ export interface MyWrapRoute { ): MyOutputNonIndexRouteObject } -const customWrapRoute: MyWrapRoute = wrapRoute; - -const rootLayout = customWrapRoute({ - path: '/', - errorElement: , - component: { - render: RootLayout, - eagerLoad: true, - props: {}, - }, - context: { - title: 'IFRC Alert Hub', - visibility: 'anything', - }, -}); - type DefaultHomeChild = 'map'; const homeLayout = customWrapRoute({ parent: rootLayout, @@ -51,6 +37,7 @@ const homeLayout = customWrapRoute({ render: () => import('#views/Home'), props: {}, }, + wrapperComponent: Auth, context: { title: 'IFRC Alert Hub', visibility: 'anything', @@ -64,6 +51,7 @@ const mySubscription = customWrapRoute({ render: () => import('#views/MySubscription'), props: {}, }, + wrapperComponent: Auth, context: { title: 'My Subscriptions', // TODO: Change visibility after login feature @@ -82,6 +70,7 @@ const homeIndex = customWrapRoute({ replace: true, }, }, + wrapperComponent: Auth, context: { title: 'IFRC Alert Hub', visibility: 'anything', @@ -95,6 +84,7 @@ const homeMap = customWrapRoute({ render: () => import('#views/Home/AlertsMap'), props: {}, }, + wrapperComponent: Auth, context: { title: 'IFRC Alert Hub - Map', visibility: 'anything', @@ -108,6 +98,7 @@ const homeTable = customWrapRoute({ render: () => import('#views/Home/AlertsTable'), props: {}, }, + wrapperComponent: Auth, context: { title: 'IFRC Alert Hub - Table', visibility: 'anything', @@ -121,6 +112,7 @@ const preferences = customWrapRoute({ render: () => import('#views/Preferences'), props: {}, }, + wrapperComponent: Auth, context: { title: 'Preferences', visibility: 'anything', @@ -134,6 +126,7 @@ const historicalAlerts = customWrapRoute({ render: () => import('#views/HistoricalAlerts'), props: {}, }, + wrapperComponent: Auth, context: { title: 'Historical Alerts', visibility: 'anything', @@ -147,6 +140,7 @@ const about = customWrapRoute({ render: () => import('#views/About'), props: {}, }, + wrapperComponent: Auth, context: { title: 'About', visibility: 'anything', @@ -160,6 +154,7 @@ const resources = customWrapRoute({ render: () => import('#views/Resources'), props: {}, }, + wrapperComponent: Auth, context: { title: 'Resources', visibility: 'anything', @@ -173,6 +168,7 @@ const alertDetails = customWrapRoute({ render: () => import('#views/AlertDetails'), props: {}, }, + wrapperComponent: Auth, context: { title: 'Alert Details', visibility: 'anything', @@ -186,6 +182,7 @@ const allSourcesFeeds = customWrapRoute({ render: () => import('#views/AllSourcesFeeds'), props: {}, }, + wrapperComponent: Auth, context: { title: 'Sources Feeds', visibility: 'anything', @@ -199,6 +196,7 @@ const pageNotFound = customWrapRoute({ render: () => import('#views/PageNotFound'), props: {}, }, + wrapperComponent: Auth, context: { title: '404', visibility: 'anything', @@ -212,6 +210,7 @@ const login = customWrapRoute({ render: () => import('#views/Login'), props: {}, }, + wrapperComponent: Auth, context: { title: 'Login', visibility: 'is-not-authenticated', @@ -225,6 +224,7 @@ const recoverAccount = customWrapRoute({ render: () => import('#views/RecoverAccount'), props: {}, }, + wrapperComponent: Auth, context: { title: 'Recover Account', visibility: 'is-not-authenticated', @@ -238,6 +238,7 @@ const resendValidationEmail = customWrapRoute({ render: () => import('#views/ResendValidationEmail'), props: {}, }, + wrapperComponent: Auth, context: { title: 'Resend Validation Email', visibility: 'is-not-authenticated', @@ -251,6 +252,7 @@ const cookiePolicy = customWrapRoute({ render: () => import('#views/CookiePolicy'), props: {}, }, + wrapperComponent: Auth, context: { title: 'Cookie Policy', visibility: 'anything', diff --git a/src/App/styles.module.css b/src/App/styles.module.css new file mode 100644 index 00000000..62cf9777 --- /dev/null +++ b/src/App/styles.module.css @@ -0,0 +1,7 @@ +.loading { + display: flex; + align-items: center; + justify-content: center; + width: 100vw; + height: 100vh; +} diff --git a/src/components/Link/index.tsx b/src/components/Link/index.tsx index b85ac36a..1f26d4c5 100644 --- a/src/components/Link/index.tsx +++ b/src/components/Link/index.tsx @@ -21,7 +21,7 @@ import { } from '@togglecorp/fujs'; import RouteContext from '#contexts/route'; -import useAuth from '#hooks/useAuth'; +import useAuth from '#hooks/domain/useAuth'; import { type WrappedRoutes } from '../../App/routes'; diff --git a/src/components/Navbar/i18n.json b/src/components/Navbar/i18n.json index 7ffcd233..117178a3 100644 --- a/src/components/Navbar/i18n.json +++ b/src/components/Navbar/i18n.json @@ -7,6 +7,8 @@ "appResources": "Resources", "headerMenuHome": "Home", "headerMenuMySubscription": "My Subscriptions", - "historicalAlerts": "Historical Alerts" + "historicalAlerts": "Historical Alerts", + "logoutFailure": "Failed to logout", + "userLogout":"Logout" } } diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx index eb1ebc2b..06221e83 100644 --- a/src/components/Navbar/index.tsx +++ b/src/components/Navbar/index.tsx @@ -1,4 +1,10 @@ +import { useContext } from 'react'; import { + gql, + useMutation, +} from '@apollo/client'; +import { + Button, Heading, NavigationTabList, PageContainer, @@ -9,18 +15,67 @@ import { _cs } from '@togglecorp/fujs'; import goLogo from '#assets/icons/go-logo-2020.svg'; import Link from '#components/Link'; import NavigationTab from '#components/NavigationTab'; +import UserContext from '#contexts/user'; +import { LogoutMutation } from '#generated/types/graphql'; +import useAuth from '#hooks/domain/useAuth'; +import useAlert from '#hooks/useAlert'; import LangaugeDropdown from './LanguageDropdown'; import i18n from './i18n.json'; import styles from './styles.module.css'; +const LOGOUT = gql` + mutation Logout { + private { + logout { + ok + errors + } + } + } +`; + interface Props { className?: string; } function Navbar(props: Props) { const { className } = props; const strings = useTranslation(i18n); + const { isAuthenticated } = useAuth(); + const alert = useAlert(); + + const { + removeUserAuth: removeUser, + } = useContext(UserContext); + + const [ + triggerLogout, + { loading: logoutPending }, + ] = useMutation( + LOGOUT, + { + onCompleted: (logoutResponse) => { + const response = logoutResponse?.private?.logout; + if (response.ok) { + window.location.reload(); + removeUser(); + } else { + alert.show( + strings.logoutFailure, + { variant: 'danger' }, + ); + } + }, + onError: () => { + alert.show( + strings.logoutFailure, + { variant: 'danger' }, + ); + }, + }, + ); + return (