diff --git a/.env b/.env index 2eca9bc6..187d3a48 100644 --- a/.env +++ b/.env @@ -1,6 +1,8 @@ # Base URL path for the enviroment PUBLIC_URL = '/dev/' +# Name of enviroment for build +REACT_APP_KBASE_ENV=ci-europa # Domain of enviroment for build REACT_APP_KBASE_DOMAIN=ci-europa.kbase.us # The following must be a subdomain of REACT_APP_KBASE_DOMAIN diff --git a/Dockerfile b/Dockerfile index bb19c5df..10136a1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM nginx:stable-alpine3.17-slim USER root ## Install sed command needed for startup script -RUN apk add --no-cache sed +RUN apk add --no-cache sed jq ## Copy built static files for all enviroments to image COPY ./deploy /deploy/ diff --git a/config.json b/config.json new file mode 100644 index 00000000..1e3340a8 --- /dev/null +++ b/config.json @@ -0,0 +1,33 @@ +{ + "environments": { + "appdev": { + "domain": "appdev.kbase.us", + "legacy": "legacy.appdev.kbase.us", + "public_url": "/" + }, + + "ci": { + "domain": "ci.kbase.us", + "legacy": "legacy.ci.kbase.us", + "public_url": "/" + }, + + "ci-europa": { + "domain": "ci-europa.kbase.us", + "legacy": "legacy.ci-europa.kbase.us", + "public_url": "/" + }, + + "narrative-dev": { + "domain": "narrative-dev.kbase.us", + "legacy": "legacy.narrative-dev.kbase.us", + "public_url": "/" + }, + + "production": { + "domain": "narrative.kbase.us", + "legacy": "legacy.narrative.kbase.us", + "public_url": "/" + } + } +} diff --git a/scripts/build_deploy.sh b/scripts/build_deploy.sh index 02cfce77..572c0b1e 100755 --- a/scripts/build_deploy.sh +++ b/scripts/build_deploy.sh @@ -1,21 +1,20 @@ #!/usr/bin/env bash +# Here we are using bash "here strings" +IFS=$'\n' read -d '' -r -a enviromentsConfig <<< "$(jq -r '.environments + | keys[] as $k + | [($k), (.[$k]["domain"]) , (.[$k]["legacy"]) , (.[$k]["public_url"])] + | join(" ")' config.json)" -declare -a enviroments=( -# " " - "ci ci.kbase.us legacy.kbase.us /" - "ci-europa ci-europa.kbase.us legacy.ci-europa.kbase.us /" - "narrative-dev narrative-dev.kbase.us legacy.narrative-dev.kbase.us /" -) +for enviro in "${enviromentsConfig[@]}"; do + read -a envConf <<< "$enviro" + echo "Building static files for enviroment \"${envConf[0]}\"..."; -for enviro in "${enviroments[@]}"; do - read -a strarr <<< "$enviro" - echo "Building static files for enviroment \"${strarr[0]}\"..."; - - BUILD_PATH="./deploy/${strarr[0]}" \ - REACT_APP_KBASE_DOMAIN="${strarr[1]}" \ - REACT_APP_KBASE_LEGACY_DOMAIN="${strarr[2]}" \ - PUBLIC_URL="${strarr[3]}" \ + BUILD_PATH="./deploy/${envConf[0]}" \ + REACT_APP_KBASE_ENV="${envConf[0]}" \ + REACT_APP_KBASE_DOMAIN="${envConf[1]}" \ + REACT_APP_KBASE_LEGACY_DOMAIN="${envConf[2]}" \ + PUBLIC_URL="${envConf[3]}" \ npm run build && \ - echo "Built static files for enviroment \"${strarr[0]}\"."; + echo "Built static files for enviroment \"${envConf[0]}\"."; done diff --git a/src/app/App.tsx b/src/app/App.tsx index 2ae39010..8476c4ba 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -27,14 +27,14 @@ const useInitApp = () => { // Use authenticated username to load user's profile const username = useAppSelector(authUsername); const initialized = useAppSelector(authInitialized); + const environment = useAppSelector((state) => state.layout.environment); useLoggedInProfileUser(username); - // Placeholder code for determining environment. useEffect(() => { // eslint-disable-next-line no-console console.info('Static Deploy Domain:', process.env.REACT_APP_KBASE_DOMAIN); - dispatch(setEnvironment('ci-europa')); - }, [dispatch]); + dispatch(setEnvironment(process.env.REACT_APP_KBASE_ENV ?? 'unknown')); + }, [dispatch, environment]); return { isLoading: !initialized }; }; diff --git a/src/app/Routes.tsx b/src/app/Routes.tsx index b30153c9..ad1f8b95 100644 --- a/src/app/Routes.tsx +++ b/src/app/Routes.tsx @@ -64,9 +64,15 @@ const Routes: FC = () => { {/* Collections */} - } /> - } /> - } /> + } />} /> + } />} + /> + } />} + /> } /> diff --git a/src/app/store.ts b/src/app/store.ts index 1d810154..a568801c 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,4 +1,4 @@ -import { configureStore } from '@reduxjs/toolkit'; +import { Action, combineReducers, configureStore } from '@reduxjs/toolkit'; import { baseApi } from '../common/api'; import auth from '../features/auth/authSlice'; import collections from '../features/collections/collectionsSlice'; @@ -9,20 +9,30 @@ import navigator from '../features/navigator/navigatorSlice'; import params from '../features/params/paramsSlice'; import profile from '../features/profile/profileSlice'; +const everyReducer = combineReducers({ + auth, + collections, + count, + icons, + layout, + navigator, + params, + profile, + [baseApi.reducerPath]: baseApi.reducer, +}); + +const rootReducer: typeof everyReducer = (state, action) => { + if (action.type === 'RESET_STATE') { + return everyReducer(undefined, action); + } + + return everyReducer(state, action); +}; + const createStore = (additionalOptions?: T) => { return configureStore({ devTools: true, - reducer: { - auth, - collections, - count, - icons, - layout, - navigator, - params, - profile, - [baseApi.reducerPath]: baseApi.reducer, - }, + reducer: rootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(baseApi.middleware), ...additionalOptions, @@ -35,6 +45,8 @@ export const store = createStore(); export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; +export const resetStateAction = (): Action => ({ type: 'RESET_STATE' }); + export const createTestStore = (preloadedState: Partial = {}) => { return createStore({ preloadedState: preloadedState }); }; diff --git a/src/common/components/Dropdown.module.scss b/src/common/components/Dropdown.module.scss index e69b7486..4c3740da 100644 --- a/src/common/components/Dropdown.module.scss +++ b/src/common/components/Dropdown.module.scss @@ -1,7 +1,17 @@ .dropdown { // these prefixed global classnames are inserted by react-select + + // uses the a11-compatible visually hidden props as per + // https://www.a11yproject.com/posts/how-to-hide-content/ + // this preserves element selectability :global(.react-select__value-container) { - display: none; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 0; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 0; } :global(.react-select__indicator-separator) { diff --git a/src/common/components/Select.tsx b/src/common/components/Select.tsx index 283decac..2bcd2474 100644 --- a/src/common/components/Select.tsx +++ b/src/common/components/Select.tsx @@ -12,6 +12,7 @@ export interface SelectOption { label: ReactNode; value: string | number; icon?: ReactNode; + fullWidth?: boolean; // ignores icon padding when icons are present } export type OptionsArray = OptionsOrGroups< @@ -104,9 +105,9 @@ export const Select: FC = (props) => { const handleFormatOptionLabel = (data: SelectOption) => { return ( - {hasIcons && ( + {hasIcons && !data.fullWidth ? ( {data.icon} - )} + ) : null} {data.label} ); diff --git a/src/features/layout/TopBar.module.scss b/src/features/layout/TopBar.module.scss index fea3185f..ecbeccd3 100644 --- a/src/features/layout/TopBar.module.scss +++ b/src/features/layout/TopBar.module.scss @@ -62,8 +62,23 @@ } } +.login_prompt, +.login_prompt:visited { + color: use-color("primary"); + display: flex; + flex-flow: column nowrap; + text-align: center; + text-decoration: none; + width: 77px; + + svg { + font-size: 24px; + } +} + .login_menu { display: block; + padding: 4px; width: 77px; .login_menu_username { @@ -74,8 +89,8 @@ align-items: center; color: use_color("black"); display: flex; + gap: 5px; justify-content: left; - padding: 4px; .login_menu_icon { color: use_color("mid-blue"); @@ -83,4 +98,9 @@ margin-right: 5px; } } + + .name_item { + text-align: center; + width: 100%; + } } diff --git a/src/features/layout/TopBar.tsx b/src/features/layout/TopBar.tsx index f55fd6a1..251f2a0b 100644 --- a/src/features/layout/TopBar.tsx +++ b/src/features/layout/TopBar.tsx @@ -11,23 +11,30 @@ import { faQuestionCircle, faSearch, faServer, + faSignIn, faSignOutAlt, faSortDown, - faSquare, faUser, faWrench, } from '@fortawesome/free-solid-svg-icons'; -import { FC } from 'react'; +import { FC, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import logo from '../../common/assets/logo/46_square.png'; import { Dropdown } from '../../common/components'; -import { useAppSelector } from '../../common/hooks'; -import { authUsername } from '../auth/authSlice'; -import { profileRealname } from '../profile/profileSlice'; +import { useAppDispatch, useAppSelector } from '../../common/hooks'; +import { authUsername, setAuth } from '../auth/authSlice'; import classes from './TopBar.module.scss'; +import { Link } from 'react-router-dom'; +import { getUserProfile } from '../../common/api/userProfileApi'; +import { revokeToken } from '../../common/api/authService'; +import { toast } from 'react-hot-toast'; +import { noOp } from '../common'; +import { resetStateAction } from '../../app/store'; export default function TopBar() { + const username = useAppSelector(authUsername); + return (
@@ -43,16 +50,33 @@ export default function TopBar() {
- + {username ? : }
); } -const LoginMenu: FC = () => { +const LoginPrompt: FC = () => ( + + + Sign In + +); + +const UserMenu: FC = () => { const username = useAppSelector(authUsername); - const realname = useAppSelector(profileRealname); + const { data: profData } = getUserProfile.useQuery( + useMemo( + () => ({ + usernames: [username || ''], + }), + [username] + ), + { skip: !username } + ); + const realname = profData?.[0]?.[0]?.user.realname; const navigate = useNavigate(); + const logout = useLogout(); return (
{ { value: '', icon: undefined, + fullWidth: true, label: ( -
+
{realname}
{username} @@ -77,12 +102,12 @@ const LoginMenu: FC = () => { { options: [ { - value: '/profile', + value: '/legacy/people', icon: , label: 'Your Profile', }, { - value: '#your_account', + value: '/legacy/account', icon: , label: 'Your Account', }, @@ -91,7 +116,7 @@ const LoginMenu: FC = () => { { options: [ { - value: '#sign_out', + value: 'LOGOUT', icon: , label: 'Sign Out', }, @@ -99,11 +124,17 @@ const LoginMenu: FC = () => { }, ]} onChange={(opt) => { - if (opt?.[0]) navigate(opt[0].value as string); + if (opt?.[0]) { + if (opt[0].value === 'LOGOUT') { + logout(); + } else { + navigate(opt[0].value as string); + } + } }} >
- +
@@ -111,7 +142,32 @@ const LoginMenu: FC = () => { ); }; +const useLogout = () => { + const tokenId = useAppSelector(({ auth }) => auth.tokenInfo?.id); + const dispatch = useAppDispatch(); + const [revoke] = revokeToken.useMutation(); + const navigate = useNavigate(); + + if (!tokenId) return noOp; + + return () => { + revoke(tokenId) + .unwrap() + .then(() => { + dispatch(resetStateAction()); + // setAuth(null) follow the state reset to initialize the page as un-Authed + dispatch(setAuth(null)); + toast('You have been signed out'); + navigate('/legacy/auth2/signedout'); + }) + .catch(() => { + toast('Error, could not log out.'); + }); + }; +}; + const HamburgerMenu: FC = () => { + const navigate = useNavigate(); return (
{ { options: [ { - value: window.location.origin + '/#narrativemanager/start', + value: '/legacy/narrativemanager/start', icon: , label: 'Narrative Interface', }, { - value: window.location.origin + '/#narrativemanager/new', + value: '/legacy/narrativemanager/new', icon: , label: 'New Narrative', }, { - value: window.location.origin + '/#jgi-search', + value: '/legacy/jgi-search', icon: , label: 'JGI Search', }, { - value: window.location.origin + '/#biochem-search', + value: '/legacy/biochem-search', icon: , label: 'Biochem Search', }, @@ -143,7 +199,7 @@ const HamburgerMenu: FC = () => { { options: [ { - value: window.location.origin + '/#about/services', + value: '/legacy/about/services', icon: , label: 'KBase Services Status', }, @@ -152,7 +208,7 @@ const HamburgerMenu: FC = () => { { options: [ { - value: window.location.origin + '/#about', + value: '/legacy/about', icon: , label: 'About', }, @@ -170,7 +226,13 @@ const HamburgerMenu: FC = () => { }, ]} onChange={(opt) => { - if (opt?.[0]) window.location.href = opt[0].value as string; + if (typeof opt?.[0]?.value === 'string') { + if (opt[0].value.startsWith('http')) { + window.location.href = opt[0].value; + } else { + navigate(opt[0].value, { relative: 'path' }); + } + } }} > @@ -179,6 +241,36 @@ const HamburgerMenu: FC = () => { ); }; +const UserAvatar = () => { + const username = useAppSelector(authUsername); + const { data: profData } = getUserProfile.useQuery( + useMemo( + () => ({ + usernames: [username || ''], + }), + [username] + ), + { skip: !username } + ); + const avatarUri = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const profile = profData ? (profData[0][0]?.profile as any) : undefined; + const avatarOption = profile?.userdata?.avatarOption || 'gravatar'; + if (avatarOption === 'gravatar') { + const gravatarDefault = profile?.userdata?.gravatarDefault || 'identicon'; + const gravatarHash = profile?.synced?.gravatarHash; + if (gravatarHash) { + return `https://www.gravatar.com/avatar/${gravatarHash}?s=300&r=pg&d=${gravatarDefault}`; + } else { + return `https://${process.env.REACT_APP_KBASE_LEGACY_DOMAIN}/images/nouserpic.png`; + } + } else { + return `https://${process.env.REACT_APP_KBASE_LEGACY_DOMAIN}/images/nouserpic.png`; + } + }, [profData]); + return {'avatar'}; +}; + const PageTitle: FC = () => { const title = useAppSelector((state) => state.layout.pageTitle); return ( @@ -192,16 +284,18 @@ const Enviroment: FC = () => { const env = useAppSelector((state) => state.layout.environment); if (env === 'production') return null; const icon = { + appdev: faWrench, ci: faFlask, 'ci-europa': faFlask, + 'narrative-dev': faWrench, unknown: faQuestionCircle, - appdev: faWrench, }[env]; const txt = { + appdev: 'APPDEV', ci: 'CI', 'ci-europa': 'EUR', - unknown: '??', - appdev: 'APPDEV', + 'narrative-dev': 'NARDEV', + unknown: 'ENV?', }[env]; return (
diff --git a/src/features/layout/layoutSlice.ts b/src/features/layout/layoutSlice.ts index 8992668e..068d1b67 100644 --- a/src/features/layout/layoutSlice.ts +++ b/src/features/layout/layoutSlice.ts @@ -2,8 +2,17 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { useEffect } from 'react'; import { useAppDispatch } from '../../common/hooks'; +const environments = [ + 'unknown', + 'production', + 'ci', + 'appdev', + 'ci-europa', + 'narrative-dev', +] as const; + interface PageState { - environment: 'unknown' | 'production' | 'ci' | 'appdev' | 'ci-europa'; + environment: typeof environments[number]; pageTitle: string; modalDialogId?: string; } @@ -17,11 +26,13 @@ export const pageSlice = createSlice({ name: 'page', initialState, reducers: { - setEnvironment: ( - state, - action: PayloadAction - ) => { - state.environment = action.payload; + setEnvironment: (state, action: PayloadAction) => { + const env = action.payload.toLowerCase(); + if (environments.includes(env as typeof environments[number])) { + state.environment = env as typeof environments[number]; + } else { + state.environment = 'unknown'; + } }, setModalDialogId: ( state, diff --git a/src/features/legacy/Legacy.test.tsx b/src/features/legacy/Legacy.test.tsx index 794f592c..b283a239 100644 --- a/src/features/legacy/Legacy.test.tsx +++ b/src/features/legacy/Legacy.test.tsx @@ -13,7 +13,8 @@ import * as layoutSlice from '../layout/layoutSlice'; import Legacy, { formatLegacyUrl, getLegacyPart, - isAuthMessage, + isLoginMessage, + isLogoutMessage, isRouteMessage, isTitleMessage, LEGACY_BASE_ROUTE, @@ -36,11 +37,15 @@ const routeMessage = { source: 'kbase-ui.app.route-component', payload: { request: { original: '#/some/hash/path' } }, }; -const authMessage = { +const loginMessage = { source: 'kbase-ui.session.loggedin', payload: { token: 'some-token' }, }; -const nullAuthMessage = { +const logoutMessage = { + source: 'kbase-ui.session.loggedout', + payload: undefined, +}; +const nullLoginMessage = { source: 'kbase-ui.session.loggedin', payload: { token: null }, }; @@ -68,22 +73,30 @@ describe('Legacy', () => { test('isTitleMessage', () => { expect(isTitleMessage(titleMessage)).toBe(true); expect(isTitleMessage(routeMessage)).toBe(false); - expect(isTitleMessage(authMessage)).toBe(false); - expect(isTitleMessage(nullAuthMessage)).toBe(false); + expect(isTitleMessage(loginMessage)).toBe(false); + expect(isTitleMessage(nullLoginMessage)).toBe(false); }); test('isRouteMessage', () => { expect(isRouteMessage(titleMessage)).toBe(false); expect(isRouteMessage(routeMessage)).toBe(true); - expect(isRouteMessage(authMessage)).toBe(false); - expect(isRouteMessage(nullAuthMessage)).toBe(false); + expect(isRouteMessage(loginMessage)).toBe(false); + expect(isRouteMessage(nullLoginMessage)).toBe(false); + }); + + test('isLoginMessage', () => { + expect(isLoginMessage(titleMessage)).toBe(false); + expect(isLoginMessage(routeMessage)).toBe(false); + expect(isLoginMessage(loginMessage)).toBe(true); + expect(isLoginMessage(nullLoginMessage)).toBe(true); }); - test('isAuthMessage', () => { - expect(isAuthMessage(titleMessage)).toBe(false); - expect(isAuthMessage(routeMessage)).toBe(false); - expect(isAuthMessage(authMessage)).toBe(true); - expect(isAuthMessage(nullAuthMessage)).toBe(true); + test('isLogoutMessage', () => { + expect(isLogoutMessage(titleMessage)).toBe(false); + expect(isLogoutMessage(routeMessage)).toBe(false); + expect(isLogoutMessage(loginMessage)).toBe(false); + expect(isLogoutMessage(nullLoginMessage)).toBe(false); + expect(isLogoutMessage(logoutMessage)).toBe(true); }); test('getLegacyPart', () => { diff --git a/src/features/legacy/Legacy.tsx b/src/features/legacy/Legacy.tsx index 1963a899..aae94569 100644 --- a/src/features/legacy/Legacy.tsx +++ b/src/features/legacy/Legacy.tsx @@ -2,6 +2,10 @@ import { RefObject, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { usePageTitle } from '../layout/layoutSlice'; import { useTryAuthFromToken } from '../auth/hooks'; +import { useAppDispatch } from '../../common/hooks'; +import { resetStateAction } from '../../app/store'; +import { setAuth } from '../auth/authSlice'; +import { toast } from 'react-hot-toast'; export const LEGACY_BASE_ROUTE = '/legacy'; @@ -13,6 +17,7 @@ export default function Legacy() { const location = useLocation(); const navigate = useNavigate(); + const dispatch = useAppDispatch(); const legacyContentRef = useRef(null); const [legacyTitle, setLegacyTitle] = useState(''); @@ -43,10 +48,15 @@ export default function Legacy() { } } else if (isTitleMessage(d)) { setLegacyTitle(d.payload); - } else if (isAuthMessage(d)) { + } else if (isLoginMessage(d)) { if (d.payload.token) { setReceivedToken(d.payload.token); } + } else if (isLogoutMessage(d)) { + dispatch(resetStateAction()); + dispatch(setAuth(null)); + toast('You have been signed out'); + navigate('/legacy/auth2/signedout'); } }); @@ -179,7 +189,7 @@ export const isRouteMessage = messageGuard( .original === 'string' ); -export const isAuthMessage = messageGuard( +export const isLoginMessage = messageGuard( 'kbase-ui.session.loggedin', (payload): payload is { token: string | null } => !!payload && @@ -188,3 +198,8 @@ export const isAuthMessage = messageGuard( (typeof (payload as Record).token === 'string' || (payload as Record).token === null) ); + +export const isLogoutMessage = messageGuard( + 'kbase-ui.session.loggedout', + (payload): payload is undefined => payload === undefined +); diff --git a/src/features/navigator/NarrativeControl/NarrativeControl.module.scss b/src/features/navigator/NarrativeControl/NarrativeControl.module.scss index 69c10edd..75c415b8 100644 --- a/src/features/navigator/NarrativeControl/NarrativeControl.module.scss +++ b/src/features/navigator/NarrativeControl/NarrativeControl.module.scss @@ -15,11 +15,6 @@ font-size: 1.5rem; justify-content: space-between; } - - :global(.react-select__control .react-select__value-container) { - padding: 0; - width: 0; - } } .permission {