diff --git a/config/alpha.json b/config/alpha.json index 65355ac867..83d9a1d820 100644 --- a/config/alpha.json +++ b/config/alpha.json @@ -2,7 +2,6 @@ "agoraUrlRoot": "https://agora.dsde-alpha.broadinstitute.org", "bardRoot": "https://terra-bard-alpha.appspot.com", "billingProfileManagerUrlRoot": "https://bpm.dsde-alpha.broadinstitute.org", - "bondUrlRoot": "https://bond.dsde-alpha.broadinstitute.org", "calhounUrlRoot": "https://calhoun.dsde-alpha.broadinstitute.org", "catalogUrlRoot": "https://catalog.dsde-alpha.broadinstitute.org", "dataRepoUrlRoot": "https://data.alpha.envs-terra.bio", diff --git a/config/dev.json b/config/dev.json index 89f8b2b6d7..13dfea7b6b 100644 --- a/config/dev.json +++ b/config/dev.json @@ -2,7 +2,6 @@ "agoraUrlRoot": "https://agora.dsde-dev.broadinstitute.org", "bardRoot": "https://terra-bard-dev.appspot.com", "billingProfileManagerUrlRoot": "https://bpm.dsde-dev.broadinstitute.org", - "bondUrlRoot": "https://bond.dsde-dev.broadinstitute.org", "calhounUrlRoot": "https://calhoun.dsde-dev.broadinstitute.org", "catalogUrlRoot": "https://catalog.dsde-dev.broadinstitute.org", "dataRepoUrlRoot": "https://jade.datarepo-dev.broadinstitute.org", diff --git a/config/prod.json b/config/prod.json index 967854586e..b4a9924326 100644 --- a/config/prod.json +++ b/config/prod.json @@ -2,7 +2,6 @@ "agoraUrlRoot": "https://agora.dsde-prod.broadinstitute.org", "bardRoot": "https://terra-bard-prod.appspot.com", "billingProfileManagerUrlRoot": "https://bpm.dsde-prod.broadinstitute.org", - "bondUrlRoot": "https://bond.dsde-prod.broadinstitute.org", "calhounUrlRoot": "https://calhoun.dsde-prod.broadinstitute.org", "catalogUrlRoot": "https://catalog.dsde-prod.broadinstitute.org", "dataRepoUrlRoot": "https://data.terra.bio", diff --git a/config/staging.json b/config/staging.json index e7c06bf85e..3ec1bd0843 100644 --- a/config/staging.json +++ b/config/staging.json @@ -2,7 +2,6 @@ "agoraUrlRoot": "https://agora.dsde-staging.broadinstitute.org", "bardRoot": "https://terra-bard-staging.appspot.com", "billingProfileManagerUrlRoot": "https://bpm.dsde-staging.broadinstitute.org", - "bondUrlRoot": "https://bond.dsde-staging.broadinstitute.org", "calhounUrlRoot": "https://calhoun.dsde-staging.broadinstitute.org", "catalogUrlRoot": "https://catalog.dsde-staging.broadinstitute.org", "dataRepoUrlRoot": "https://data.staging.envs-terra.bio", diff --git a/src/auth/AuthContainer.test.tsx b/src/auth/AuthContainer.test.tsx index c68f45e607..b827dc0268 100644 --- a/src/auth/AuthContainer.test.tsx +++ b/src/auth/AuthContainer.test.tsx @@ -206,7 +206,6 @@ describe('AuthContainer', () => { } as Partial, User: { getNihStatus: jest.fn(), - getFenceStatus: jest.fn(), } as Partial, TermsOfService: { getUserTermsOfServiceDetails: jest.fn(), diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 2e733ce4b3..95054461d7 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -22,7 +22,6 @@ import Events, { captureAppcuesEvent, MetricsEventName } from 'src/libs/events'; import * as Nav from 'src/libs/nav'; import { clearNotification, notify, sessionTimeoutProps } from 'src/libs/notifications'; import { getLocalPref, getLocalPrefForUserId, setLocalPref } from 'src/libs/prefs'; -import allProviders from 'src/libs/providers'; import { asyncImportJobStore, AuthState, @@ -42,6 +41,7 @@ import { workspaceStore, } from 'src/libs/state'; import * as Utils from 'src/libs/utils'; +import { allOAuth2Providers } from 'src/profile/external-identities/OAuth2Providers'; import { v4 as uuid } from 'uuid'; export const getAuthToken = (): string | undefined => { @@ -655,18 +655,16 @@ authStore.subscribe( ); authStore.subscribe( - withErrorReporting('Error loading Framework Services account status')( - async (state: AuthState, oldState: AuthState) => { - if (userCanNowUseTerra(oldState, state)) { - await Promise.all( - _.map(async ({ key }) => { - const status = await Ajax().User.getFenceStatus(key); - authStore.update(_.set(['fenceStatus', key], status)); - }, allProviders) - ); - } + withErrorReporting('Error loading OAuth2 account status')(async (state: AuthState, oldState: AuthState) => { + if (userCanNowUseTerra(oldState, state)) { + await Promise.all( + _.map(async (provider) => { + const status = await Ajax().ExternalCredentials(provider).getAccountLinkStatus(); + authStore.update(_.set(['oAuth2AccountStatus', provider.key], status)); + }, allOAuth2Providers) + ); } - ) + }) ); authStore.subscribe((state: AuthState, oldState: AuthState) => { diff --git a/src/libs/ajax/ExternalCredentials.ts b/src/libs/ajax/ExternalCredentials.ts index b5ace7cb6b..8a79e1bf17 100644 --- a/src/libs/ajax/ExternalCredentials.ts +++ b/src/libs/ajax/ExternalCredentials.ts @@ -17,7 +17,12 @@ export const ExternalCredentials = (signal?: AbortSignal) => (oAuth2Provider: OA getAccountLinkStatus: async (): Promise => { try { const res = await fetchEcm(oauthRoot, _.merge(authOpts(), { signal })); - return res.json(); + const json = await res.json(); + return { + externalUserId: json.externalUserId, + expirationTimestamp: new Date(json.expirationTimestamp), + authenticated: json.authenticated, + }; } catch (error: unknown) { if (error instanceof Response && error.status === 404) { return undefined; diff --git a/src/libs/ajax/User.ts b/src/libs/ajax/User.ts index 57c9f4643d..489a79311a 100644 --- a/src/libs/ajax/User.ts +++ b/src/libs/ajax/User.ts @@ -1,6 +1,5 @@ import _ from 'lodash/fp'; -import * as qs from 'qs'; -import { authOpts, fetchBond, fetchOrchestration, fetchRex, fetchSam, jsonBody } from 'src/libs/ajax/ajax-common'; +import { authOpts, fetchOrchestration, fetchRex, fetchSam, jsonBody } from 'src/libs/ajax/ajax-common'; import { getTerraUser, TerraUserProfile } from 'src/libs/state'; import * as Utils from 'src/libs/utils'; @@ -140,15 +139,6 @@ export interface OrchestrationNihStatusResponse { linkExpireTime: number; } -export interface BondFenceUrlResponse { - url: string; -} - -export interface BondFenceStatusResponse { - issued_at: Date; - username: string; -} - export interface SamInviteUserResponse { userSubjectId: string; userEmail: string; @@ -309,53 +299,6 @@ export const User = (signal?: AbortSignal) => { await fetchOrchestration('api/nih/account', _.mergeAll([authOpts(), { signal, method: 'DELETE' }])); }, - getFenceStatus: async (providerKey: string): Promise => { - try { - const res = await fetchBond(`api/link/v1/${providerKey}`, _.merge(authOpts(), { signal })); - return res.json(); - } catch (error: unknown) { - if (error instanceof Response && error.status === 404) { - return {}; - } - throw error; - } - }, - - getFenceAuthUrl: async (providerKey: string, redirectUri: string): Promise => { - const queryParams = { - scopes: ['openid', 'google_credentials', 'data', 'user'], - redirect_uri: redirectUri, - state: btoa(JSON.stringify({ provider: providerKey })), - }; - const res = await fetchBond( - `api/link/v1/${providerKey}/authorization-url?${qs.stringify(queryParams, { indices: false })}`, - _.merge(authOpts(), { signal }) - ); - return res.json(); - }, - - linkFenceAccount: async ( - providerKey: string, - authCode: string | undefined, - redirectUri: string, - state: string - ): Promise => { - const queryParams = { - oauthcode: authCode, - redirect_uri: redirectUri, - state, - }; - const res = await fetchBond( - `api/link/v1/${providerKey}/oauthcode?${qs.stringify(queryParams)}`, - _.merge(authOpts(), { signal, method: 'POST' }) - ); - return res.json(); - }, - - unlinkFenceAccount: async (providerKey: string): Promise => { - await fetchBond(`api/link/v1/${providerKey}`, _.merge(authOpts(), { signal, method: 'DELETE' })); - }, - isUserRegistered: async (email: string): Promise => { try { await fetchSam(`api/users/v1/${encodeURIComponent(email)}`, _.merge(authOpts(), { signal, method: 'GET' })); diff --git a/src/libs/ajax/ajax-common.ts b/src/libs/ajax/ajax-common.ts index bc1d147207..3a6f288625 100644 --- a/src/libs/ajax/ajax-common.ts +++ b/src/libs/ajax/ajax-common.ts @@ -264,11 +264,6 @@ export const fetchRex = _.flow( withRetryAfterReloadingExpiredAuthToken )(fetchOk); -export const fetchBond = _.flow( - withUrlPrefix(`${getConfig().bondUrlRoot}/`), - withRetryAfterReloadingExpiredAuthToken -)(fetchOk); - export const fetchDrsHub = _.flow( withUrlPrefix(`${getConfig().drsHubUrlRoot}/`), withRetryAfterReloadingExpiredAuthToken diff --git a/src/libs/link-expiration-alerts.js b/src/libs/link-expiration-alerts.js deleted file mode 100644 index a79c30271f..0000000000 --- a/src/libs/link-expiration-alerts.js +++ /dev/null @@ -1,80 +0,0 @@ -import { addDays, differenceInDays, parseJSON } from 'date-fns/fp'; -import _ from 'lodash/fp'; -import { Fragment, useEffect, useState } from 'react'; -import { h } from 'react-hyperscript-helpers'; -import { getEnabledBrand } from 'src/libs/brand-utils'; -import * as Nav from 'src/libs/nav'; -import allProviders from 'src/libs/providers'; -import { authStore } from 'src/libs/state'; -import { FrameworkServiceLink } from 'src/profile/external-identities/FrameworkServiceLink'; -import { ShibbolethLink } from 'src/profile/external-identities/ShibbolethLink'; -import { UnlinkFenceAccount } from 'src/profile/external-identities/UnlinkFenceAccount'; - -const getNihAccountLinkExpirationAlert = (status, now) => { - // Orchestration API returns NIH link expiration time in seconds since epoch - const dateOfExpiration = status && new Date(status.linkExpireTime * 1000); - const shouldNotify = Boolean(dateOfExpiration) && now >= addDays(-1, dateOfExpiration); - if (!shouldNotify) { - return null; - } - - const hasExpired = now >= dateOfExpiration; - const expireStatus = hasExpired ? 'has expired' : 'will expire soon'; - - return { - id: 'nih-link-expiration', - title: `Your access to NIH Controlled Access workspaces and data ${expireStatus}.`, - message: h(Fragment, [ - 'To regain access, ', - h(ShibbolethLink, { style: { color: 'unset', fontWeight: 600, textDecoration: 'underline' } }, ['re-link']), - ` your eRA Commons / NIH account (${status.linkedNihUsername}) with ${getEnabledBrand().name}.`, - ]), - severity: 'info', - }; -}; - -const getFenceAccountLinkExpirationAlert = (provider, status, now) => { - const { key, name } = provider; - - // Bond API returns link time as an ISO formatted string. - const dateOfExpiration = status && addDays(provider.expiresAfter, parseJSON(status.issued_at)); - const shouldNotify = Boolean(dateOfExpiration) && now >= addDays(-5, dateOfExpiration); - - if (!shouldNotify) { - return null; - } - - const hasExpired = now >= dateOfExpiration; - const expireStatus = hasExpired ? 'has expired' : `will expire in ${differenceInDays(now, dateOfExpiration)} day(s)`; - - const redirectUrl = `${window.location.origin}/${Nav.getLink('fence-callback')}`; - return { - id: `fence-link-expiration/${key}`, - title: `Your access to ${name} ${expireStatus}.`, - message: h(Fragment, [ - 'Log in to ', - h(FrameworkServiceLink, { linkText: expireStatus === 'has expired' ? 'restore ' : 'renew ', providerKey: key, redirectUrl }), - ' your access or ', - h(UnlinkFenceAccount, { linkText: 'unlink ', provider: { key, name } }), - ' your account.', - ]), - severity: 'info', - }; -}; - -export const getLinkExpirationAlerts = (authState) => { - const now = Date.now(); - - return _.compact([ - getNihAccountLinkExpirationAlert(authState.nihStatus, now), - ..._.map((provider) => getFenceAccountLinkExpirationAlert(provider, _.get(['fenceStatus', provider.key], authState), now), allProviders), - ]); -}; - -export const useLinkExpirationAlerts = () => { - const [alerts, setAlerts] = useState(() => getLinkExpirationAlerts(authStore.get())); - useEffect(() => { - return authStore.subscribe((authState) => setAlerts(getLinkExpirationAlerts(authState))).unsubscribe; - }, []); - return alerts; -}; diff --git a/src/libs/link-expiration-alerts.test.js b/src/libs/link-expiration-alerts.test.ts similarity index 56% rename from src/libs/link-expiration-alerts.test.js rename to src/libs/link-expiration-alerts.test.ts index 4251b3a9a1..5a0f630807 100644 --- a/src/libs/link-expiration-alerts.test.js +++ b/src/libs/link-expiration-alerts.test.ts @@ -1,8 +1,11 @@ import { addDays, addHours, setMilliseconds } from 'date-fns/fp'; import _ from 'lodash/fp'; +import { ReactElement } from 'react'; +import { getConfig } from 'src/libs/config'; import { getLinkExpirationAlerts } from 'src/libs/link-expiration-alerts'; import * as Nav from 'src/libs/nav'; -import { renderWithAppContexts as render } from 'src/testing/test-utils'; +import { AuthState } from 'src/libs/state'; +import { asMockedFn, renderWithAppContexts as render } from 'src/testing/test-utils'; jest.mock('src/auth/auth', () => { return { @@ -11,14 +14,26 @@ jest.mock('src/auth/auth', () => { }; }); -jest.mock('src/libs/providers', () => [ - { - key: 'anvil', - name: 'NHGRI AnVIL Data Commons Framework Services', - expiresAfter: 30, - short: 'NHGRI', - }, -]); +jest.mock('src/libs/config', () => ({ + ...jest.requireActual('src/libs/config'), + getConfig: jest.fn().mockReturnValue({}), +})); + +jest.mock('src/profile/external-identities/OAuth2Providers', () => ({ + allOAuth2Providers: [ + { + key: 'fence', + name: 'NHLBI BioData Catalyst Framework Services', + short: 'NHLBI', + queryParams: { + redirectUri: 'localhost://#fence-callback', + }, + supportsAccessToken: true, + supportsIdToken: false, + isFence: true, + }, + ], +})); describe('getLinkExpirationAlerts', () => { beforeAll(() => { @@ -36,8 +51,9 @@ describe('getLinkExpirationAlerts', () => { nihStatus: { linkedNihUsername: 'user@example.com', linkExpireTime: expirationDate.getTime() / 1000, + datasetPermissions: [], }, - }); + } as unknown as AuthState); expect(alerts).toEqual( expect.arrayContaining([ @@ -56,8 +72,9 @@ describe('getLinkExpirationAlerts', () => { nihStatus: { linkedNihUsername: 'user@example.com', linkExpireTime: expirationDate.getTime() / 1000, + datasetPermissions: [], }, - }); + } as unknown as AuthState); expect(alerts).toEqual( expect.arrayContaining([ @@ -76,8 +93,9 @@ describe('getLinkExpirationAlerts', () => { nihStatus: { linkedNihUsername: 'user@example.com', linkExpireTime: expirationDate.getTime() / 1000, + datasetPermissions: [], }, - }); + } as unknown as AuthState); expect(alerts).toEqual([]); }); @@ -86,69 +104,75 @@ describe('getLinkExpirationAlerts', () => { describe('fence links', () => { beforeEach(() => { jest.spyOn(Nav, 'getLink').mockReturnValue('fence-callback'); + asMockedFn(getConfig).mockReturnValue({ externalCreds: { providers: ['fence'], urlRoot: 'https/foo.bar.com' } }); }); it('includes alert if link has expired', () => { - const issueDate = addDays(-90, new Date()); + const expirationDate = addDays(-90, new Date()); const alerts = getLinkExpirationAlerts({ - fenceStatus: { - anvil: { - username: 'user@example.com', - issued_at: issueDate.toISOString(), + oAuth2AccountStatus: { + fence: { + externalUserId: 'user@example.com', + expirationTimestamp: expirationDate, + authenticated: true, }, }, - }); + } as unknown as AuthState); expect(alerts).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: 'fence-link-expiration/anvil', - title: 'Your access to NHGRI AnVIL Data Commons Framework Services has expired.', + id: 'oauth2-account-link-expiration/fence', + title: 'Your access to NHLBI BioData Catalyst Framework Services has expired.', }), ]) ); - const { message } = _.find({ id: 'fence-link-expiration/anvil' }, alerts); - const { container } = render(message); + const message = _.find({ id: 'oauth2-account-link-expiration/fence' }, alerts)?.message; + const { container } = render(message as ReactElement); expect(container).toHaveTextContent('Log in to restore your access or unlink your account.'); }); it('includes alert if link will expire within the next 5 days', () => { - const issueDate = addDays(-27, new Date()); + const expirationDate = addDays(3, new Date()); const alerts = getLinkExpirationAlerts({ - fenceStatus: { - anvil: { - username: 'user@example.com', - issued_at: issueDate.toISOString(), + oAuth2AccountStatus: { + fence: { + externalUserId: 'user@example.com', + expirationTimestamp: expirationDate, + authenticated: true, }, }, - }); + } as unknown as AuthState); expect(alerts).toEqual( expect.arrayContaining([ expect.objectContaining({ - id: 'fence-link-expiration/anvil', - title: 'Your access to NHGRI AnVIL Data Commons Framework Services will expire in 3 day(s).', + id: 'oauth2-account-link-expiration/fence', + title: 'Your access to NHLBI BioData Catalyst Framework Services will expire in 3 day(s).', }), ]) ); - const { message } = _.find({ id: 'fence-link-expiration/anvil' }, alerts); - const { container } = render(message); + const message = _.find({ id: 'oauth2-account-link-expiration/fence' }, alerts)?.message; + const { container } = render(message as ReactElement); expect(container).toHaveTextContent('Log in to renew your access or unlink your account.'); }); it('does not include alert if link will not expire within the next 5 days', () => { + const expirationDate = addDays(30, new Date()); + const alerts = getLinkExpirationAlerts({ - fenceStatus: { - anvil: { - username: 'user@example.com', - issued_at: new Date().toISOString(), + oAuth2AccountStatus: { + fence: { + externalUserId: 'user@example.com', + expirationTimestamp: expirationDate, + authenticated: true, }, }, - }); + } as unknown as AuthState); expect(alerts).toEqual([]); }); diff --git a/src/libs/link-expiration-alerts.tsx b/src/libs/link-expiration-alerts.tsx new file mode 100644 index 0000000000..e7a4266f7c --- /dev/null +++ b/src/libs/link-expiration-alerts.tsx @@ -0,0 +1,104 @@ +import { addDays, differenceInDays } from 'date-fns/fp'; +import _ from 'lodash/fp'; +import React, { useEffect, useState } from 'react'; +import { Alert } from 'src/alerts/Alert'; +import { EcmLinkAccountResponse } from 'src/libs/ajax/ExternalCredentials'; +import { getEnabledBrand } from 'src/libs/brand-utils'; +import { AuthState, authStore, NihStatus } from 'src/libs/state'; +import { LinkOAuth2Account } from 'src/profile/external-identities/LinkOAuth2Account'; +import { allOAuth2Providers, OAuth2Provider } from 'src/profile/external-identities/OAuth2Providers'; +import { ShibbolethLink } from 'src/profile/external-identities/ShibbolethLink'; +import { UnlinkOAuth2Account } from 'src/profile/external-identities/UnlinkOAuth2Account'; + +const getNihAccountLinkExpirationAlert = (status: NihStatus | undefined, now: number) => { + if (status) { + // Orchestration API returns NIH link expiration time in seconds since epoch + const dateOfExpiration = status && new Date(status.linkExpireTime * 1000).getTime(); + const shouldNotify = Boolean(dateOfExpiration) && now >= addDays(-1, dateOfExpiration).getTime(); + if (!shouldNotify) { + return null; + } + + const hasExpired = now >= dateOfExpiration; + const expireStatus = hasExpired ? 'has expired' : 'will expire soon'; + + return { + id: 'nih-link-expiration', + title: `Your access to NIH Controlled Access workspaces and data ${expireStatus}.`, + message: ( +
+ To regain access,{' '} + + re-link + {' '} + your eRA Commons / NIH account ({status.linkedNihUsername}) with {getEnabledBrand().name}. +
+ ), + severity: 'info', + }; + } + return undefined; +}; + +export const getOAuth2ProviderLinkExpirationAlert = ( + provider: OAuth2Provider, + status: EcmLinkAccountResponse, + now: number +) => { + const { key, name } = provider; + const { expirationTimestamp } = status; + + const dateOfExpiration = expirationTimestamp.getTime(); + const shouldNotify = Boolean(dateOfExpiration) && now >= addDays(-5, dateOfExpiration).getTime(); + + if (!shouldNotify) { + return null; + } + + const hasExpired = now >= dateOfExpiration; + const expireStatus = hasExpired ? 'has expired' : `will expire in ${differenceInDays(now, dateOfExpiration)} day(s)`; + + return { + id: `oauth2-account-link-expiration/${key}`, + title: `Your access to ${name} ${expireStatus}.`, + message: ( +
+ Log in to{' '} + {' '} + your access or your account. +
+ ), + severity: 'info', + }; +}; + +export const getLinkExpirationAlerts = (authState: AuthState): Alert[] => { + const now = Date.now(); + + const oauth2Providers = allOAuth2Providers.map((provider) => { + const maybeResponse = _.get(['oAuth2AccountStatus', provider.key], authState); + if (maybeResponse) { + return { provider, ecmAccountStatus: maybeResponse }; + } + return undefined; + }); + return _.compact([ + getNihAccountLinkExpirationAlert(authState.nihStatus, now), + ..._.map( + ({ provider, ecmAccountStatus }) => getOAuth2ProviderLinkExpirationAlert(provider, ecmAccountStatus, now), + _.compact(oauth2Providers) + ), + ]); +}; + +export const useLinkExpirationAlerts = () => { + const [alerts, setAlerts] = useState(() => getLinkExpirationAlerts(authStore.get())); + useEffect(() => { + return authStore.subscribe((authState) => setAlerts(getLinkExpirationAlerts(authState))).unsubscribe; + }, []); + return alerts; +}; diff --git a/src/libs/providers.js b/src/libs/providers.js deleted file mode 100644 index 25aa8f0685..0000000000 --- a/src/libs/providers.js +++ /dev/null @@ -1,15 +0,0 @@ -import { getConfig } from 'src/libs/config'; - -const terraDeploymentEnv = getConfig().terraDeploymentEnv; - -const anvil = - terraDeploymentEnv === 'prod' ? [{ key: 'anvil', name: 'NHGRI AnVIL Data Commons Framework Services', expiresAfter: 30, short: 'NHGRI' }] : []; - -const allProviders = [ - { key: 'fence', name: 'NHLBI BioData Catalyst Framework Services', expiresAfter: 30, short: 'NHLBI' }, - { key: 'dcf-fence', name: 'NCI CRDC Framework Services', expiresAfter: 15, short: 'NCI' }, - ...anvil, - { key: 'kids-first', name: 'Kids First DRC Framework Services', expiresAfter: 30, short: 'KidsFirst' }, -]; - -export default allProviders; diff --git a/src/libs/state.ts b/src/libs/state.ts index 3272b049b0..13078a31a1 100644 --- a/src/libs/state.ts +++ b/src/libs/state.ts @@ -4,14 +4,10 @@ import { AuthContextProps } from 'react-oidc-context'; import { AuthTokenState } from 'src/auth/auth'; import { OidcUser } from 'src/auth/oidc-broker'; import { Dataset } from 'src/libs/ajax/Catalog'; +import { EcmLinkAccountResponse } from 'src/libs/ajax/ExternalCredentials'; import { OidcConfig } from 'src/libs/ajax/OAuth2'; import { SamTermsOfServiceConfig } from 'src/libs/ajax/TermsOfService'; -import { - BondFenceStatusResponse, - NihDatasetPermission, - SamUserAllowances, - SamUserAttributes, -} from 'src/libs/ajax/User'; +import { NihDatasetPermission, SamUserAllowances, SamUserAttributes } from 'src/libs/ajax/User'; import { getLocalStorage, getSessionStorage, staticStorageSlot } from 'src/libs/browser-storage'; import type { WorkspaceWrapper } from 'src/workspaces/utils'; @@ -58,13 +54,13 @@ export type SignInStatusState = export type SignInStatus = Initializable; -export interface FenceStatus { - [key: string]: BondFenceStatusResponse; +export interface OAuth2AccountStatus { + [key: string]: EcmLinkAccountResponse; } export interface AuthState { cookiesAccepted: boolean | undefined; - fenceStatus: FenceStatus; + oAuth2AccountStatus: OAuth2AccountStatus; hasGcpBillingScopeThroughB2C: boolean | undefined; signInStatus: SignInStatus; userJustSignedIn: boolean; @@ -80,7 +76,7 @@ export interface AuthState { */ export const authStore: Atom = atom({ cookiesAccepted: undefined, - fenceStatus: {}, + oAuth2AccountStatus: {}, hasGcpBillingScopeThroughB2C: false, signInStatus: 'uninitialized', userJustSignedIn: false, diff --git a/src/profile/external-identities/ExternalIdentities.test.tsx b/src/profile/external-identities/ExternalIdentities.test.tsx index db0285484e..05cad07deb 100644 --- a/src/profile/external-identities/ExternalIdentities.test.tsx +++ b/src/profile/external-identities/ExternalIdentities.test.tsx @@ -11,19 +11,9 @@ jest.mock('src/libs/config', () => ({ getConfig: jest.fn().mockReturnValue({}), })); -jest.mock('src/profile/external-identities/OAuth2Link', () => ({ - ...jest.requireActual('src/profile/external-identities/OAuth2Link'), - OAuth2Link: jest.fn((props) =>
{props.provider.name}
), -})); - -jest.mock('src/profile/external-identities/OAuth2Link', () => ({ - ...jest.requireActual('src/profile/external-identities/OAuth2Link'), - OAuth2Link: jest.fn((props) =>
{props.provider.name}
), -})); - -jest.mock('src/profile/external-identities/FenceAccount', () => ({ - ...jest.requireActual('src/profile/external-identities/FenceAccount'), - FenceAccount: jest.fn((props) =>
{props.provider.name}
), +jest.mock('src/profile/external-identities/OAuth2Account', () => ({ + ...jest.requireActual('src/profile/external-identities/OAuth2Account'), + OAuth2Account: jest.fn((props) =>
{props.provider.name}
), })); jest.mock('src/profile/external-identities/NihAccount', () => ({ diff --git a/src/profile/external-identities/ExternalIdentities.tsx b/src/profile/external-identities/ExternalIdentities.tsx index d92824d92b..f11e938605 100644 --- a/src/profile/external-identities/ExternalIdentities.tsx +++ b/src/profile/external-identities/ExternalIdentities.tsx @@ -3,8 +3,8 @@ import { PageBox, PageBoxVariants } from 'src/components/PageBox'; import { userHasAccessToEnterpriseFeature } from 'src/enterprise-features/features'; import { getConfig } from 'src/libs/config'; import { NihAccount } from 'src/profile/external-identities/NihAccount'; -import { OAuth2Link } from 'src/profile/external-identities/OAuth2Link'; -import { oauth2Provider } from 'src/profile/external-identities/OAuth2Providers'; +import { OAuth2Account } from 'src/profile/external-identities/OAuth2Account'; +import { oauth2Provider, OAuth2ProviderKey } from 'src/profile/external-identities/OAuth2Providers'; type ExternalIdentitiesProps = { queryParams: { [key: string]: string }; @@ -18,8 +18,8 @@ export const ExternalIdentities = (props: ExternalIdentitiesProps): ReactNode => {getConfig() .externalCreds?.providers.filter((p) => p !== 'github') - .map((providerKey) => ( - ( + ))} {getConfig().externalCreds?.providers.includes('github') && userHasAccessToEnterpriseFeature('github-account-linking') && ( - + )} ); diff --git a/src/profile/external-identities/FenceAccount.test.ts b/src/profile/external-identities/FenceAccount.test.ts deleted file mode 100644 index 005bd1a7cc..0000000000 --- a/src/profile/external-identities/FenceAccount.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { act, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { h } from 'react-hyperscript-helpers'; -import { Ajax } from 'src/libs/ajax'; -import { authStore, FenceStatus } from 'src/libs/state'; -import { FenceAccount } from 'src/profile/external-identities/FenceAccount'; -import { asMockedFn, renderWithAppContexts } from 'src/testing/test-utils'; - -// Mocking for Nav.getLink -type NavExports = typeof import('src/libs/nav'); -jest.mock( - 'src/libs/nav', - (): NavExports => ({ - ...jest.requireActual('src/libs/nav'), - getLink: jest.fn(() => ''), - useRoute: jest.fn(() => 'fence-callback'), - }) -); - -jest.mock('react-notifications-component', () => { - return { - Store: { - addNotification: jest.fn(), - removeNotification: jest.fn(), - }, - }; -}); - -jest.mock('src/libs/ajax'); - -const nhlbi = { - key: 'fence', - provider: { - key: 'fence', - name: 'NHLBI BioData Catalyst Framework Services', - expiresAfter: 30, - short: 'NHLBI', - }, -}; - -const fenceStatus: FenceStatus = { - fence: { - issued_at: new Date(Date.now()), - username: 'bojackhorseman', - }, -}; - -type AjaxContract = ReturnType; -type MetricsPartial = Partial; -type UserPartial = Partial; - -describe('FenceLink', () => { - describe('when the user has not linked a fence account', () => { - it('renders the login button', async () => { - // Arrange - - asMockedFn(Ajax).mockImplementation( - () => - ({ - Metrics: { captureEvent: jest.fn() } as MetricsPartial, - User: { getFenceAuthUrl: jest.fn().mockReturnValue({ url: 'https://foo.bar' }) } as UserPartial, - } as AjaxContract) - ); - // Act - await act(async () => { - renderWithAppContexts(h(FenceAccount, nhlbi)); - }); - - // Assert - expect(screen.getByText('Log in to NHLBI')); - }); - }); - - describe('when a user has linked a fence account', () => { - it('renders the status of the Fence Account link', async () => { - // Arrange - - asMockedFn(Ajax).mockImplementation( - () => - ({ - Metrics: { captureEvent: jest.fn() } as MetricsPartial, - User: { getFenceAuthUrl: jest.fn().mockReturnValue({ url: 'https://foo.bar' }) } as UserPartial, - } as AjaxContract) - ); - - await act(async () => { - authStore.update((state) => ({ ...state, fenceStatus })); - }); - - // Act - await act(async () => { - renderWithAppContexts(h(FenceAccount, nhlbi)); - }); - - // Assert - expect(screen.getByText('Renew')); - expect(screen.getByText('Unlink')); - expect(screen.getByText(fenceStatus.fence.username)); - expect(screen.getByText('Link Expiration:')); - }); - - it('reaches out to Bond when the "Unlink" link is clicked', async () => { - // Arrange - const user = userEvent.setup(); - - const unlinkFenceAccountFunction = jest.fn().mockReturnValue(Promise.resolve()); - asMockedFn(Ajax).mockImplementation( - () => - ({ - Metrics: { captureEvent: jest.fn() } as MetricsPartial, - User: { - unlinkFenceAccount: unlinkFenceAccountFunction, - getFenceAuthUrl: jest.fn().mockReturnValue({ url: 'https://foo.bar' }), - } as UserPartial, - } as AjaxContract) - ); - await act(async () => { - authStore.update((state) => ({ ...state, fenceStatus })); - }); - - // Act - renderWithAppContexts(h(FenceAccount, nhlbi)); - - await user.click(screen.getByText('Unlink')); - expect(screen.getByText('Confirm unlink account')); - - await user.click(screen.getByText('OK')); - - // Assert - expect(unlinkFenceAccountFunction).toHaveBeenCalled(); - }); - }); - describe('when loading after being redirected from an NIH login provider', () => { - it('reaches out to Bond when the page is loaded after the user has logged in to a Fence Account', async () => { - const url = new URL('https://localhost:3000/?code=oauthCode&state=eyJwcm92aWRlciI6ICJmZW5jZSJ9#fence-callback'); - Object.defineProperty(window, 'location', { - value: { - href: url.href, - search: url.search, - }, - writable: true, // possibility to override - }); - // Arrange - const linkFenceAccountFunction = jest.fn().mockReturnValue(Promise.resolve()); - asMockedFn(Ajax).mockImplementation( - () => - ({ - Metrics: { captureEvent: jest.fn() } as MetricsPartial, - User: { - linkFenceAccount: linkFenceAccountFunction, - getFenceAuthUrl: jest.fn().mockReturnValue({ url: 'https://foo.bar' }), - } as UserPartial, - } as AjaxContract) - ); - // Act - await act(async () => { - renderWithAppContexts(h(FenceAccount, nhlbi)); - }); - - // Assert - expect(linkFenceAccountFunction).toHaveBeenCalled(); - }); - }); -}); diff --git a/src/profile/external-identities/FenceAccount.ts b/src/profile/external-identities/FenceAccount.ts deleted file mode 100644 index c6afa0ac77..0000000000 --- a/src/profile/external-identities/FenceAccount.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { addDays, parseJSON } from 'date-fns/fp'; -import _ from 'lodash/fp'; -import * as qs from 'qs'; -import { Fragment, useState } from 'react'; -import { div, h, h3, span } from 'react-hyperscript-helpers'; -import { Ajax } from 'src/libs/ajax'; -import { withErrorReporting } from 'src/libs/error'; -import * as Nav from 'src/libs/nav'; -import { useOnMount, useStore } from 'src/libs/react-utils'; -import { authStore } from 'src/libs/state'; -import * as Utils from 'src/libs/utils'; -import { FrameworkServiceLink } from 'src/profile/external-identities/FrameworkServiceLink'; -import { linkStyles as styles } from 'src/profile/external-identities/LinkStyles'; -import { UnlinkFenceAccount } from 'src/profile/external-identities/UnlinkFenceAccount'; -import { SpacedSpinner } from 'src/profile/SpacedSpinner'; - -export const FenceAccount = ({ provider: { key, name, expiresAfter, short } }) => { - // State - const { fenceStatus: storedFenceStatus } = useStore(authStore); - const { username, issued_at: issuedAt } = storedFenceStatus[key] ?? { username: undefined, issued_at: undefined }; - - const oauth2State = new URLSearchParams(window.location.search).get('state'); - const provider = oauth2State ? JSON.parse(atob(oauth2State)).provider : ''; - const [isLinking, setIsLinking] = useState(Nav.useRoute().name === 'fence-callback' && key === provider); - - // Helpers - const redirectUrl = `${window.location.origin}/${Nav.getLink('fence-callback')}`; - - // Lifecycle - useOnMount(() => { - const { state, code } = qs.parse(window.location.search, { ignoreQueryPrefix: true }); - const extractedProvider = state ? JSON.parse(atob(state)).provider : ''; - const token = key === extractedProvider ? code : undefined; - - const linkFenceAccount = _.flow( - withErrorReporting('Error linking NIH account'), - Utils.withBusyState(setIsLinking) - )(async () => { - const status = await Ajax().User.linkFenceAccount(key, token, redirectUrl, state); - authStore.update(_.set(['fenceStatus', key], status)); - }); - - if (token) { - const profileLink = `/${Nav.getLink('profile')}`; - window.history.replaceState({}, '', profileLink); - linkFenceAccount(); - } - }); - - // Render - return div({ style: styles.idLink.container }, [ - div({ style: styles.idLink.linkContentTop(false) }, [ - h3({ style: { marginTop: 0, ...styles.idLink.linkName } }, [name]), - Utils.cond( - [isLinking, () => h(SpacedSpinner, ['Loading account status...'])], - [ - !username, - () => - div([ - h(FrameworkServiceLink, { button: true, linkText: `Log in to ${short} `, providerKey: key, redirectUrl }), - ]), - ], - () => - h(Fragment, [ - div([span({ style: styles.idLink.linkDetailLabel }, ['Username:']), username]), - div([ - span({ style: styles.idLink.linkDetailLabel }, ['Link Expiration:']), - span([Utils.makeCompleteDate(addDays(expiresAfter, parseJSON(issuedAt)))]), - ]), - div([ - h(FrameworkServiceLink, { - linkText: 'Renew', - 'aria-label': `Renew your ${short} link`, - providerKey: key, - redirectUrl, - }), - span({ style: { margin: '0 .25rem 0' } }, [' | ']), - h(UnlinkFenceAccount, { - linkText: 'Unlink', - 'aria-label': `Unlink from ${short}`, - provider: { key, name }, - }), - ]), - ]) - ), - ]), - ]); -}; diff --git a/src/profile/external-identities/FrameworkServiceLink.tsx b/src/profile/external-identities/FrameworkServiceLink.tsx deleted file mode 100644 index 65b0c53cb6..0000000000 --- a/src/profile/external-identities/FrameworkServiceLink.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ClickableProps } from '@terra-ui-packages/components'; -import React, { ReactNode } from 'react'; -import { ButtonPrimary, Link } from 'src/components/common'; -import { icon } from 'src/components/icons'; -import { Ajax } from 'src/libs/ajax'; -import { withErrorReporting } from 'src/libs/error'; -import * as Utils from 'src/libs/utils'; - -interface FrameworkServiceLinkProps extends Omit { - linkText: string; - providerKey: string; - redirectUrl: string; - button?: boolean; -} - -export const FrameworkServiceLink = (props: FrameworkServiceLinkProps): ReactNode => { - const { linkText, providerKey, redirectUrl, button = false, ...clickableProps } = props; - const Component = button ? ButtonPrimary : Link; - const style = button ? {} : { display: 'inline-flex', alignItems: 'center' }; - return ( - { - const result = await Ajax().User.getFenceAuthUrl(providerKey, redirectUrl); - window.open(result.url, Utils.newTabLinkProps.target, 'noopener,noreferrer'); - })} - style={style} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...clickableProps} - > - {linkText} - {icon('pop-out', { size: 12, style: { marginLeft: '0.2rem' } })} - - ); -}; diff --git a/src/profile/external-identities/LinkOAuth2Account.tsx b/src/profile/external-identities/LinkOAuth2Account.tsx new file mode 100644 index 0000000000..fa4200139c --- /dev/null +++ b/src/profile/external-identities/LinkOAuth2Account.tsx @@ -0,0 +1,41 @@ +import { ClickableProps } from '@terra-ui-packages/components'; +import React, { ReactNode } from 'react'; +import { ButtonPrimary, Link } from 'src/components/common'; +import { icon } from 'src/components/icons'; +import { Ajax } from 'src/libs/ajax'; +import { withErrorReporting } from 'src/libs/error'; +import { useCancellation } from 'src/libs/react-utils'; +import * as Utils from 'src/libs/utils'; +import { OAuth2Provider } from 'src/profile/external-identities/OAuth2Providers'; + +interface LinkOAuth2AccountProps extends Omit { + linkText: string; + provider: OAuth2Provider; + button?: boolean; +} +export const LinkOAuth2Account = (props: LinkOAuth2AccountProps): ReactNode => { + const signal = useCancellation(); + + const { linkText, provider, button = true } = props; + const Component = button ? ButtonPrimary : Link; + const style = button ? {} : { display: 'inline-flex', alignItems: 'center' }; + + const getAuthUrlAndRedirect = withErrorReporting(`Error getting Authorization URL for ${provider.short}`)( + async () => { + const url = await Ajax(signal).ExternalCredentials(provider).getAuthorizationUrl(); + window.open(url, Utils.newTabLinkProps.target, 'noopener,noreferrer'); + } + ); + + return ( + + {linkText} + {icon('pop-out', { size: 12, style: { marginLeft: '0.2rem' } })} + + ); +}; diff --git a/src/profile/external-identities/OAuth2Link.test.tsx b/src/profile/external-identities/OAuth2Account.test.tsx similarity index 82% rename from src/profile/external-identities/OAuth2Link.test.tsx rename to src/profile/external-identities/OAuth2Account.test.tsx index fee787a9e8..1a39614582 100644 --- a/src/profile/external-identities/OAuth2Link.test.tsx +++ b/src/profile/external-identities/OAuth2Account.test.tsx @@ -5,11 +5,12 @@ import { axe } from 'jest-axe'; import React from 'react'; import { Ajax } from 'src/libs/ajax'; import { getCurrentRoute } from 'src/libs/nav'; +import { authStore } from 'src/libs/state'; import * as Utils from 'src/libs/utils'; import { OAuth2Provider } from 'src/profile/external-identities/OAuth2Providers'; import { asMockedFn, renderWithAppContexts as render } from 'src/testing/test-utils'; -import { OAuth2Link } from './OAuth2Link'; +import { OAuth2Account } from './OAuth2Account'; jest.mock('src/libs/ajax'); @@ -19,6 +20,11 @@ jest.mock('src/auth/auth', () => ({ signOut: jest.fn(), })); +jest.mock('src/libs/config', () => ({ + ...jest.requireActual('src/libs/config'), + getConfig: jest.fn().mockReturnValue({ externalCreds: { providers: ['github'], urlRoot: 'https/foo.bar.com' } }), +})); + jest.mock('react-notifications-component', () => { return { Store: { @@ -51,9 +57,10 @@ const testAccessTokenProvider: OAuth2Provider = { }, supportsAccessToken: true, supportsIdToken: false, + isFence: false, }; -describe('OAuth2Link', () => { +describe('OAuth2Account', () => { describe('When no account is linked', () => { it('shows the button to link an account', async () => { // Arrange @@ -71,11 +78,12 @@ describe('OAuth2Link', () => { } as DeepPartial as AjaxContract) ); // Act - const { container } = await act(() => render()); + const { container } = await act(() => + render() + ); // Assert screen.getByText(`Log Into ${testAccessTokenProvider.short}`); - expect(getLinkStatusFn).toHaveBeenCalled(); expect(getAuthorizationUrlFn).not.toHaveBeenCalled(); expect(await axe(container)).toHaveNoViolations(); }); @@ -99,7 +107,7 @@ describe('OAuth2Link', () => { } as DeepPartial as AjaxContract) ); // Act - await act(() => render()); + await act(() => render()); // Assert const button = screen.getByText(`Log Into ${testAccessTokenProvider.short}`); @@ -132,7 +140,7 @@ describe('OAuth2Link', () => { } as DeepPartial as AjaxContract) ); // Act - await act(() => render()); + await act(() => render()); // Assert expect(linkAccountFn).toHaveBeenCalled(); @@ -142,24 +150,12 @@ describe('OAuth2Link', () => { it('shows the linked account status', async () => { // Arrange const linkStatus = { externalUserId: 'testUser', expirationTimestamp: new Date(), authenticated: true }; - const getLinkStatusFn = jest.fn().mockResolvedValue(linkStatus); - asMockedFn(Ajax).mockImplementation( - () => - ({ - ExternalCredentials: () => { - return { - getAccountLinkStatus: getLinkStatusFn, - }; - }, - } as DeepPartial as AjaxContract) - ); + authStore.update((state) => ({ ...state, oAuth2AccountStatus: { [testAccessTokenProvider.key]: linkStatus } })); // Act - await act(() => render()); + await act(() => render()); // Assert - expect(getLinkStatusFn).toHaveBeenCalled(); - - screen.getByText('Renew'); + screen.getByText(`Renew your ${testAccessTokenProvider.short} link`); screen.getByText('Unlink'); screen.getByText('Username:'); screen.getByText(linkStatus.externalUserId); @@ -170,24 +166,28 @@ describe('OAuth2Link', () => { // Arrange const user = userEvent.setup(); const linkStatus = { externalUserId: 'testUser', expirationTimestamp: new Date(), authenticated: true }; - const getLinkStatusFn = jest.fn().mockResolvedValue(linkStatus); + authStore.update((state) => ({ ...state, oAuth2AccountStatus: { [testAccessTokenProvider.key]: linkStatus } })); const unlinkAccountFn = jest.fn().mockResolvedValue(undefined); asMockedFn(Ajax).mockImplementation( () => ({ ExternalCredentials: () => { return { - getAccountLinkStatus: getLinkStatusFn, unlinkAccount: unlinkAccountFn, }; }, } as DeepPartial as AjaxContract) ); // Act - const { container } = await act(() => render()); + const { container } = await act(() => + render() + ); const unlinkButton = screen.getByText('Unlink'); await user.click(unlinkButton); + const okButton = screen.getByText('OK'); + await user.click(okButton); + // Assert expect(unlinkAccountFn).toHaveBeenCalled(); screen.getByText(`Log Into ${testAccessTokenProvider.short}`); diff --git a/src/profile/external-identities/OAuth2Link.tsx b/src/profile/external-identities/OAuth2Account.tsx similarity index 53% rename from src/profile/external-identities/OAuth2Link.tsx rename to src/profile/external-identities/OAuth2Account.tsx index fd8c9e0ce6..89dd539c11 100644 --- a/src/profile/external-identities/OAuth2Link.tsx +++ b/src/profile/external-identities/OAuth2Account.tsx @@ -1,16 +1,16 @@ -import { Clickable } from '@terra-ui-packages/components'; -import React, { useState } from 'react'; +import _ from 'lodash/fp'; +import React from 'react'; import { ClipboardButton } from 'src/components/ClipboardButton'; -import { ButtonPrimary } from 'src/components/common'; -import { icon } from 'src/components/icons'; import { Ajax } from 'src/libs/ajax'; -import { EcmLinkAccountResponse } from 'src/libs/ajax/ExternalCredentials'; import colors from 'src/libs/colors'; import { withErrorReporting } from 'src/libs/error'; import * as Nav from 'src/libs/nav'; -import { useCancellation, useOnMount } from 'src/libs/react-utils'; +import { useCancellation, useOnMount, useStore } from 'src/libs/react-utils'; +import { authStore } from 'src/libs/state'; import * as Utils from 'src/libs/utils'; +import { LinkOAuth2Account } from 'src/profile/external-identities/LinkOAuth2Account'; import { OAuth2Callback, OAuth2Provider } from 'src/profile/external-identities/OAuth2Providers'; +import { UnlinkOAuth2Account } from 'src/profile/external-identities/UnlinkOAuth2Account'; import { SpacedSpinner } from 'src/profile/SpacedSpinner'; const styles = { @@ -51,95 +51,66 @@ const styles = { }, }; -interface OAuth2LinkProps { +interface OAuth2AccountProps { queryParams: { state?: string; code?: string }; provider: OAuth2Provider; } -export const OAuth2Link = (props: OAuth2LinkProps) => { +export const OAuth2Account = (props: OAuth2AccountProps) => { const { queryParams: { state, code }, provider, } = props; + const { oAuth2AccountStatus: storedOAuth2AccountStatus } = useStore(authStore); + + const { externalUserId, expirationTimestamp } = storedOAuth2AccountStatus[provider.key] ?? { + externalUserId: undefined, + expirationTimestamp: undefined, + }; const signal = useCancellation(); - const [accountInfo, setAccountInfo] = useState(); - const [accountLoaded, setAccountLoaded] = useState(false); const callbacks: Array = ['oauth-callback', 'ecm-callback', 'fence-callback']; // ecm-callback is deprecated, but still needs to be supported const isLinking = callbacks.includes(Nav.getCurrentRoute().name) && state && JSON.parse(atob(state)).provider === provider.key; useOnMount(() => { - const loadAccount = withErrorReporting(`Error loading ${provider.short} account`)(async () => { - setAccountInfo(await Ajax(signal).ExternalCredentials(provider).getAccountLinkStatus()); - }); const linkAccount = withErrorReporting(`Error linking ${provider.short} account`)(async (code, state) => { - setAccountInfo(await Ajax(signal).ExternalCredentials(provider).linkAccountWithAuthorizationCode(code, state)); + const accountInfo = await Ajax(signal) + .ExternalCredentials(provider) + .linkAccountWithAuthorizationCode(code, state); + authStore.update(_.set(['oAuth2AccountStatus', provider.key], accountInfo)); }); if (isLinking) { const profileLink = `/${Nav.getLink('profile', {}, { tab: 'externalIdentities' })}`; window.history.replaceState({}, '', profileLink); linkAccount(code, state); - } else { - loadAccount(); } - setAccountLoaded(true); - }); - - const unlinkAccount = withErrorReporting(`Error unlinking ${provider.short} account`)(async () => { - await Ajax(signal).ExternalCredentials(provider).unlinkAccount(); - setAccountInfo(undefined); }); - const getAuthUrlAndRedirect = withErrorReporting(`Error getting Authorization URL for ${provider.short}`)( - async () => { - const url = await Ajax(signal).ExternalCredentials(provider).getAuthorizationUrl(); - window.open(url, Utils.newTabLinkProps.target, 'noopener,noreferrer'); - } - ); - return (

{provider.name}

- {!accountLoaded && Loading account status...} - {accountLoaded && !accountInfo && ( + {isLinking && Loading account status...} + {!externalUserId && (
- - Log Into {provider.short} - +
)} - {accountInfo && ( + {externalUserId && ( <>
Username: - {accountInfo.externalUserId} + {externalUserId}
Link Expiration: - {Utils.makeCompleteDate(accountInfo.expirationTimestamp)} + {Utils.makeCompleteDate(expirationTimestamp)}
- - Renew{icon('pop-out', { size: 12, style: { marginLeft: '0.2rem' } })} - + | - - Unlink - +
{provider.supportsIdToken && ( diff --git a/src/profile/external-identities/OAuth2Providers.ts b/src/profile/external-identities/OAuth2Providers.ts index 7d7d9906d5..2b153b2e14 100644 --- a/src/profile/external-identities/OAuth2Providers.ts +++ b/src/profile/external-identities/OAuth2Providers.ts @@ -16,10 +16,12 @@ export type OAuth2Provider = { }; supportsAccessToken: boolean; supportsIdToken: boolean; + isFence: boolean; }; const createRedirectUri = (callback: OAuth2Callback['link']) => { - return `${window.location.hostname === 'localhost' ? getConfig().devUrlRoot : window.location.origin}/${callback}`; + // return `${window.location.hostname === 'localhost' ? getConfig().devUrlRoot : window.location.origin}/${callback}`; + return `${window.location.origin}/${callback}`; }; export const oauth2Provider = (providerKey: OAuth2ProviderKey): OAuth2Provider => { switch (providerKey) { @@ -33,6 +35,7 @@ export const oauth2Provider = (providerKey: OAuth2ProviderKey): OAuth2Provider = }, supportsAccessToken: true, supportsIdToken: false, + isFence: false, }; case 'ras': return { @@ -45,6 +48,7 @@ export const oauth2Provider = (providerKey: OAuth2ProviderKey): OAuth2Provider = }, supportsAccessToken: false, supportsIdToken: false, // turning off clipboard copying for now. + isFence: false, }; case 'fence': return { @@ -56,6 +60,7 @@ export const oauth2Provider = (providerKey: OAuth2ProviderKey): OAuth2Provider = }, supportsAccessToken: true, supportsIdToken: false, + isFence: true, }; case 'dcf-fence': return { @@ -67,6 +72,7 @@ export const oauth2Provider = (providerKey: OAuth2ProviderKey): OAuth2Provider = }, supportsAccessToken: true, supportsIdToken: false, + isFence: true, }; case 'kids-first': return { @@ -78,6 +84,7 @@ export const oauth2Provider = (providerKey: OAuth2ProviderKey): OAuth2Provider = }, supportsAccessToken: true, supportsIdToken: false, + isFence: true, }; case 'anvil': return { @@ -89,8 +96,13 @@ export const oauth2Provider = (providerKey: OAuth2ProviderKey): OAuth2Provider = }, supportsAccessToken: true, supportsIdToken: false, + isFence: true, }; default: throw new Error(`Unknown OAuth2 provider key: ${providerKey}`); } }; + +const providers = (getConfig().externalCreds?.providers ?? []) as OAuth2ProviderKey[]; + +export const allOAuth2Providers: OAuth2Provider[] = providers.map((key) => oauth2Provider(key)); diff --git a/src/profile/external-identities/ShibbolethLink.ts b/src/profile/external-identities/ShibbolethLink.ts index e69b04c795..b5445d88a1 100644 --- a/src/profile/external-identities/ShibbolethLink.ts +++ b/src/profile/external-identities/ShibbolethLink.ts @@ -10,7 +10,7 @@ import * as Utils from 'src/libs/utils'; interface ShibbolethLinkProps extends ClickableProps { button?: boolean; - children: React.ReactNode[]; + children: React.ReactNode[] | React.ReactNode; } export const ShibbolethLink = ({ button = false, children, ...props }: ShibbolethLinkProps) => { diff --git a/src/profile/external-identities/UnlinkFenceAccount.ts b/src/profile/external-identities/UnlinkFenceAccount.ts deleted file mode 100644 index 99ecfd57c0..0000000000 --- a/src/profile/external-identities/UnlinkFenceAccount.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { ClickableProps, Modal } from '@terra-ui-packages/components'; -import _ from 'lodash/fp'; -import { useState } from 'react'; -import { div, h } from 'react-hyperscript-helpers'; -import { ButtonPrimary, Link, spinnerOverlay } from 'src/components/common'; -import { Ajax } from 'src/libs/ajax'; -import { withErrorReporting } from 'src/libs/error'; -import { notify } from 'src/libs/notifications'; -import { authStore } from 'src/libs/state'; -import * as Utils from 'src/libs/utils'; - -interface UnlinkFenceAccountProps extends ClickableProps { - linkText: string; - provider: { key: string; name: string }; -} - -export const UnlinkFenceAccount = ({ linkText, provider }: UnlinkFenceAccountProps) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const [isUnlinking, setIsUnlinking] = useState(false); - - return div({ style: { display: 'inline-flex' } }, [ - h( - Link, - { - onClick: () => { - setIsModalOpen(true); - }, - }, - [linkText] - ), - isModalOpen && - h( - Modal, - { - title: 'Confirm unlink account', - onDismiss: () => setIsModalOpen(false), - okButton: h( - ButtonPrimary, - { - onClick: _.flow( - withErrorReporting('Error unlinking account'), - Utils.withBusyState(setIsUnlinking) - )(async () => { - await Ajax().User.unlinkFenceAccount(provider.key); - authStore.update(_.set(['fenceStatus', provider.key], {})); - setIsModalOpen(false); - notify('success', 'Successfully unlinked account', { - message: `Successfully unlinked your account from ${provider.name}`, - timeout: 30000, - }); - }), - }, - ['OK'] - ), - }, - [ - div([`Are you sure you want to unlink from ${provider.name}?`]), - div({ style: { marginTop: '1rem' } }, [ - 'You will lose access to any underlying datasets. You can always re-link your account later.', - ]), - isUnlinking && spinnerOverlay, - ] - ), - ]); -}; diff --git a/src/profile/external-identities/UnlinkOAuth2Account.tsx b/src/profile/external-identities/UnlinkOAuth2Account.tsx new file mode 100644 index 0000000000..aa1de5d084 --- /dev/null +++ b/src/profile/external-identities/UnlinkOAuth2Account.tsx @@ -0,0 +1,66 @@ +import { Clickable, ClickableProps, Modal } from '@terra-ui-packages/components'; +import _ from 'lodash/fp'; +import React, { ReactNode, useState } from 'react'; +import { ButtonPrimary, spinnerOverlay } from 'src/components/common'; +import { Ajax } from 'src/libs/ajax'; +import colors from 'src/libs/colors'; +import { withErrorReporting } from 'src/libs/error'; +import { notify } from 'src/libs/notifications'; +import { authStore } from 'src/libs/state'; +import * as Utils from 'src/libs/utils'; +import { OAuth2Provider } from 'src/profile/external-identities/OAuth2Providers'; + +const styles = { + clickableLink: { + display: 'inline', + color: colors.accent(), + cursor: 'pointer', + fontWeight: 500, + }, +}; +interface UnlinkOAuth2AccountProps extends ClickableProps { + linkText: string; + provider: OAuth2Provider; +} + +export const UnlinkOAuth2Account = ({ linkText, provider }: UnlinkOAuth2AccountProps): ReactNode => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isUnlinking, setIsUnlinking] = useState(false); + + const okButton = ( + { + await Ajax().ExternalCredentials(provider).unlinkAccount(); + authStore.update(_.unset(['oAuth2AccountStatus', provider.key])); + setIsModalOpen(false); + notify('success', 'Successfully unlinked account', { + message: `Successfully unlinked your account from ${provider.name}`, + timeout: 30000, + }); + })} + > + OK + + ); + + return ( +
+ setIsModalOpen(true)}> + {linkText} + + {isModalOpen && ( + setIsModalOpen(false)} okButton={okButton}> +
Are you sure you want to unlink from {provider.name}?
+
+ {provider.isFence && + 'You will lose access to any underlying datasets. You can always re-link your account later.'} +
+ {isUnlinking && spinnerOverlay} +
+ )} +
+ ); +}; diff --git a/src/registration/terms-of-service/TermsOfServicePage.test.ts b/src/registration/terms-of-service/TermsOfServicePage.test.ts index c45877408f..9a4d257f13 100644 --- a/src/registration/terms-of-service/TermsOfServicePage.test.ts +++ b/src/registration/terms-of-service/TermsOfServicePage.test.ts @@ -59,7 +59,6 @@ const setupMockAjax = async ( .mockResolvedValue(termsOfService satisfies SamUserTermsOfServiceDetails); const acceptTermsOfService = jest.fn().mockResolvedValue(undefined); const rejectTermsOfService = jest.fn().mockResolvedValue(undefined); - const getFenceStatus = jest.fn(); const getNihStatus = jest.fn(); type AjaxExports = typeof import('src/libs/ajax'); @@ -81,7 +80,6 @@ const setupMockAjax = async ( setPreferences: jest.fn().mockResolvedValue({}), preferLegacyFirecloud: jest.fn().mockResolvedValue({}), }, - getFenceStatus, getNihStatus, }, TermsOfService: {