diff --git a/src/app/Routes.tsx b/src/app/Routes.tsx index 8e5af5d7..d1ee5dbb 100644 --- a/src/app/Routes.tsx +++ b/src/app/Routes.tsx @@ -80,6 +80,10 @@ const Routes: FC = () => { path="providers" element={} />} /> + } />} + /> } />} diff --git a/src/common/api/authService.ts b/src/common/api/authService.ts index 992efd29..9bc554c2 100644 --- a/src/common/api/authService.ts +++ b/src/common/api/authService.ts @@ -45,6 +45,15 @@ interface AuthParams { linkall: false; policyids: string[]; }; + unlinkID: { + token: string; + id: string; + }; + getLinkChoice: void; + postLinkPick: { + token: string; + id: string; + }; } interface AuthResults { @@ -102,6 +111,18 @@ interface AuthResults { }; loginUsernameSuggest: { availablename: string }; loginCreate: AuthResults['postLoginPick']; + unlinkID: void; + getLinkChoice: { + pickurl: string; + expires: number; + provider: string; + haslinks: boolean; + user: string; + cancelurl: string; + idents: { provusername: string; id: string }[]; // if linkable + linked: { provusername: string; id: string; user: string }[]; // if already linked + }; + postLinkPick: void; } // Auth does not use JSONRpc, so we use queryFn to make custom queries @@ -238,6 +259,50 @@ export const authApi = baseApi url: `/login/create/`, }), }), + unlinkID: builder.mutation< + AuthResults['unlinkID'], + AuthParams['unlinkID'] + >({ + query: ({ token, id }) => + authService({ + headers: { + Authorization: token, + }, + method: 'POST', + url: `/me/unlink/{id}`, + }), + invalidatesTags: ['AccountMe'], + }), + + getLinkChoice: builder.query< + AuthResults['getLinkChoice'], + AuthParams['getLinkChoice'] + >({ + query: () => + // MUST have an in-process-link-token cookie + authService({ + headers: { + accept: 'application/json', + }, + method: 'GET', + url: '/link/choice', + }), + }), + postLinkPick: builder.mutation< + AuthResults['postLinkPick'], + AuthParams['postLinkPick'] + >({ + query: ({ token, id }) => + authService({ + headers: { + Authorization: token, + }, + url: encode`/link/pick`, + body: { id }, + method: 'POST', + }), + invalidatesTags: ['AccountMe'], + }), }), }); @@ -252,5 +317,8 @@ export const { postLoginPick, loginUsernameSuggest, loginCreate, + unlinkID, + getLinkChoice, + postLinkPick, } = authApi.endpoints; export type GetLoginChoiceResult = AuthResults['getLoginChoice']; diff --git a/src/features/account/Account.tsx b/src/features/account/Account.tsx index 843045bc..939f17e2 100644 --- a/src/features/account/Account.tsx +++ b/src/features/account/Account.tsx @@ -8,20 +8,18 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom'; export const Account: FC = () => { const navigate = useNavigate(); const location = useLocation(); - const [activeTab, setActiveTab] = useState(() => { - switch (location.pathname) { - case '/account/info': - return 0; - case '/account/providers': - return 1; - case '/account/logins': - return 2; - case '/account/use-agreements': - return 3; - default: - return 0; - } - }); + const tabs = [ + '/account/info', + '/account/providers', + '/account/logins', + '/account/use-agreements', + ]; + const defaultTab = tabs.findIndex((tabPath) => + location.pathname.startsWith(tabPath) + ); + const [activeTab, setActiveTab] = useState( + defaultTab === -1 ? 0 : defaultTab + ); const handleChange = (event: React.SyntheticEvent, newValue: number) => { setActiveTab(newValue); diff --git a/src/features/account/AccountInfo.tsx b/src/features/account/AccountInfo.tsx index 0bf8585e..a432d9fa 100644 --- a/src/features/account/AccountInfo.tsx +++ b/src/features/account/AccountInfo.tsx @@ -19,68 +19,86 @@ import { getUserProfile, setUserProfile, } from '../../common/api/userProfileApi'; +import { toast } from 'react-hot-toast'; /** * Content for the Account tab in the Account page */ export const AccountInfo: FC = () => { - const token = useAppSelector((s) => s.auth.token) ?? ''; - const username = useAppSelector((s) => s.auth.username) ?? ''; + const token = useAppSelector(({ auth }) => auth.token ?? ''); + const username = useAppSelector(({ auth }) => auth.username ?? ''); // Profile - const profiles = getUserProfile.useQuery({ usernames: [username] }); - const profile = profiles.data?.[0]?.[0]; + const { data: profiles, refetch: refetchProfiles } = getUserProfile.useQuery({ + usernames: [username], + }); + const profile = profiles?.[0]?.[0]; const [triggerSetProfile, setProfileResult] = setUserProfile.useMutation(); + // Account - const account = getMe.useQuery({ token }); + const { data: accountData, refetch: refetchAccount } = getMe.useQuery({ + token, + }); const [triggerSetMe, setMeResult] = setMe.useMutation(); + // Form + const form = useForm<{ name: string; email: string }>({ values: { - name: account.data?.display ?? '', - email: account.data?.email ?? '', + name: accountData?.display ?? '', + email: accountData?.email ?? '', }, mode: 'onChange', }); - const onSubmit = form.handleSubmit((formData) => { - if (!profile) throw new Error('Error, undefined profile cannot be set'); - triggerSetProfile([ - { - profile: { - user: { - username: profile.user.username, - realname: formData.name, + // Save the form info to Account/Profile + const onSubmit = form.handleSubmit( + async (formData) => { + if (!profile) throw new Error('Error, undefined profile cannot be set'); + await triggerSetProfile([ + { + profile: { + user: { + username: profile.user.username, + realname: formData.name, + }, + profile: profile.profile, }, - profile: profile.profile, }, - }, - token, - ]); - triggerSetMe({ - token, - meUpdate: { - display: formData.name, - email: formData.email, - }, - }); - }); + token, + ]); + await triggerSetMe({ + token, + meUpdate: { + display: formData.name, + email: formData.email, + }, + }); + }, + (e) => { + const firstErr = [e.name, e.email].filter(Boolean)?.[0]; + toast(firstErr?.message ?? 'Something went wrong'); + } + ); const onReset = () => { setMeResult.reset(); setProfileResult.reset(); form.reset(); - profiles.refetch(); - account.refetch(); + refetchProfiles(); + refetchAccount(); }; - const loading = setProfileResult.isLoading || setMeResult.isLoading; - const complete = - !setProfileResult.isUninitialized && - !setMeResult.isUninitialized && - setProfileResult.isSuccess && - setMeResult.isSuccess; - const error = setProfileResult.isError || setMeResult.isError; + // Request States + const save = { + loading: setProfileResult.isLoading || setMeResult.isLoading, + complete: + setProfileResult.isSuccess && + setMeResult.isSuccess && + !setProfileResult.isUninitialized && + !setMeResult.isUninitialized, + error: setProfileResult.isError || setMeResult.isError, + }; return ( { type="submit" variant={!form.formState.isValid ? 'outlined' : 'contained'} endIcon={ - - {complete ? ( - - ) : error ? ( - - ) : undefined} - + save.loading ? ( + + ) : save.complete ? ( + + ) : save.error ? ( + + ) : undefined } size="large" > @@ -164,18 +182,18 @@ export const AccountInfo: FC = () => { Username - {account.data?.user} + {accountData?.user} Account Created - {new Date(account.data?.created ?? 0).toLocaleString()} + {new Date(accountData?.created ?? 0).toLocaleString()} Last Sign In - {new Date(account.data?.lastlogin ?? 0).toLocaleString()} + {new Date(accountData?.lastlogin ?? 0).toLocaleString()} diff --git a/src/features/account/LinkedProviders.tsx b/src/features/account/LinkedProviders.tsx index 6b0e883b..d210e148 100644 --- a/src/features/account/LinkedProviders.tsx +++ b/src/features/account/LinkedProviders.tsx @@ -1,6 +1,7 @@ -import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCheck, faInfoCircle, faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { + Alert, Button, Paper, Stack, @@ -12,29 +13,35 @@ import { Tooltip, Typography, } from '@mui/material'; -import { FC } from 'react'; -import globusLogo from '../../common/assets/globus.png'; -import googleLogo from '../../common/assets/google.webp'; -import orcidLogo from '../../common/assets/orcid.png'; +import { FC, useEffect } from 'react'; +import { toast } from 'react-hot-toast'; +import { + getLinkChoice, + getMe, + postLinkPick, + unlinkID, +} from '../../common/api/authService'; +import { Loader } from '../../common/components'; +import { useAppSelector } from '../../common/hooks'; +import { ProviderButtons } from '../auth/providers'; import classes from './Account.module.scss'; -/** - * Dummy data for the linked providers table. - * Can be deleted once table is linked to backend. - */ -const sampleProviders = [ - { - provider: 'Google', - username: 'coolkbasehuman@lbl.gov', - linked: true, - }, -]; - /** * Content for the Linked Providers tab in the Account page */ -export const LinkedProviders: FC = () => { - const linkedProviders = sampleProviders; +export const LinkedProviders: FC<{ isContinueRoute?: boolean }> = ({ + isContinueRoute, +}) => { + const token = useAppSelector(({ auth }) => auth.token ?? ''); + const { data: me } = getMe.useQuery({ token }, { skip: !token }); + + const identities = me?.idents; + + const { loginOrigin, loginActionUrl, loginRedirectUrl } = makeLinkURLs(); + const { linkPending, targetLinkProvider, targetLink } = + useManageLinkContinue(isContinueRoute); + const unklinkOk = (identities?.length ?? 0) > 1; + return ( { - {linkedProviders.map((provider, i) => ( + {identities?.map(({ provider, provusername, id }, i) => ( - {provider.provider} - {provider.username} + {provider} + {provusername} - + ))} + {linkPending ? ( + + {targetLinkProvider} + {targetLink?.provusername} + + + + + ) : undefined} Link an additional sign-in account to this KBase account + {process.env.NODE_ENV === 'development' ? ( + + DEV MODE: Link will occur on {loginOrigin} + + ) : ( + <> + )} - - - - - +
+ `Link with ${provider}`} /> + +
); }; + +const UnlinkButton = ({ + id, + unklinkOk, +}: { + id: string; + unklinkOk: boolean; +}) => { + const token = useAppSelector(({ auth }) => auth.token ?? ''); + const [triggerUnlink, unlink] = unlinkID.useMutation(); + return ( + + ); +}; + +const useManageLinkContinue = (isContinueRoute = false) => { + const token = useAppSelector(({ auth }) => auth.token ?? ''); + const choiceResult = getLinkChoice.useQuery(undefined, { + skip: !isContinueRoute, + }); + const [triggerLink, linkPick] = postLinkPick.useMutation(); + + const linkOk = (choiceResult.data?.linked.length ?? 0) < 1; + const targetLinkProvider = choiceResult.data?.provider; + const targetLink = choiceResult.data?.idents?.[0]; + const priorLinkProvUsername = choiceResult.data?.linked[0].provusername; + const otherUser = choiceResult.data?.linked[0].user; + + useEffect(() => { + if (targetLink && linkOk) { + triggerLink({ token, id: targetLink.id }); + } + if (!linkOk) { + toast( + `Cannot link ${targetLinkProvider} account "${priorLinkProvUsername}". Already linked to account ${otherUser}` + ); + } + }, [ + linkOk, + targetLinkProvider, + otherUser, + priorLinkProvUsername, + targetLink, + token, + triggerLink, + ]); + + return { + linkPending: choiceResult.isLoading || linkPick.isLoading, + targetLinkProvider, + targetLink, + }; +}; + +export const makeLinkURLs = (nextRequest?: string) => { + // OAuth Login wont work in dev mode, so redirect to ci + const loginOrigin = + process.env.NODE_ENV === 'development' + ? 'https://ci.kbase.us' + : document.location.origin; + + // Triggering login requires a form POST submission + const loginActionUrl = new URL('/services/auth/link/start/', loginOrigin); + + // Redirect URL is used to pass state to link/continue + const loginRedirectUrl = new URL( + `${loginOrigin}/account/providers/link/continue` + ); + loginRedirectUrl.searchParams.set( + 'state', + JSON.stringify({ + nextRequest: nextRequest, + origin: loginOrigin, + }) + ); + + return { loginOrigin, loginActionUrl, loginRedirectUrl }; +}; diff --git a/src/features/auth/providers.module.scss b/src/features/auth/providers.module.scss new file mode 100644 index 00000000..ece5890c --- /dev/null +++ b/src/features/auth/providers.module.scss @@ -0,0 +1,13 @@ +@import "../../common/colors"; + +.sso-logo { + height: 2.5rem; + width: auto; +} + +.separator { + align-self: center; + background-color: use-color("base-lighter"); + height: 1px; + width: 80%; +} diff --git a/src/features/auth/providers.tsx b/src/features/auth/providers.tsx new file mode 100644 index 00000000..8d2aed7e --- /dev/null +++ b/src/features/auth/providers.tsx @@ -0,0 +1,69 @@ +import { Button, Stack } from '@mui/material'; +import { Box } from '@mui/system'; +import classes from './providers.module.scss'; +import orcidLogo from '../../common/assets/orcid.png'; +import globusLogo from '../../common/assets/globus.png'; +import googleLogo from '../../common/assets/google.webp'; + +export const providers = [ + { + name: 'ORCID', + icon: ( + ORCID logo + ), + }, + { + name: 'Google', + icon: ( + Google logo + ), + }, + { + name: 'Globus', + icon: ( + Globus logo + ), + }, +]; + +export const ProviderButtons = ({ + text, +}: { + text: (provider: string) => string; +}) => { + const [orcidProvider, ...otherProviders] = providers; + + return ( + + + + + {otherProviders.map((provider) => ( + + ))} + + + ); +}; diff --git a/src/features/login/LogIn.module.scss b/src/features/login/LogIn.module.scss index 31975577..34009572 100644 --- a/src/features/login/LogIn.module.scss +++ b/src/features/login/LogIn.module.scss @@ -5,11 +5,6 @@ width: auto; } -.sso-logo { - height: 2.5rem; - width: auto; -} - .separator { align-self: center; background-color: use-color("base-lighter"); diff --git a/src/features/login/LogIn.tsx b/src/features/login/LogIn.tsx index a01eba07..2b5927f4 100644 --- a/src/features/login/LogIn.tsx +++ b/src/features/login/LogIn.tsx @@ -1,7 +1,6 @@ import { Alert, Box, - Button, Container, Link, Paper, @@ -10,9 +9,6 @@ import { } from '@mui/material'; import { FC, useEffect } from 'react'; import logoRectangle from '../../common/assets/logo/rectangle.png'; -import orcidLogo from '../../common/assets/orcid.png'; -import globusLogo from '../../common/assets/globus.png'; -import googleLogo from '../../common/assets/google.webp'; import classes from './LogIn.module.scss'; import { useAppDispatch, useAppSelector } from '../../common/hooks'; import { useAppParam } from '../params/hooks'; @@ -23,6 +19,7 @@ import { toast } from 'react-hot-toast'; import { revokeToken } from '../../common/api/authService'; import { noOp } from '../common'; import { useCookie } from '../../common/cookie'; +import { ProviderButtons } from '../auth/providers'; export const useCheckLoggedIn = (nextRequest: string | undefined) => { const { initialized, token } = useAppSelector((state) => state.auth); @@ -125,7 +122,9 @@ export const LogIn: FC = () => { ) : ( <> )} - `Continue with ${provider}`} /> + `Continue with ${provider}`} + /> New to KBase? Sign up @@ -168,69 +167,3 @@ export const makeLoginURLs = (nextRequest?: string) => { return { loginOrigin, loginActionUrl, loginRedirectUrl }; }; - -export const LoginButtons = ({ - text, -}: { - text: (provider: string) => string; -}) => { - return ( - - - - - - - - - ); -}; diff --git a/src/features/signup/ProviderSelect.tsx b/src/features/signup/ProviderSelect.tsx index 6c7f1409..502d7d80 100644 --- a/src/features/signup/ProviderSelect.tsx +++ b/src/features/signup/ProviderSelect.tsx @@ -9,7 +9,8 @@ import { } from '@mui/material'; import { FC } from 'react'; import { LOGIN_ROUTE } from '../../app/Routes'; -import { LoginButtons, makeLoginURLs } from '../login/LogIn'; +import { ProviderButtons } from '../auth/providers'; +import { makeLoginURLs } from '../login/LogIn'; import classes from './SignUp.module.scss'; /** @@ -32,7 +33,9 @@ export const ProviderSelect: FC = () => { <> )}
- `Sign up with ${provider}`} /> + `Sign up with ${provider}`} + />