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}
+
+ }
+ >
+ Linking
+
+
+
+ ) : 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 ORCID
-
-
- }
- >
- Link with Google
-
-
- }
- >
- Link with Globus
-
-
+
);
};
+
+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: (
+
+ ),
+ },
+ {
+ name: 'Google',
+ icon: (
+
+ ),
+ },
+ {
+ name: 'Globus',
+ icon: (
+
+ ),
+ },
+];
+
+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 (
-
-
- }
- data-testid="loginORCID"
- >
- {text('ORCID')}
-
-
-
-
- }
- >
- {text('Google')}
-
-
- }
- >
- {text('Globus')}
-
-
-
- );
-};
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 = () => {
<>>
)}