synapse-react-client 4.0.23 → 4.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/SWC.index.d.ts +1 -0
  2. package/dist/SWC.index.d.ts.map +1 -1
  3. package/dist/SWC.index.js +2 -1
  4. package/dist/SWC.index.js.map +1 -1
  5. package/dist/components/AccessRequirementList/ManagedACTAccessRequirementRequestFlow/DataAccessRequestAccessorsFilesForm/DataAccessRequestAccessorsFilesForm.js.map +1 -1
  6. package/dist/components/AclEditor/useUpdateAcl.js.map +1 -1
  7. package/dist/components/Authentication/TwoFactorBackupCodes.js.map +1 -1
  8. package/dist/components/Authentication/TwoFactorEnrollmentForm.js.map +1 -1
  9. package/dist/components/Breadcrumbs/Breadcrumbs.d.ts +3 -0
  10. package/dist/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -1
  11. package/dist/components/Breadcrumbs/Breadcrumbs.js +43 -30
  12. package/dist/components/Breadcrumbs/Breadcrumbs.js.map +1 -1
  13. package/dist/components/CannedRejectionDialog/CannedRejectionDialog.js.map +1 -1
  14. package/dist/components/ChallengeSubmission/SubmissionDirectoryList.js.map +1 -1
  15. package/dist/components/CopyToClipboardString/CopyToClipboardString.d.ts +7 -1
  16. package/dist/components/CopyToClipboardString/CopyToClipboardString.d.ts.map +1 -1
  17. package/dist/components/CopyToClipboardString/CopyToClipboardString.js +22 -16
  18. package/dist/components/CopyToClipboardString/CopyToClipboardString.js.map +1 -1
  19. package/dist/components/EntityCitation/EntityCitation.d.ts.map +1 -1
  20. package/dist/components/EntityCitation/EntityCitation.js +28 -26
  21. package/dist/components/EntityCitation/EntityCitation.js.map +1 -1
  22. package/dist/components/EntityFinder/tree/EntityTree.js.map +1 -1
  23. package/dist/components/EntityFinder/tree/VirtualizedTree.js.map +1 -1
  24. package/dist/components/EntityIcon.d.ts +1 -0
  25. package/dist/components/EntityIcon.d.ts.map +1 -1
  26. package/dist/components/EntityIcon.js +37 -36
  27. package/dist/components/EntityIcon.js.map +1 -1
  28. package/dist/components/EntitySidebar/EntitySidebar.css +1 -0
  29. package/dist/components/EntitySidebar/EntitySidebar.d.ts +7 -0
  30. package/dist/components/EntitySidebar/EntitySidebar.d.ts.map +1 -0
  31. package/dist/components/EntitySidebar/EntitySidebar.js +62 -0
  32. package/dist/components/EntitySidebar/EntitySidebar.js.map +1 -0
  33. package/dist/components/EntitySidebar/EntitySidebar.module.js +17 -0
  34. package/dist/components/EntitySidebar/EntitySidebar.module.js.map +1 -0
  35. package/dist/components/EntitySidebar/EntitySidebar.module.scss +87 -0
  36. package/dist/components/Evaluation/EvaluationRoundEditorList.js.map +1 -1
  37. package/dist/components/GenericCard/BioregistryRules.js.map +1 -1
  38. package/dist/components/IconSvg/IconSvg.d.ts +2 -1
  39. package/dist/components/IconSvg/IconSvg.d.ts.map +1 -1
  40. package/dist/components/IconSvg/IconSvg.js +112 -89
  41. package/dist/components/IconSvg/IconSvg.js.map +1 -1
  42. package/dist/components/Plot/SynapsePlot.d.ts +4 -0
  43. package/dist/components/Plot/SynapsePlot.d.ts.map +1 -1
  44. package/dist/components/Plot/SynapsePlot.js +57 -44
  45. package/dist/components/Plot/SynapsePlot.js.map +1 -1
  46. package/dist/components/Plot/UpsetPlot.js.map +1 -1
  47. package/dist/components/QueryWrapperPlotNav/QueryWrapperSynapsePlot.d.ts.map +1 -1
  48. package/dist/components/QueryWrapperPlotNav/QueryWrapperSynapsePlot.js +77 -34
  49. package/dist/components/QueryWrapperPlotNav/QueryWrapperSynapsePlot.js.map +1 -1
  50. package/dist/components/SchemaDrivenAnnotationEditor/field/AdditionalPropertiesSchemaField.js.map +1 -1
  51. package/dist/components/SearchQueryWrapperPlotNav/SearchQueryWrapperPlotNav.d.ts.map +1 -1
  52. package/dist/components/SearchQueryWrapperPlotNav/SearchQueryWrapperPlotNav.js +1 -0
  53. package/dist/components/SearchQueryWrapperPlotNav/SearchQueryWrapperPlotNav.js.map +1 -1
  54. package/dist/components/StorybookComponentWrapper.js.map +1 -1
  55. package/dist/components/SynapseTable/TablePagination.js.map +1 -1
  56. package/dist/components/SynapseTable/datasets/DatasetItemsEditor.js.map +1 -1
  57. package/dist/components/TableColumnSchemaEditor/TableColumnSchemaForm.js.map +1 -1
  58. package/dist/components/TanStackTable/StyledTanStackTable.js.map +1 -1
  59. package/dist/components/entity/page/action_menu/EntityActionMenu.d.ts +2 -0
  60. package/dist/components/entity/page/action_menu/EntityActionMenu.d.ts.map +1 -1
  61. package/dist/components/entity/page/action_menu/EntityActionMenu.js +46 -27
  62. package/dist/components/entity/page/action_menu/EntityActionMenu.js.map +1 -1
  63. package/dist/components/entity/page/breadcrumbs/EntityPageBreadcrumbs.d.ts +1 -8
  64. package/dist/components/entity/page/breadcrumbs/EntityPageBreadcrumbs.d.ts.map +1 -1
  65. package/dist/components/entity/page/breadcrumbs/EntityPageBreadcrumbs.js +17 -13
  66. package/dist/components/entity/page/breadcrumbs/EntityPageBreadcrumbs.js.map +1 -1
  67. package/dist/components/entity/page/title_bar/EntityPageTitleBar.d.ts +1 -0
  68. package/dist/components/entity/page/title_bar/EntityPageTitleBar.d.ts.map +1 -1
  69. package/dist/components/entity/page/title_bar/EntityPageTitleBar.js +35 -22
  70. package/dist/components/entity/page/title_bar/EntityPageTitleBar.js.map +1 -1
  71. package/dist/components/entity/page/title_bar/EntityTitleBarVersionInfo.d.ts.map +1 -1
  72. package/dist/components/entity/page/title_bar/EntityTitleBarVersionInfo.js +34 -20
  73. package/dist/components/entity/page/title_bar/EntityTitleBarVersionInfo.js.map +1 -1
  74. package/dist/components/entity/page/title_bar/TitleBarProperties.d.ts +0 -1
  75. package/dist/components/entity/page/title_bar/TitleBarProperties.d.ts.map +1 -1
  76. package/dist/components/entity/page/title_bar/TitleBarProperties.js +17 -82
  77. package/dist/components/entity/page/title_bar/TitleBarProperties.js.map +1 -1
  78. package/dist/components/entity/page/title_bar/useGetEntityTitleBarProperties.d.ts.map +1 -1
  79. package/dist/components/entity/page/title_bar/useGetEntityTitleBarProperties.js +3 -4
  80. package/dist/components/entity/page/title_bar/useGetEntityTitleBarProperties.js.map +1 -1
  81. package/dist/components/favorites/FavoriteButton.d.ts +1 -0
  82. package/dist/components/favorites/FavoriteButton.d.ts.map +1 -1
  83. package/dist/components/favorites/FavoriteButton.js +8 -8
  84. package/dist/components/favorites/FavoriteButton.js.map +1 -1
  85. package/dist/components/menu/ComplexMenu.d.ts.map +1 -1
  86. package/dist/components/menu/ComplexMenu.js +5 -3
  87. package/dist/components/menu/ComplexMenu.js.map +1 -1
  88. package/dist/components/menu/DropdownMenu.d.ts +1 -0
  89. package/dist/components/menu/DropdownMenu.d.ts.map +1 -1
  90. package/dist/components/menu/DropdownMenu.js +24 -24
  91. package/dist/components/menu/DropdownMenu.js.map +1 -1
  92. package/dist/components/widgets/facet-nav/FacetNavPanel.d.ts.map +1 -1
  93. package/dist/components/widgets/facet-nav/FacetNavPanel.js +59 -49
  94. package/dist/components/widgets/facet-nav/FacetNavPanel.js.map +1 -1
  95. package/dist/components/widgets/facet-nav/PlotsContainer.d.ts.map +1 -1
  96. package/dist/components/widgets/facet-nav/PlotsContainer.js +8 -2
  97. package/dist/components/widgets/facet-nav/PlotsContainer.js.map +1 -1
  98. package/dist/style/components/facet_nav/_facet-nav-panel.scss +11 -0
  99. package/dist/style/main.css +1 -1
  100. package/dist/testutils/ReactQueryMockUtils.js.map +1 -1
  101. package/dist/tsconfig.build.tsbuildinfo +1 -1
  102. package/dist/types/ThemeTypes.d.ts +2 -2
  103. package/dist/utils/functions/IsEqualTreatUndefinedAsMissing.js.map +1 -1
  104. package/dist/utils/hooks/useDebouncedEffect.js.map +1 -1
  105. package/dist/utils/hooks/useDetectSSOCode.js.map +1 -1
  106. package/dist/utils/hooks/useImmutableTableQuery/useImmutableTableQuery.js.map +1 -1
  107. package/dist/utils/hooks/useLogin.js.map +1 -1
  108. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
- /* eslint-disable @typescript-eslint/no-empty-interface */
2
- /* eslint-disable @typescript-eslint/no-empty-object-type */
1
+ /* oxlint-disable @typescript-eslint/no-empty-interface */
2
+ /* oxlint-disable @typescript-eslint/no-empty-object-type */
3
3
  import '@mui/material/styles'
4
4
 
5
5
  // If creating a new Typography variant, add it to this list.
@@ -1 +1 @@
1
- {"version":3,"file":"IsEqualTreatUndefinedAsMissing.js","names":[],"sources":["../../../src/utils/functions/IsEqualTreatUndefinedAsMissing.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-explicit-any */\n// `any` is safe for these comparison functions\nimport { isArray, isObject, includes, isEqualWith, omitBy } from 'lodash-es'\n\nconst comparisonFunc = (a: any, b: any): boolean | undefined => {\n if (isArray(a) || isArray(b)) return\n if (!isObject(a) || !isObject(b)) return\n\n if (!includes<any>(a, undefined) && !includes<any>(b, undefined)) return\n\n // Call recursively, after filtering all undefined properties\n return isEqualWith(\n omitBy(a, value => value === undefined),\n omitBy(b, value => value === undefined),\n comparisonFunc,\n )\n}\n\n/**\n * A version of lodash's isEqual that treats undefined and missing as equal.\n *\n * Note: `null` and missing/undefined are not considered equal in this implementation\n */\nexport function isEqualTreatUndefinedAsMissing(a: any, b: any): boolean {\n /* implementation lifted from https://stackoverflow.com/a/57894104 */\n return isEqualWith(a, b, comparisonFunc)\n}\n"],"mappings":";;AAIA,IAAM,KAAkB,GAAQ,MAAgC;CAC1D,QAAQ,CAAC,KAAK,EAAQ,CAAC,MACvB,GAAC,EAAS,CAAC,KAAK,CAAC,EAAS,CAAC,MAE3B,GAAC,EAAc,GAAG,KAAA,CAAS,KAAK,CAAC,EAAc,GAAG,KAAA,CAAS,IAG/D,OAAO,EACL,EAAO,IAAG,MAAS,MAAU,KAAA,CAAS,GACtC,EAAO,IAAG,MAAS,MAAU,KAAA,CAAS,GACtC,CACF;AACF;AAOA,SAAgB,EAA+B,GAAQ,GAAiB;CAEtE,OAAO,EAAY,GAAG,GAAG,CAAc;AACzC"}
1
+ {"version":3,"file":"IsEqualTreatUndefinedAsMissing.js","names":[],"sources":["../../../src/utils/functions/IsEqualTreatUndefinedAsMissing.ts"],"sourcesContent":["/* oxlint-disable @typescript-eslint/no-explicit-any */\n// `any` is safe for these comparison functions\nimport { isArray, isObject, includes, isEqualWith, omitBy } from 'lodash-es'\n\nconst comparisonFunc = (a: any, b: any): boolean | undefined => {\n if (isArray(a) || isArray(b)) return\n if (!isObject(a) || !isObject(b)) return\n\n if (!includes<any>(a, undefined) && !includes<any>(b, undefined)) return\n\n // Call recursively, after filtering all undefined properties\n return isEqualWith(\n omitBy(a, value => value === undefined),\n omitBy(b, value => value === undefined),\n comparisonFunc,\n )\n}\n\n/**\n * A version of lodash's isEqual that treats undefined and missing as equal.\n *\n * Note: `null` and missing/undefined are not considered equal in this implementation\n */\nexport function isEqualTreatUndefinedAsMissing(a: any, b: any): boolean {\n /* implementation lifted from https://stackoverflow.com/a/57894104 */\n return isEqualWith(a, b, comparisonFunc)\n}\n"],"mappings":";;AAIA,IAAM,KAAkB,GAAQ,MAAgC;CAC1D,QAAQ,CAAC,KAAK,EAAQ,CAAC,MACvB,GAAC,EAAS,CAAC,KAAK,CAAC,EAAS,CAAC,MAE3B,GAAC,EAAc,GAAG,KAAA,CAAS,KAAK,CAAC,EAAc,GAAG,KAAA,CAAS,IAG/D,OAAO,EACL,EAAO,IAAG,MAAS,MAAU,KAAA,CAAS,GACtC,EAAO,IAAG,MAAS,MAAU,KAAA,CAAS,GACtC,CACF;AACF;AAOA,SAAgB,EAA+B,GAAQ,GAAiB;CAEtE,OAAO,EAAY,GAAG,GAAG,CAAc;AACzC"}
@@ -1 +1 @@
1
- {"version":3,"file":"useDebouncedEffect.js","names":[],"sources":["../../../src/utils/hooks/useDebouncedEffect.ts"],"sourcesContent":["import { useEffect } from 'react'\n\n/**\n * Debounces execution of an effect based on a specified delay.\n * See https://stackoverflow.com/a/61127960\n * @param effect\n * @param deps\n * @param delay\n */\nexport const useDebouncedEffect = (\n effect: React.EffectCallback,\n deps: React.DependencyList,\n delay: number,\n) => {\n useEffect(() => {\n const handler = setTimeout(() => effect(), delay)\n\n return () => clearTimeout(handler)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [...(deps || []), delay])\n}\n"],"mappings":";;AASA,IAAa,KACX,GACA,GACA,MACG;CACH,QAAgB;EACd,IAAM,IAAU,iBAAiB,EAAO,GAAG,CAAK;EAEhD,aAAa,aAAa,CAAO;CAEnC,GAAG,CAAC,GAAI,KAAQ,CAAC,GAAI,CAAK,CAAC;AAC7B"}
1
+ {"version":3,"file":"useDebouncedEffect.js","names":[],"sources":["../../../src/utils/hooks/useDebouncedEffect.ts"],"sourcesContent":["import { useEffect } from 'react'\n\n/**\n * Debounces execution of an effect based on a specified delay.\n * See https://stackoverflow.com/a/61127960\n * @param effect\n * @param deps\n * @param delay\n */\nexport const useDebouncedEffect = (\n effect: React.EffectCallback,\n deps: React.DependencyList,\n delay: number,\n) => {\n useEffect(() => {\n const handler = setTimeout(() => effect(), delay)\n\n return () => clearTimeout(handler)\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n }, [...(deps || []), delay])\n}\n"],"mappings":";;AASA,IAAa,KACX,GACA,GACA,MACG;CACH,QAAgB;EACd,IAAM,IAAU,iBAAiB,EAAO,GAAG,CAAK;EAEhD,aAAa,aAAa,CAAO;CAEnC,GAAG,CAAC,GAAI,KAAQ,CAAC,GAAI,CAAK,CAAC;AAC7B"}
@@ -1 +1 @@
1
- {"version":3,"file":"useDetectSSOCode.js","names":[],"sources":["../../../src/utils/hooks/useDetectSSOCode.ts"],"sourcesContent":["import {\n bindOAuthProviderToAccount,\n getRootURL,\n oAuthRegisterAccountStep2,\n oAuthSessionRequest,\n setAccessTokenCookie,\n} from '@/synapse-client'\nimport { OAuth2State } from '@/utils/types'\nimport { TwoFactorAuthErrorResponse } from '@sage-bionetworks/synapse-client/generated/models/TwoFactorAuthErrorResponse'\nimport { SynapseClientError } from '@sage-bionetworks/synapse-client/util/SynapseClientError'\nimport { LoginResponse } from '@sage-bionetworks/synapse-types'\nimport { useEffect, useMemo, useState } from 'react'\nimport { BackendDestinationEnum } from '../functions'\nimport { CSRF_TOKEN_STORAGE_KEY, OAUTH2_PROVIDERS } from '../SynapseConstants'\nimport { useOneSageURL } from './useOneSageURL'\n\nfunction safeLocalStorageGetItem(key: string): string | null {\n try {\n return localStorage.getItem(key)\n } catch (err) {\n console.warn(`Unable to read from localStorage: ${key}`, err)\n return null\n }\n}\n\nfunction safeLocalStorageRemoveItem(key: string): void {\n try {\n localStorage.removeItem(key)\n } catch (err) {\n console.warn(`Unable to remove from localStorage: ${key}`, err)\n }\n}\n\nexport type UseDetectSSOCodeReturnType = {\n /* true iff SSO login has occurred and the completion of the OAuth flow in Synapse is pending */\n isLoading: boolean\n}\n\nexport type UseDetectSSOCodeOptions = {\n onSignInComplete?: () => void\n registerAccountUrl?: string\n onError?: (err: unknown) => void\n onTwoFactorAuthRequired?: (resp: TwoFactorAuthErrorResponse) => void\n onTwoFactorAuthResetTokenPresent?: (\n resp: TwoFactorAuthErrorResponse,\n encodedTwoFaResetToken: string,\n ) => void\n isInitializingSession: boolean\n isAuthenticated: boolean\n}\n\n/*\n * During SSO login, the authorization provider (Google, ORCiD, ArcusBio Okta, ...) will send the user back to the portal with an authorization code,\n * which can be exchanged for a Synapse user session. This function should be called whenever the root App is initialized\n * (to look for this code parameter and complete the round-trip). If state is included, then we assume that this is being\n * used for account creation, where we pass the username through the process.\n */\nexport default function useDetectSSOCode(\n opts: UseDetectSSOCodeOptions = {\n isInitializingSession: true,\n isAuthenticated: false,\n },\n): UseDetectSSOCodeReturnType {\n const defaultRegisterAccountURL = useOneSageURL('/register1')\n\n const {\n onSignInComplete,\n registerAccountUrl = defaultRegisterAccountURL.toString(),\n onError,\n onTwoFactorAuthRequired,\n onTwoFactorAuthResetTokenPresent,\n isInitializingSession,\n isAuthenticated,\n } = opts\n const redirectURL = getRootURL()\n // 'code' handling (from SSO) should be preformed on the root page, and then redirect to original route.\n // Use 'http://localhost/' as a placeholder during SSR (no browser URL available).\n // Since there is no 'code' or 'provider' in this placeholder URL, the rest of the hook is a no-op.\n const fullUrl: URL = new URL(\n typeof window !== 'undefined' ? window.location.href : 'http://localhost/',\n )\n // in test environment the searchParams isn't defined\n const { searchParams } = fullUrl\n const code = searchParams?.get('code')\n const provider = searchParams?.get('provider')\n\n // If the URL contains a client_id and redirect_uri, then we are acting as an identity provider for an external OAuth client\n const isHandlingSynapseOAuthSignIn = Boolean(\n searchParams?.get('client_id') && searchParams?.get('redirect_uri'),\n )\n\n // If the Synapse user signed in with an external IdP, we may have passed data in the 'state' param\n // Parse it (if appropriate)\n const state: OAuth2State | null = useMemo(() => {\n // If we are acting as an OIDC identity provider, then we should not parse the state param -- it was sent to us, and we should return it untouched\n if (!isHandlingSynapseOAuthSignIn) {\n const encodedState = searchParams?.get('state')\n try {\n return encodedState\n ? (JSON.parse(decodeURIComponent(encodedState)) as OAuth2State)\n : null\n } catch (e) {\n console.error(\n 'Error parsing state param:\\n',\n e,\n '\\nEncoded value:\\n',\n encodedState,\n )\n }\n }\n return null\n }, [isHandlingSynapseOAuthSignIn, searchParams])\n\n const [isLoading, setIsLoading] = useState(!!(code && provider))\n\n useEffect(() => {\n if (!isInitializingSession) {\n if (code && provider) {\n if (!isHandlingSynapseOAuthSignIn) {\n const storedCsrfToken = safeLocalStorageGetItem(\n CSRF_TOKEN_STORAGE_KEY,\n )\n\n const expectedCsrfToken = state?.csrfToken ?? null\n const tokensMatch =\n typeof expectedCsrfToken === 'string' &&\n typeof storedCsrfToken === 'string' &&\n expectedCsrfToken === storedCsrfToken\n\n if (!tokensMatch) {\n safeLocalStorageRemoveItem(CSRF_TOKEN_STORAGE_KEY)\n console.error(\n 'Invalid or missing OAuth CSRF token detected. Aborting OAuth flow.',\n )\n if (onError) {\n onError('Invalid OAuth state. Please try signing in again.')\n }\n setIsLoading(false)\n return\n }\n\n safeLocalStorageRemoveItem(CSRF_TOKEN_STORAGE_KEY)\n }\n\n const redirectUrl = `${redirectURL}?provider=${provider}`\n\n //If user is already logged in, and the provider is ORCID, then try to bind this OAuth provider to the account.\n if (OAUTH2_PROVIDERS.ORCID == provider && isAuthenticated) {\n // now bind this to the user account\n const onFailure = (err: SynapseClientError) => {\n console.error('Error binding ORCiD to account: ', err)\n if (onError) {\n onError(err.reason)\n }\n }\n bindOAuthProviderToAccount(\n provider,\n code,\n redirectUrl,\n BackendDestinationEnum.REPO_ENDPOINT,\n )\n .then(onSignInComplete)\n .catch(onFailure)\n .finally(() => setIsLoading(false))\n } else if (\n OAUTH2_PROVIDERS.GOOGLE == provider ||\n OAUTH2_PROVIDERS.ORCID == provider ||\n OAUTH2_PROVIDERS.ARCUS == provider ||\n OAUTH2_PROVIDERS.SAGE_BIONETWORKS == provider\n ) {\n const onSuccess = (\n response: LoginResponse | TwoFactorAuthErrorResponse | null,\n ) => {\n if (response) {\n if ('accessToken' in response) {\n setAccessTokenCookie(response.accessToken).then(\n onSignInComplete,\n )\n } else {\n // The app will redirect or open a modal to handle a standard 2FA sign in\n if (onTwoFactorAuthRequired) {\n onTwoFactorAuthRequired(response)\n }\n if (\n // The user logged in with OAuth while attempting to disable 2FA using an emailed signed token\n state &&\n state.twoFaResetToken &&\n onTwoFactorAuthResetTokenPresent\n ) {\n // Let the app handle redirecting to the 2FA reset page\n onTwoFactorAuthResetTokenPresent(\n response,\n state.twoFaResetToken,\n )\n }\n }\n }\n }\n const onFailure = (err: SynapseClientError) => {\n if (err.status === 404) {\n // Synapse account not found, send to registration page\n window.location.replace(registerAccountUrl)\n }\n console.error('Error with account login: ', err)\n if (onError) {\n onError(err.reason)\n }\n }\n\n if (\n (OAUTH2_PROVIDERS.GOOGLE == provider ||\n OAUTH2_PROVIDERS.ARCUS == provider ||\n OAUTH2_PROVIDERS.SAGE_BIONETWORKS == provider) &&\n state?.registrationUsername\n ) {\n oAuthRegisterAccountStep2(\n state.registrationUsername,\n provider,\n code,\n redirectUrl,\n BackendDestinationEnum.REPO_ENDPOINT,\n )\n .then(onSuccess)\n .catch(onFailure)\n .finally(() => setIsLoading(false))\n } else {\n oAuthSessionRequest(\n provider,\n code,\n redirectUrl,\n BackendDestinationEnum.REPO_ENDPOINT,\n )\n .then(onSuccess)\n .catch(onFailure)\n .finally(() => setIsLoading(false))\n }\n } else {\n console.warn('Unknown SSO Provider: ', provider)\n setIsLoading(false)\n }\n }\n }\n // Intentionally only monitoring initialization of the session -- only running on mount after the session detection has completed since this uses URL params that come from a redirect\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isInitializingSession])\n\n return { isLoading }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"useDetectSSOCode.js","names":[],"sources":["../../../src/utils/hooks/useDetectSSOCode.ts"],"sourcesContent":["import {\n bindOAuthProviderToAccount,\n getRootURL,\n oAuthRegisterAccountStep2,\n oAuthSessionRequest,\n setAccessTokenCookie,\n} from '@/synapse-client'\nimport { OAuth2State } from '@/utils/types'\nimport { TwoFactorAuthErrorResponse } from '@sage-bionetworks/synapse-client/generated/models/TwoFactorAuthErrorResponse'\nimport { SynapseClientError } from '@sage-bionetworks/synapse-client/util/SynapseClientError'\nimport { LoginResponse } from '@sage-bionetworks/synapse-types'\nimport { useEffect, useMemo, useState } from 'react'\nimport { BackendDestinationEnum } from '../functions'\nimport { CSRF_TOKEN_STORAGE_KEY, OAUTH2_PROVIDERS } from '../SynapseConstants'\nimport { useOneSageURL } from './useOneSageURL'\n\nfunction safeLocalStorageGetItem(key: string): string | null {\n try {\n return localStorage.getItem(key)\n } catch (err) {\n console.warn(`Unable to read from localStorage: ${key}`, err)\n return null\n }\n}\n\nfunction safeLocalStorageRemoveItem(key: string): void {\n try {\n localStorage.removeItem(key)\n } catch (err) {\n console.warn(`Unable to remove from localStorage: ${key}`, err)\n }\n}\n\nexport type UseDetectSSOCodeReturnType = {\n /* true iff SSO login has occurred and the completion of the OAuth flow in Synapse is pending */\n isLoading: boolean\n}\n\nexport type UseDetectSSOCodeOptions = {\n onSignInComplete?: () => void\n registerAccountUrl?: string\n onError?: (err: unknown) => void\n onTwoFactorAuthRequired?: (resp: TwoFactorAuthErrorResponse) => void\n onTwoFactorAuthResetTokenPresent?: (\n resp: TwoFactorAuthErrorResponse,\n encodedTwoFaResetToken: string,\n ) => void\n isInitializingSession: boolean\n isAuthenticated: boolean\n}\n\n/*\n * During SSO login, the authorization provider (Google, ORCiD, ArcusBio Okta, ...) will send the user back to the portal with an authorization code,\n * which can be exchanged for a Synapse user session. This function should be called whenever the root App is initialized\n * (to look for this code parameter and complete the round-trip). If state is included, then we assume that this is being\n * used for account creation, where we pass the username through the process.\n */\nexport default function useDetectSSOCode(\n opts: UseDetectSSOCodeOptions = {\n isInitializingSession: true,\n isAuthenticated: false,\n },\n): UseDetectSSOCodeReturnType {\n const defaultRegisterAccountURL = useOneSageURL('/register1')\n\n const {\n onSignInComplete,\n registerAccountUrl = defaultRegisterAccountURL.toString(),\n onError,\n onTwoFactorAuthRequired,\n onTwoFactorAuthResetTokenPresent,\n isInitializingSession,\n isAuthenticated,\n } = opts\n const redirectURL = getRootURL()\n // 'code' handling (from SSO) should be preformed on the root page, and then redirect to original route.\n // Use 'http://localhost/' as a placeholder during SSR (no browser URL available).\n // Since there is no 'code' or 'provider' in this placeholder URL, the rest of the hook is a no-op.\n const fullUrl: URL = new URL(\n typeof window !== 'undefined' ? window.location.href : 'http://localhost/',\n )\n // in test environment the searchParams isn't defined\n const { searchParams } = fullUrl\n const code = searchParams?.get('code')\n const provider = searchParams?.get('provider')\n\n // If the URL contains a client_id and redirect_uri, then we are acting as an identity provider for an external OAuth client\n const isHandlingSynapseOAuthSignIn = Boolean(\n searchParams?.get('client_id') && searchParams?.get('redirect_uri'),\n )\n\n // If the Synapse user signed in with an external IdP, we may have passed data in the 'state' param\n // Parse it (if appropriate)\n const state: OAuth2State | null = useMemo(() => {\n // If we are acting as an OIDC identity provider, then we should not parse the state param -- it was sent to us, and we should return it untouched\n if (!isHandlingSynapseOAuthSignIn) {\n const encodedState = searchParams?.get('state')\n try {\n return encodedState\n ? (JSON.parse(decodeURIComponent(encodedState)) as OAuth2State)\n : null\n } catch (e) {\n console.error(\n 'Error parsing state param:\\n',\n e,\n '\\nEncoded value:\\n',\n encodedState,\n )\n }\n }\n return null\n }, [isHandlingSynapseOAuthSignIn, searchParams])\n\n const [isLoading, setIsLoading] = useState(!!(code && provider))\n\n useEffect(() => {\n if (!isInitializingSession) {\n if (code && provider) {\n if (!isHandlingSynapseOAuthSignIn) {\n const storedCsrfToken = safeLocalStorageGetItem(\n CSRF_TOKEN_STORAGE_KEY,\n )\n\n const expectedCsrfToken = state?.csrfToken ?? null\n const tokensMatch =\n typeof expectedCsrfToken === 'string' &&\n typeof storedCsrfToken === 'string' &&\n expectedCsrfToken === storedCsrfToken\n\n if (!tokensMatch) {\n safeLocalStorageRemoveItem(CSRF_TOKEN_STORAGE_KEY)\n console.error(\n 'Invalid or missing OAuth CSRF token detected. Aborting OAuth flow.',\n )\n if (onError) {\n onError('Invalid OAuth state. Please try signing in again.')\n }\n setIsLoading(false)\n return\n }\n\n safeLocalStorageRemoveItem(CSRF_TOKEN_STORAGE_KEY)\n }\n\n const redirectUrl = `${redirectURL}?provider=${provider}`\n\n //If user is already logged in, and the provider is ORCID, then try to bind this OAuth provider to the account.\n if (OAUTH2_PROVIDERS.ORCID == provider && isAuthenticated) {\n // now bind this to the user account\n const onFailure = (err: SynapseClientError) => {\n console.error('Error binding ORCiD to account: ', err)\n if (onError) {\n onError(err.reason)\n }\n }\n bindOAuthProviderToAccount(\n provider,\n code,\n redirectUrl,\n BackendDestinationEnum.REPO_ENDPOINT,\n )\n .then(onSignInComplete)\n .catch(onFailure)\n .finally(() => setIsLoading(false))\n } else if (\n OAUTH2_PROVIDERS.GOOGLE == provider ||\n OAUTH2_PROVIDERS.ORCID == provider ||\n OAUTH2_PROVIDERS.ARCUS == provider ||\n OAUTH2_PROVIDERS.SAGE_BIONETWORKS == provider\n ) {\n const onSuccess = (\n response: LoginResponse | TwoFactorAuthErrorResponse | null,\n ) => {\n if (response) {\n if ('accessToken' in response) {\n setAccessTokenCookie(response.accessToken).then(\n onSignInComplete,\n )\n } else {\n // The app will redirect or open a modal to handle a standard 2FA sign in\n if (onTwoFactorAuthRequired) {\n onTwoFactorAuthRequired(response)\n }\n if (\n // The user logged in with OAuth while attempting to disable 2FA using an emailed signed token\n state &&\n state.twoFaResetToken &&\n onTwoFactorAuthResetTokenPresent\n ) {\n // Let the app handle redirecting to the 2FA reset page\n onTwoFactorAuthResetTokenPresent(\n response,\n state.twoFaResetToken,\n )\n }\n }\n }\n }\n const onFailure = (err: SynapseClientError) => {\n if (err.status === 404) {\n // Synapse account not found, send to registration page\n window.location.replace(registerAccountUrl)\n }\n console.error('Error with account login: ', err)\n if (onError) {\n onError(err.reason)\n }\n }\n\n if (\n (OAUTH2_PROVIDERS.GOOGLE == provider ||\n OAUTH2_PROVIDERS.ARCUS == provider ||\n OAUTH2_PROVIDERS.SAGE_BIONETWORKS == provider) &&\n state?.registrationUsername\n ) {\n oAuthRegisterAccountStep2(\n state.registrationUsername,\n provider,\n code,\n redirectUrl,\n BackendDestinationEnum.REPO_ENDPOINT,\n )\n .then(onSuccess)\n .catch(onFailure)\n .finally(() => setIsLoading(false))\n } else {\n oAuthSessionRequest(\n provider,\n code,\n redirectUrl,\n BackendDestinationEnum.REPO_ENDPOINT,\n )\n .then(onSuccess)\n .catch(onFailure)\n .finally(() => setIsLoading(false))\n }\n } else {\n console.warn('Unknown SSO Provider: ', provider)\n setIsLoading(false)\n }\n }\n }\n // Intentionally only monitoring initialization of the session -- only running on mount after the session detection has completed since this uses URL params that come from a redirect\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n }, [isInitializingSession])\n\n return { isLoading }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"useImmutableTableQuery.js","names":[],"sources":["../../../../src/utils/hooks/useImmutableTableQuery/useImmutableTableQuery.ts"],"sourcesContent":["import { UniqueFacetIdentifier } from '@/utils/types/UniqueFacetIdentifier'\nimport {\n QueryBundleRequest,\n QueryFilter,\n} from '@sage-bionetworks/synapse-types'\nimport { cloneDeep, isEqual } from 'lodash-es'\nimport * as React from 'react'\nimport { useCallback, useEffect, useMemo } from 'react'\nimport { ReadonlyDeep } from 'type-fest'\nimport useDeepCompareEffect from 'use-deep-compare-effect'\nimport * as DeepLinkingUtils from '../../functions/deepLinkingUtils'\nimport { removeEmptyQueryParams } from '../../functions/queryUtils'\nimport { parseEntityIdAndVersionFromSqlStatement } from '../../functions/SqlFunctions'\nimport { DEFAULT_PAGE_SIZE } from '../../SynapseConstants'\nimport {\n QueryChangeCommitOptions,\n useTableQueryReducer,\n} from './useTableQueryReducer'\n\nexport type ImmutableTableQueryResult = {\n /** The ID of the table parsed from the SQL query */\n entityId?: string\n /** The version number of the table parsed from the SQL query */\n versionNumber?: number\n /** The current query request, which is passed on to the server for results */\n currentQueryRequest: ReadonlyDeep<QueryBundleRequest>\n /** The next (uncommitted) query request. This will become the current query request when commitChanges is called, or the configured debounced timer elapses. */\n nextQueryRequest: ReadonlyDeep<QueryBundleRequest>\n /** Resets the debounce timer to delay committing changes in `nextQueryRequest` */\n resetDebounceTimer: () => void\n /** Update the currentQueryRequest to be the nextQueryRequest */\n commitChanges: () => void\n getInitQueryRequest: () => QueryBundleRequest\n getCurrentQueryRequest: () => QueryBundleRequest\n setQuery: (\n queryRequest: React.SetStateAction<QueryBundleRequest>,\n commitOptions?: QueryChangeCommitOptions,\n ) => void\n pageSize: number\n /** The current page of results. The first page is `1` */\n currentPage: number\n setPageSize: (pageSize: number) => void\n /** pageNumber is 1-indexed */\n goToPage: (pageNumber: number) => void\n /** Resets the query to the initial state, clearing all user-specified filters */\n resetQuery: () => void\n addValueToSelectedFacet: (\n facet: UniqueFacetIdentifier,\n value: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => void\n /** Removes a particular selected facet from the query */\n removeSelectedFacet: (\n facet: UniqueFacetIdentifier | UniqueFacetIdentifier[],\n ) => void\n /** Removes a particular value from a selected facet. If the value is the last value in the FacetColumnRequest, the selected facet will be removed. */\n removeValueFromSelectedFacet: (\n facet: UniqueFacetIdentifier,\n value: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => void\n setRangeFacetValue: (\n facet: UniqueFacetIdentifier,\n min?: string,\n max?: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => void\n /** Removes a particular QueryFilter from the query */\n removeQueryFilter: (filter: QueryFilter) => void\n /** Removes a particular value from a QueryFilter. If the value is the last value in the filter, the filter will be removed. */\n removeValueFromQueryFilter: (filter: QueryFilter, value: string) => void\n /** If `requireConfirmationOnChange` is true, this will become true when a function that triggers a query change is invoked. */\n isConfirmingChange: boolean\n /** If `isConfirmingChange` is true, invoke this function to complete the query change */\n onConfirmChange: () => void\n /** If `isConfirmingChange` is true, invoke this function to cancel the query change */\n onCancelChange: () => void\n\n /**\n * TODO: This hook could handle all potential query transformations, such as\n * - addFacetFilter, removeFacetFilter, clearFacetFilters\n * - addAdditionalFilter, remove..., clear...\n *\n * This could be preferable to allowing any QueryContext subscriber to arbitrarily update the query with `setQuery`\n * because we could uniformly handle all complex stateful logic in this hook\n */\n}\n\nexport type UseImmutableTableQueryOptions = {\n /** The initial table query request object */\n initQueryRequest: QueryBundleRequest\n /** Whether the URL should update when the query is modified. */\n shouldDeepLink?: boolean\n /** Unique index for the component on the page so URL updates do not conflict between table query components */\n componentIndex?: number\n /** Callback invoked when the query is modified */\n onQueryChange?: (newQueryJson: string) => void\n /** Whether to require explicit user confirmation before changing the query. */\n requireConfirmationOnChange?: boolean\n}\n\n// When changing the query with the debounce option, the amount of time to wait for additional debounced changes before updating the query\nexport const DEBOUNCE_DELAY_MS = 750\n\n/**\n * Utility to synchronize the URL with the current query request. Synchronization only occurs if `shouldDeepLink` is true.\n * @param shouldDeepLink whether the query should be synchronized with the URL\n * @param componentIndex the index of the component to synchronize with the URL - essential if multiple components to synchronize are on the page\n * @param setQuery - callback used to update the current query\n * @param initQueryRequest - the initial query request passed via props\n * @param currentQueryRequest - the query request that is currently used\n */\nfunction useSynchronizeQueryWithUrl(\n shouldDeepLink: boolean,\n componentIndex: number,\n setQuery: (\n queryRequest: React.SetStateAction<QueryBundleRequest>,\n commitOptions?: QueryChangeCommitOptions,\n ) => void,\n initQueryRequest: QueryBundleRequest,\n currentQueryRequest: QueryBundleRequest,\n) {\n /**\n * Inspect the URL on mount to see if we have a particular query request that we must show.\n */\n useEffect(() => {\n // Only run this effect if deep linking is enabled\n if (shouldDeepLink) {\n DeepLinkingUtils.getQueryRequestFromLink(\n componentIndex,\n initQueryRequest.query,\n ).then(queryRequestFromLink => {\n if (queryRequestFromLink && queryRequestFromLink.query) {\n setQuery(prevState => ({\n ...prevState,\n ...queryRequestFromLink,\n query: {\n ...prevState.query,\n ...queryRequestFromLink.query,\n },\n }))\n }\n })\n }\n // should only run on mount, or if the component index changes\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [componentIndex])\n\n // If `shouldDeepLink` is true, synchronize the URL\n useEffect(() => {\n if (shouldDeepLink) {\n if (isEqual(initQueryRequest, currentQueryRequest)) {\n void DeepLinkingUtils.updateUrlWithNewSearchParam(\n 'qw',\n componentIndex,\n null,\n null,\n ).catch(error => {\n console.error('Failed to update URL with search param:', error)\n })\n } else {\n void DeepLinkingUtils.updateUrlWithNewSearchParam(\n 'qw',\n componentIndex,\n currentQueryRequest.query,\n initQueryRequest.query,\n ).catch(error => {\n console.error('Failed to update URL with search param:', error)\n })\n }\n }\n // Clean up the URL param when this component unmounts or deep linking is disabled\n return () => {\n if (shouldDeepLink) {\n DeepLinkingUtils.updateUrlWithNewSearchParam(\n 'qw',\n componentIndex,\n null,\n initQueryRequest.query,\n )\n }\n }\n }, [componentIndex, currentQueryRequest, initQueryRequest, shouldDeepLink])\n}\n\n/**\n * Custom hook that maintains and manages the state of a Synapse Table query.\n * @param options\n * @returns\n */\nexport default function useImmutableTableQuery(\n options: UseImmutableTableQueryOptions,\n): ImmutableTableQueryResult {\n const {\n initQueryRequest: initQueryRequestFromProps,\n componentIndex = 0,\n shouldDeepLink = false,\n onQueryChange,\n requireConfirmationOnChange = false,\n } = options\n\n const initQueryRequest = useMemo(() => {\n const request = cloneDeep(initQueryRequestFromProps)\n request.query = removeEmptyQueryParams(request.query)\n return request\n }, [initQueryRequestFromProps])\n\n const {\n currentQueryRequest,\n nextQueryRequest,\n isConfirmingChange,\n dispatch,\n } = useTableQueryReducer(\n initQueryRequest,\n requireConfirmationOnChange,\n onQueryChange,\n )\n\n const onConfirmChange = useCallback(() => {\n dispatch({ type: 'confirmChanges' })\n }, [dispatch])\n\n /**\n * Pass down a deep clone (so no side effects on the child's part) of the\n * current query request\n *\n * @returns\n * @memberof QueryWrapper\n */\n const getCurrentQueryRequest = useCallback(() => {\n return cloneDeep(currentQueryRequest)\n }, [currentQueryRequest])\n\n /**\n * Pass down a deep clone (so no side effects on the child's part) of the\n * first query request made\n *\n * @returns\n * @memberof QueryWrapper\n */\n const getInitQueryRequest = useCallback((): QueryBundleRequest => {\n return cloneDeep(initQueryRequest)\n }, [initQueryRequest])\n\n /**\n * Execute the given query request, updating all the data in the QueryContext to match the new query\n * @param {*} queryRequest Query request as specified by\n * https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/table/Query.html\n */\n const setQuery: ImmutableTableQueryResult['setQuery'] = useCallback(\n (\n queryRequest: React.SetStateAction<QueryBundleRequest>,\n commitOptions?: QueryChangeCommitOptions,\n ): void => {\n dispatch({\n type: 'setQuery',\n queryOrUpdater: queryRequest,\n commitOptions,\n })\n },\n [dispatch],\n )\n\n useSynchronizeQueryWithUrl(\n shouldDeepLink,\n componentIndex,\n setQuery,\n initQueryRequest,\n currentQueryRequest,\n )\n\n const onCancelChange = useCallback(() => {\n dispatch({ type: 'cancelChanges' })\n }, [dispatch])\n\n const { entityId, versionNumber } = useMemo(\n () =>\n parseEntityIdAndVersionFromSqlStatement(currentQueryRequest.query.sql),\n [currentQueryRequest.query.sql],\n )\n\n const pageSize = currentQueryRequest.query.limit ?? DEFAULT_PAGE_SIZE\n const currentPage = Math.ceil(\n ((currentQueryRequest.query.offset ?? 0) + Number(pageSize)) / pageSize,\n )\n\n const setPageSize = useCallback(\n (pageSize: number) => {\n dispatch({ type: 'setPageSize', pageSize })\n },\n [dispatch],\n )\n\n const goToPage = useCallback(\n (pageNumber: number) => {\n dispatch({ type: 'goToPage', pageNumber })\n },\n [dispatch],\n )\n\n const resetQuery = useCallback(() => {\n dispatch({\n type: 'resetQuery',\n })\n }, [dispatch])\n\n /* If the initial query changes, then reset the query to match the new prop */\n useDeepCompareEffect(() => {\n if (currentQueryRequest != initQueryRequest) {\n resetQuery()\n }\n }, [initQueryRequest])\n\n const addValueToSelectedFacet = useCallback(\n (\n facet: UniqueFacetIdentifier,\n value: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => {\n dispatch({\n type: 'addValueToSelectedFacet',\n facet: facet,\n value: value,\n commitOptions: commitOptions,\n })\n },\n [dispatch],\n )\n\n const setRangeFacetValue = useCallback(\n (\n facet: UniqueFacetIdentifier,\n min?: string,\n max?: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => {\n dispatch({\n type: 'setRangeFacetValue',\n facet,\n min,\n max,\n commitOptions,\n })\n },\n [dispatch],\n )\n\n const removeSelectedFacet = useCallback(\n (facetsToRemove: UniqueFacetIdentifier | UniqueFacetIdentifier[]) => {\n dispatch({\n type: 'removeSelectedFacet',\n facetsToRemove: facetsToRemove,\n })\n },\n [dispatch],\n )\n\n const removeValueFromSelectedFacet = useCallback(\n (\n facet: UniqueFacetIdentifier,\n value: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => {\n dispatch({\n type: 'removeValueFromSelectedFacet',\n facet,\n value,\n commitOptions,\n })\n },\n [dispatch],\n )\n\n const removeQueryFilter = useCallback(\n (queryFilter: QueryFilter) => {\n dispatch({\n type: 'removeQueryFilter',\n queryFilter,\n })\n },\n [dispatch],\n )\n\n const removeValueFromQueryFilter = useCallback(\n (queryFilter: QueryFilter, value: string) => {\n dispatch({\n type: 'removeValueFromQueryFilter',\n queryFilter,\n value,\n })\n },\n [dispatch],\n )\n const commitChanges = useCallback(() => {\n dispatch({ type: 'commitChanges' })\n }, [dispatch])\n\n const resetDebounceTimer = useCallback(() => {\n dispatch({ type: 'resetDebounce' })\n }, [dispatch])\n\n return {\n entityId,\n commitChanges,\n resetDebounceTimer,\n currentQueryRequest,\n nextQueryRequest,\n versionNumber,\n getInitQueryRequest,\n getCurrentQueryRequest,\n setQuery,\n pageSize,\n currentPage,\n setPageSize,\n goToPage,\n resetQuery,\n removeSelectedFacet,\n removeValueFromSelectedFacet,\n removeQueryFilter,\n removeValueFromQueryFilter,\n isConfirmingChange,\n onConfirmChange,\n onCancelChange,\n addValueToSelectedFacet,\n setRangeFacetValue,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"useImmutableTableQuery.js","names":[],"sources":["../../../../src/utils/hooks/useImmutableTableQuery/useImmutableTableQuery.ts"],"sourcesContent":["import { UniqueFacetIdentifier } from '@/utils/types/UniqueFacetIdentifier'\nimport {\n QueryBundleRequest,\n QueryFilter,\n} from '@sage-bionetworks/synapse-types'\nimport { cloneDeep, isEqual } from 'lodash-es'\nimport * as React from 'react'\nimport { useCallback, useEffect, useMemo } from 'react'\nimport { ReadonlyDeep } from 'type-fest'\nimport useDeepCompareEffect from 'use-deep-compare-effect'\nimport * as DeepLinkingUtils from '../../functions/deepLinkingUtils'\nimport { removeEmptyQueryParams } from '../../functions/queryUtils'\nimport { parseEntityIdAndVersionFromSqlStatement } from '../../functions/SqlFunctions'\nimport { DEFAULT_PAGE_SIZE } from '../../SynapseConstants'\nimport {\n QueryChangeCommitOptions,\n useTableQueryReducer,\n} from './useTableQueryReducer'\n\nexport type ImmutableTableQueryResult = {\n /** The ID of the table parsed from the SQL query */\n entityId?: string\n /** The version number of the table parsed from the SQL query */\n versionNumber?: number\n /** The current query request, which is passed on to the server for results */\n currentQueryRequest: ReadonlyDeep<QueryBundleRequest>\n /** The next (uncommitted) query request. This will become the current query request when commitChanges is called, or the configured debounced timer elapses. */\n nextQueryRequest: ReadonlyDeep<QueryBundleRequest>\n /** Resets the debounce timer to delay committing changes in `nextQueryRequest` */\n resetDebounceTimer: () => void\n /** Update the currentQueryRequest to be the nextQueryRequest */\n commitChanges: () => void\n getInitQueryRequest: () => QueryBundleRequest\n getCurrentQueryRequest: () => QueryBundleRequest\n setQuery: (\n queryRequest: React.SetStateAction<QueryBundleRequest>,\n commitOptions?: QueryChangeCommitOptions,\n ) => void\n pageSize: number\n /** The current page of results. The first page is `1` */\n currentPage: number\n setPageSize: (pageSize: number) => void\n /** pageNumber is 1-indexed */\n goToPage: (pageNumber: number) => void\n /** Resets the query to the initial state, clearing all user-specified filters */\n resetQuery: () => void\n addValueToSelectedFacet: (\n facet: UniqueFacetIdentifier,\n value: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => void\n /** Removes a particular selected facet from the query */\n removeSelectedFacet: (\n facet: UniqueFacetIdentifier | UniqueFacetIdentifier[],\n ) => void\n /** Removes a particular value from a selected facet. If the value is the last value in the FacetColumnRequest, the selected facet will be removed. */\n removeValueFromSelectedFacet: (\n facet: UniqueFacetIdentifier,\n value: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => void\n setRangeFacetValue: (\n facet: UniqueFacetIdentifier,\n min?: string,\n max?: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => void\n /** Removes a particular QueryFilter from the query */\n removeQueryFilter: (filter: QueryFilter) => void\n /** Removes a particular value from a QueryFilter. If the value is the last value in the filter, the filter will be removed. */\n removeValueFromQueryFilter: (filter: QueryFilter, value: string) => void\n /** If `requireConfirmationOnChange` is true, this will become true when a function that triggers a query change is invoked. */\n isConfirmingChange: boolean\n /** If `isConfirmingChange` is true, invoke this function to complete the query change */\n onConfirmChange: () => void\n /** If `isConfirmingChange` is true, invoke this function to cancel the query change */\n onCancelChange: () => void\n\n /**\n * TODO: This hook could handle all potential query transformations, such as\n * - addFacetFilter, removeFacetFilter, clearFacetFilters\n * - addAdditionalFilter, remove..., clear...\n *\n * This could be preferable to allowing any QueryContext subscriber to arbitrarily update the query with `setQuery`\n * because we could uniformly handle all complex stateful logic in this hook\n */\n}\n\nexport type UseImmutableTableQueryOptions = {\n /** The initial table query request object */\n initQueryRequest: QueryBundleRequest\n /** Whether the URL should update when the query is modified. */\n shouldDeepLink?: boolean\n /** Unique index for the component on the page so URL updates do not conflict between table query components */\n componentIndex?: number\n /** Callback invoked when the query is modified */\n onQueryChange?: (newQueryJson: string) => void\n /** Whether to require explicit user confirmation before changing the query. */\n requireConfirmationOnChange?: boolean\n}\n\n// When changing the query with the debounce option, the amount of time to wait for additional debounced changes before updating the query\nexport const DEBOUNCE_DELAY_MS = 750\n\n/**\n * Utility to synchronize the URL with the current query request. Synchronization only occurs if `shouldDeepLink` is true.\n * @param shouldDeepLink whether the query should be synchronized with the URL\n * @param componentIndex the index of the component to synchronize with the URL - essential if multiple components to synchronize are on the page\n * @param setQuery - callback used to update the current query\n * @param initQueryRequest - the initial query request passed via props\n * @param currentQueryRequest - the query request that is currently used\n */\nfunction useSynchronizeQueryWithUrl(\n shouldDeepLink: boolean,\n componentIndex: number,\n setQuery: (\n queryRequest: React.SetStateAction<QueryBundleRequest>,\n commitOptions?: QueryChangeCommitOptions,\n ) => void,\n initQueryRequest: QueryBundleRequest,\n currentQueryRequest: QueryBundleRequest,\n) {\n /**\n * Inspect the URL on mount to see if we have a particular query request that we must show.\n */\n useEffect(() => {\n // Only run this effect if deep linking is enabled\n if (shouldDeepLink) {\n DeepLinkingUtils.getQueryRequestFromLink(\n componentIndex,\n initQueryRequest.query,\n ).then(queryRequestFromLink => {\n if (queryRequestFromLink && queryRequestFromLink.query) {\n setQuery(prevState => ({\n ...prevState,\n ...queryRequestFromLink,\n query: {\n ...prevState.query,\n ...queryRequestFromLink.query,\n },\n }))\n }\n })\n }\n // should only run on mount, or if the component index changes\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n }, [componentIndex])\n\n // If `shouldDeepLink` is true, synchronize the URL\n useEffect(() => {\n if (shouldDeepLink) {\n if (isEqual(initQueryRequest, currentQueryRequest)) {\n void DeepLinkingUtils.updateUrlWithNewSearchParam(\n 'qw',\n componentIndex,\n null,\n null,\n ).catch(error => {\n console.error('Failed to update URL with search param:', error)\n })\n } else {\n void DeepLinkingUtils.updateUrlWithNewSearchParam(\n 'qw',\n componentIndex,\n currentQueryRequest.query,\n initQueryRequest.query,\n ).catch(error => {\n console.error('Failed to update URL with search param:', error)\n })\n }\n }\n // Clean up the URL param when this component unmounts or deep linking is disabled\n return () => {\n if (shouldDeepLink) {\n DeepLinkingUtils.updateUrlWithNewSearchParam(\n 'qw',\n componentIndex,\n null,\n initQueryRequest.query,\n )\n }\n }\n }, [componentIndex, currentQueryRequest, initQueryRequest, shouldDeepLink])\n}\n\n/**\n * Custom hook that maintains and manages the state of a Synapse Table query.\n * @param options\n * @returns\n */\nexport default function useImmutableTableQuery(\n options: UseImmutableTableQueryOptions,\n): ImmutableTableQueryResult {\n const {\n initQueryRequest: initQueryRequestFromProps,\n componentIndex = 0,\n shouldDeepLink = false,\n onQueryChange,\n requireConfirmationOnChange = false,\n } = options\n\n const initQueryRequest = useMemo(() => {\n const request = cloneDeep(initQueryRequestFromProps)\n request.query = removeEmptyQueryParams(request.query)\n return request\n }, [initQueryRequestFromProps])\n\n const {\n currentQueryRequest,\n nextQueryRequest,\n isConfirmingChange,\n dispatch,\n } = useTableQueryReducer(\n initQueryRequest,\n requireConfirmationOnChange,\n onQueryChange,\n )\n\n const onConfirmChange = useCallback(() => {\n dispatch({ type: 'confirmChanges' })\n }, [dispatch])\n\n /**\n * Pass down a deep clone (so no side effects on the child's part) of the\n * current query request\n *\n * @returns\n * @memberof QueryWrapper\n */\n const getCurrentQueryRequest = useCallback(() => {\n return cloneDeep(currentQueryRequest)\n }, [currentQueryRequest])\n\n /**\n * Pass down a deep clone (so no side effects on the child's part) of the\n * first query request made\n *\n * @returns\n * @memberof QueryWrapper\n */\n const getInitQueryRequest = useCallback((): QueryBundleRequest => {\n return cloneDeep(initQueryRequest)\n }, [initQueryRequest])\n\n /**\n * Execute the given query request, updating all the data in the QueryContext to match the new query\n * @param {*} queryRequest Query request as specified by\n * https://rest-docs.synapse.org/rest/org/sagebionetworks/repo/model/table/Query.html\n */\n const setQuery: ImmutableTableQueryResult['setQuery'] = useCallback(\n (\n queryRequest: React.SetStateAction<QueryBundleRequest>,\n commitOptions?: QueryChangeCommitOptions,\n ): void => {\n dispatch({\n type: 'setQuery',\n queryOrUpdater: queryRequest,\n commitOptions,\n })\n },\n [dispatch],\n )\n\n useSynchronizeQueryWithUrl(\n shouldDeepLink,\n componentIndex,\n setQuery,\n initQueryRequest,\n currentQueryRequest,\n )\n\n const onCancelChange = useCallback(() => {\n dispatch({ type: 'cancelChanges' })\n }, [dispatch])\n\n const { entityId, versionNumber } = useMemo(\n () =>\n parseEntityIdAndVersionFromSqlStatement(currentQueryRequest.query.sql),\n [currentQueryRequest.query.sql],\n )\n\n const pageSize = currentQueryRequest.query.limit ?? DEFAULT_PAGE_SIZE\n const currentPage = Math.ceil(\n ((currentQueryRequest.query.offset ?? 0) + Number(pageSize)) / pageSize,\n )\n\n const setPageSize = useCallback(\n (pageSize: number) => {\n dispatch({ type: 'setPageSize', pageSize })\n },\n [dispatch],\n )\n\n const goToPage = useCallback(\n (pageNumber: number) => {\n dispatch({ type: 'goToPage', pageNumber })\n },\n [dispatch],\n )\n\n const resetQuery = useCallback(() => {\n dispatch({\n type: 'resetQuery',\n })\n }, [dispatch])\n\n /* If the initial query changes, then reset the query to match the new prop */\n useDeepCompareEffect(() => {\n if (currentQueryRequest != initQueryRequest) {\n resetQuery()\n }\n }, [initQueryRequest])\n\n const addValueToSelectedFacet = useCallback(\n (\n facet: UniqueFacetIdentifier,\n value: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => {\n dispatch({\n type: 'addValueToSelectedFacet',\n facet: facet,\n value: value,\n commitOptions: commitOptions,\n })\n },\n [dispatch],\n )\n\n const setRangeFacetValue = useCallback(\n (\n facet: UniqueFacetIdentifier,\n min?: string,\n max?: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => {\n dispatch({\n type: 'setRangeFacetValue',\n facet,\n min,\n max,\n commitOptions,\n })\n },\n [dispatch],\n )\n\n const removeSelectedFacet = useCallback(\n (facetsToRemove: UniqueFacetIdentifier | UniqueFacetIdentifier[]) => {\n dispatch({\n type: 'removeSelectedFacet',\n facetsToRemove: facetsToRemove,\n })\n },\n [dispatch],\n )\n\n const removeValueFromSelectedFacet = useCallback(\n (\n facet: UniqueFacetIdentifier,\n value: string,\n commitOptions?: QueryChangeCommitOptions,\n ) => {\n dispatch({\n type: 'removeValueFromSelectedFacet',\n facet,\n value,\n commitOptions,\n })\n },\n [dispatch],\n )\n\n const removeQueryFilter = useCallback(\n (queryFilter: QueryFilter) => {\n dispatch({\n type: 'removeQueryFilter',\n queryFilter,\n })\n },\n [dispatch],\n )\n\n const removeValueFromQueryFilter = useCallback(\n (queryFilter: QueryFilter, value: string) => {\n dispatch({\n type: 'removeValueFromQueryFilter',\n queryFilter,\n value,\n })\n },\n [dispatch],\n )\n const commitChanges = useCallback(() => {\n dispatch({ type: 'commitChanges' })\n }, [dispatch])\n\n const resetDebounceTimer = useCallback(() => {\n dispatch({ type: 'resetDebounce' })\n }, [dispatch])\n\n return {\n entityId,\n commitChanges,\n resetDebounceTimer,\n currentQueryRequest,\n nextQueryRequest,\n versionNumber,\n getInitQueryRequest,\n getCurrentQueryRequest,\n setQuery,\n pageSize,\n currentPage,\n setPageSize,\n goToPage,\n resetQuery,\n removeSelectedFacet,\n removeValueFromSelectedFacet,\n removeQueryFilter,\n removeValueFromQueryFilter,\n isConfirmingChange,\n onConfirmChange,\n onCancelChange,\n addValueToSelectedFacet,\n setRangeFacetValue,\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"useLogin.js","names":[],"sources":["../../../src/utils/hooks/useLogin.ts"],"sourcesContent":["import SynapseClient from '@/synapse-client'\nimport { useResetTwoFactorAuth } from '@/synapse-queries'\nimport {\n instanceOfTwoFactorAuthErrorResponse,\n TwoFactorAuthErrorResponse,\n} from '@sage-bionetworks/synapse-client/generated/models/TwoFactorAuthErrorResponse'\nimport { SynapseClientError } from '@sage-bionetworks/synapse-client/util/SynapseClientError'\nimport {\n ErrorResponseCode,\n LoginResponse,\n TwoFactorAuthLoginRequest,\n TwoFactorAuthOtpType,\n TwoFactorAuthResetRequest,\n} from '@sage-bionetworks/synapse-types'\nimport { useMutation } from '@tanstack/react-query'\nimport { noop } from 'lodash-es'\nimport { Dispatch, SetStateAction, useEffect, useState } from 'react'\nimport { AUTHENTICATION_RECEIPT_LOCALSTORAGE_KEY } from '../SynapseConstants'\nimport { useOneSageURL } from './useOneSageURL'\nimport { ONE_TIME_PASSWORD_STEP } from '@/components/Authentication/OneTimePasswordForm'\nimport { TOTP_CLOCK_SKEW_ERROR_APPENDAGE } from '@/components/Authentication/Constants'\n\nexport type UseLoginOptions = {\n sessionCallback?: () => void\n twoFaErrorResponse?: TwoFactorAuthErrorResponse\n /* If a twoFactorAuthError is encountered (including passed in the twoFactorAuthenticationRequired arg), this callback will be invoked */\n onTwoFactorAuthRequired?: (\n twoFaToken: Pick<TwoFactorAuthErrorResponse, 'twoFaToken' | 'userId'>,\n ) => void\n}\n\nexport type UseLoginReturn = {\n step:\n | 'CHOOSE_AUTH_METHOD'\n | 'USERNAME_PASSWORD'\n | ONE_TIME_PASSWORD_STEP\n | 'LOGGED_IN'\n onStepChange: Dispatch<SetStateAction<UseLoginReturn['step']>>\n submitUsernameAndPassword: (username: string, password: string) => void\n submitOneTimePassword: (\n code: string,\n /* The type of one time password code that can be used to authenticate through two-factor authentication. Default is based on current value of `step` */\n otpType?: TwoFactorAuthOtpType,\n ) => void\n /* Trigger sending an email which can be used to disable 2FA */\n beginTwoFactorAuthReset: (twoFaResetEndpoint: string) => void\n twoFactorAuthResetIsPending: boolean\n twoFactorAuthResetIsSuccess: boolean\n errorMessage: string | undefined\n loginIsPending: boolean\n}\n\n// When prompting the user for a 2FA code, allow the UI to show only these steps\nconst VALID_STEPS_DURING_2FA_PROMPT: Array<UseLoginReturn['step']> = [\n 'VERIFICATION_CODE',\n 'RECOVERY_CODE',\n 'LOGGED_IN',\n 'DISABLE_2FA_PROMPT',\n]\n\n/**\n * Stateful hook that manages logging into Synapse\n */\nexport default function useLogin(opts: UseLoginOptions): UseLoginReturn {\n const {\n sessionCallback = noop,\n twoFaErrorResponse: twoFaErrorResponseFromProps,\n onTwoFactorAuthRequired = noop,\n } = opts\n const [step, setStep] = useState<UseLoginReturn['step']>('CHOOSE_AUTH_METHOD')\n const [errorMessage, setErrorMessage] = useState<string | undefined>()\n const [twoFaErrorResponse, setTwoFaErrorResponse] = useState<\n TwoFactorAuthErrorResponse | undefined\n >()\n\n const changePasswordUrl = useOneSageURL('/changePassword')\n /**\n * Update state variable if optional prop changes\n */\n useEffect(() => {\n if (twoFaErrorResponseFromProps) {\n setTwoFaErrorResponse(twoFaErrorResponseFromProps)\n }\n }, [twoFaErrorResponseFromProps])\n\n /*\n * In SWC, if logging in with OAuth, the servlet will call POST /oauth2/session2 to get the access token.\n *\n * If the user has 2FA enabled, the servlet will receive a 401 with the userId and twoFaToken in the response.\n * The servlet will then redirect the user to the login page with the userId and twoFaToken in the searchParams.\n *\n * This effect will check if the search params are present, and change the step to 'VERIFICATION_CODE' if they are.\n */\n useEffect(() => {\n const fullUrl: URL = new URL(window.location.href)\n\n const { searchParams } = fullUrl\n if (searchParams) {\n const userId = searchParams.get('userId')\n const twoFaToken = searchParams.get('twoFaToken')\n if (userId && twoFaToken) {\n setTwoFaErrorResponse({\n errorCode: ErrorResponseCode.TWO_FA_REQUIRED,\n reason: '',\n userId: parseInt(userId, 10),\n twoFaToken,\n concreteType:\n 'org.sagebionetworks.repo.model.auth.TwoFactorAuthErrorResponse',\n })\n if (!VALID_STEPS_DURING_2FA_PROMPT.includes(step)) {\n setStep('VERIFICATION_CODE')\n }\n }\n }\n }, [step])\n\n /*\n * In client-only apps (like Portals), if logging in with OAuth, the client app will call POST /oauth2/session2 to get the access token.\n *\n * If the user has 2FA enabled, the client app will receive a 401 with the userId and twoFaToken in the response.\n *\n * The app can pass the error response as a prop/argument, and we'll use it here to jump straight to the VERIFICATION_CODE step.\n */\n useEffect(() => {\n if (twoFaErrorResponse) {\n setTwoFaErrorResponse(twoFaErrorResponse)\n if (!VALID_STEPS_DURING_2FA_PROMPT.includes(step)) {\n setStep('VERIFICATION_CODE')\n }\n }\n // We do NOT want to rerun this effect on step change. It should only run once, when we get the error.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [twoFaErrorResponse])\n\n /**\n * Call the onTwoFactorAuthRequired callback when a twoFaToken is present\n */\n useEffect(() => {\n if (twoFaErrorResponse) {\n onTwoFactorAuthRequired(twoFaErrorResponse)\n }\n }, [onTwoFactorAuthRequired, twoFaErrorResponse])\n\n /* When the step changes, clear the old error message. */\n useEffect(() => {\n setErrorMessage(undefined)\n }, [step])\n\n async function finishLogin(loginResponse: LoginResponse) {\n await SynapseClient.setAccessTokenCookie(loginResponse.accessToken)\n localStorage.setItem(\n AUTHENTICATION_RECEIPT_LOCALSTORAGE_KEY,\n loginResponse.authenticationReceipt ?? '',\n )\n setStep('LOGGED_IN')\n\n if (sessionCallback) {\n sessionCallback()\n }\n }\n\n const {\n mutate: mutateLoginWithUsernameAndPassword,\n isPending: loginWithUsernameAndPasswordIsPending,\n } = useMutation<\n LoginResponse | TwoFactorAuthErrorResponse,\n SynapseClientError,\n { username: string; password: string; authenticationReceipt: string | null }\n >({\n mutationFn: ({ username, password, authenticationReceipt }) =>\n SynapseClient.login(username, password, authenticationReceipt),\n onError: error => {\n setErrorMessage(error.reason)\n const { errorResponse } = error\n if (\n errorResponse &&\n 'errorCode' in errorResponse &&\n errorResponse.errorCode ==\n ErrorResponseCode.PASSWORD_RESET_VIA_EMAIL_REQUIRED\n ) {\n window.location.assign(changePasswordUrl.toString())\n }\n },\n onSuccess: async loginResponse => {\n if (loginResponse) {\n if (instanceOfTwoFactorAuthErrorResponse(loginResponse)) {\n setStep('VERIFICATION_CODE')\n setTwoFaErrorResponse(loginResponse)\n } else {\n await finishLogin(loginResponse)\n }\n }\n },\n })\n\n const {\n mutate: mutateLoginWith2FACode,\n isPending: loginWith2FACodeIsPending,\n } = useMutation<LoginResponse, SynapseClientError, TwoFactorAuthLoginRequest>(\n {\n mutationFn: SynapseClient.loginWith2fa,\n onError: e => {\n setErrorMessage(e.reason)\n if (e.reason.includes('The provided code is invalid')) {\n // The most common cause of an invalid TOTP code is clock skew between the user's device and the server.\n setErrorMessage(`${e.reason} ${TOTP_CLOCK_SKEW_ERROR_APPENDAGE}`)\n } else if (\n // The twoFaToken wasn't transmitted correctly\n e.reason.includes('The provided twoFaToken is invalid') ||\n // The user waited too long to enter the code.\n e.reason.includes('Token has expired')\n ) {\n console.warn(e)\n // Instruct the user refresh to start over.\n setErrorMessage(\n 'Something went wrong. Refresh the page and try again.',\n )\n // If the 2FA token is in the search parameters, remove it so the user doesn't just get the same error again.\n if (window.location.href.includes('twoFaToken')) {\n window.history.replaceState(\n {},\n document.title,\n // using regex because SWC hashbang doesn't work with URLSearchParams\n window.location.href.replaceAll(\n /(twoFaToken|userId)=[^&]*&?/g,\n '',\n ),\n )\n }\n }\n },\n onSuccess: finishLogin,\n },\n )\n\n const {\n mutate: resetTwoFactorAuth,\n isSuccess: twoFactorAuthResetIsSuccess,\n isPending: twoFactorAuthResetIsPending,\n } = useResetTwoFactorAuth({\n onError: e => {\n setErrorMessage(e.reason)\n },\n })\n\n const submitUsernameAndPassword: UseLoginReturn['submitUsernameAndPassword'] =\n (username, password) => {\n setErrorMessage(undefined)\n const authenticationReceipt = localStorage.getItem(\n AUTHENTICATION_RECEIPT_LOCALSTORAGE_KEY,\n )\n mutateLoginWithUsernameAndPassword({\n username,\n password,\n authenticationReceipt,\n })\n }\n\n function verifyTwoFaErrorIsPresent(\n twoFaErrorResponse: TwoFactorAuthErrorResponse | null | undefined,\n ): twoFaErrorResponse is TwoFactorAuthErrorResponse {\n if (twoFaErrorResponse == null) {\n // This type of error could happen if the 2FA component exists on its own route, and the user directly navigates to it without first logging in with credentials/OAuth\n setErrorMessage(\n 'You did not first log in with your password or a third-party identity provider.',\n )\n return false\n }\n return true\n }\n\n const submitOneTimePassword: UseLoginReturn['submitOneTimePassword'] = (\n code,\n otpType = step === 'RECOVERY_CODE' ? 'RECOVERY_CODE' : 'TOTP',\n ) => {\n setErrorMessage(undefined)\n if (verifyTwoFaErrorIsPresent(twoFaErrorResponse)) {\n const request: TwoFactorAuthLoginRequest = {\n userId: twoFaErrorResponse.userId!,\n twoFaToken: twoFaErrorResponse.twoFaToken!,\n otpCode: code,\n otpType: otpType,\n }\n mutateLoginWith2FACode(request)\n }\n }\n\n const beginTwoFactorAuthReset: UseLoginReturn['beginTwoFactorAuthReset'] = (\n twoFaResetEndpoint: string,\n ) => {\n setErrorMessage(undefined)\n if (verifyTwoFaErrorIsPresent(twoFaErrorResponse)) {\n const request: TwoFactorAuthResetRequest = {\n userId: twoFaErrorResponse.userId!,\n twoFaToken: twoFaErrorResponse.twoFaToken,\n twoFaResetEndpoint: twoFaResetEndpoint,\n }\n resetTwoFactorAuth(request)\n }\n }\n\n return {\n step,\n onStepChange: setStep,\n submitUsernameAndPassword,\n submitOneTimePassword: submitOneTimePassword,\n errorMessage,\n loginIsPending:\n loginWithUsernameAndPasswordIsPending || loginWith2FACodeIsPending,\n beginTwoFactorAuthReset,\n twoFactorAuthResetIsPending,\n twoFactorAuthResetIsSuccess,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"useLogin.js","names":[],"sources":["../../../src/utils/hooks/useLogin.ts"],"sourcesContent":["import SynapseClient from '@/synapse-client'\nimport { useResetTwoFactorAuth } from '@/synapse-queries'\nimport {\n instanceOfTwoFactorAuthErrorResponse,\n TwoFactorAuthErrorResponse,\n} from '@sage-bionetworks/synapse-client/generated/models/TwoFactorAuthErrorResponse'\nimport { SynapseClientError } from '@sage-bionetworks/synapse-client/util/SynapseClientError'\nimport {\n ErrorResponseCode,\n LoginResponse,\n TwoFactorAuthLoginRequest,\n TwoFactorAuthOtpType,\n TwoFactorAuthResetRequest,\n} from '@sage-bionetworks/synapse-types'\nimport { useMutation } from '@tanstack/react-query'\nimport { noop } from 'lodash-es'\nimport { Dispatch, SetStateAction, useEffect, useState } from 'react'\nimport { AUTHENTICATION_RECEIPT_LOCALSTORAGE_KEY } from '../SynapseConstants'\nimport { useOneSageURL } from './useOneSageURL'\nimport { ONE_TIME_PASSWORD_STEP } from '@/components/Authentication/OneTimePasswordForm'\nimport { TOTP_CLOCK_SKEW_ERROR_APPENDAGE } from '@/components/Authentication/Constants'\n\nexport type UseLoginOptions = {\n sessionCallback?: () => void\n twoFaErrorResponse?: TwoFactorAuthErrorResponse\n /* If a twoFactorAuthError is encountered (including passed in the twoFactorAuthenticationRequired arg), this callback will be invoked */\n onTwoFactorAuthRequired?: (\n twoFaToken: Pick<TwoFactorAuthErrorResponse, 'twoFaToken' | 'userId'>,\n ) => void\n}\n\nexport type UseLoginReturn = {\n step:\n | 'CHOOSE_AUTH_METHOD'\n | 'USERNAME_PASSWORD'\n | ONE_TIME_PASSWORD_STEP\n | 'LOGGED_IN'\n onStepChange: Dispatch<SetStateAction<UseLoginReturn['step']>>\n submitUsernameAndPassword: (username: string, password: string) => void\n submitOneTimePassword: (\n code: string,\n /* The type of one time password code that can be used to authenticate through two-factor authentication. Default is based on current value of `step` */\n otpType?: TwoFactorAuthOtpType,\n ) => void\n /* Trigger sending an email which can be used to disable 2FA */\n beginTwoFactorAuthReset: (twoFaResetEndpoint: string) => void\n twoFactorAuthResetIsPending: boolean\n twoFactorAuthResetIsSuccess: boolean\n errorMessage: string | undefined\n loginIsPending: boolean\n}\n\n// When prompting the user for a 2FA code, allow the UI to show only these steps\nconst VALID_STEPS_DURING_2FA_PROMPT: Array<UseLoginReturn['step']> = [\n 'VERIFICATION_CODE',\n 'RECOVERY_CODE',\n 'LOGGED_IN',\n 'DISABLE_2FA_PROMPT',\n]\n\n/**\n * Stateful hook that manages logging into Synapse\n */\nexport default function useLogin(opts: UseLoginOptions): UseLoginReturn {\n const {\n sessionCallback = noop,\n twoFaErrorResponse: twoFaErrorResponseFromProps,\n onTwoFactorAuthRequired = noop,\n } = opts\n const [step, setStep] = useState<UseLoginReturn['step']>('CHOOSE_AUTH_METHOD')\n const [errorMessage, setErrorMessage] = useState<string | undefined>()\n const [twoFaErrorResponse, setTwoFaErrorResponse] = useState<\n TwoFactorAuthErrorResponse | undefined\n >()\n\n const changePasswordUrl = useOneSageURL('/changePassword')\n /**\n * Update state variable if optional prop changes\n */\n useEffect(() => {\n if (twoFaErrorResponseFromProps) {\n setTwoFaErrorResponse(twoFaErrorResponseFromProps)\n }\n }, [twoFaErrorResponseFromProps])\n\n /*\n * In SWC, if logging in with OAuth, the servlet will call POST /oauth2/session2 to get the access token.\n *\n * If the user has 2FA enabled, the servlet will receive a 401 with the userId and twoFaToken in the response.\n * The servlet will then redirect the user to the login page with the userId and twoFaToken in the searchParams.\n *\n * This effect will check if the search params are present, and change the step to 'VERIFICATION_CODE' if they are.\n */\n useEffect(() => {\n const fullUrl: URL = new URL(window.location.href)\n\n const { searchParams } = fullUrl\n if (searchParams) {\n const userId = searchParams.get('userId')\n const twoFaToken = searchParams.get('twoFaToken')\n if (userId && twoFaToken) {\n setTwoFaErrorResponse({\n errorCode: ErrorResponseCode.TWO_FA_REQUIRED,\n reason: '',\n userId: parseInt(userId, 10),\n twoFaToken,\n concreteType:\n 'org.sagebionetworks.repo.model.auth.TwoFactorAuthErrorResponse',\n })\n if (!VALID_STEPS_DURING_2FA_PROMPT.includes(step)) {\n setStep('VERIFICATION_CODE')\n }\n }\n }\n }, [step])\n\n /*\n * In client-only apps (like Portals), if logging in with OAuth, the client app will call POST /oauth2/session2 to get the access token.\n *\n * If the user has 2FA enabled, the client app will receive a 401 with the userId and twoFaToken in the response.\n *\n * The app can pass the error response as a prop/argument, and we'll use it here to jump straight to the VERIFICATION_CODE step.\n */\n useEffect(() => {\n if (twoFaErrorResponse) {\n setTwoFaErrorResponse(twoFaErrorResponse)\n if (!VALID_STEPS_DURING_2FA_PROMPT.includes(step)) {\n setStep('VERIFICATION_CODE')\n }\n }\n // We do NOT want to rerun this effect on step change. It should only run once, when we get the error.\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n }, [twoFaErrorResponse])\n\n /**\n * Call the onTwoFactorAuthRequired callback when a twoFaToken is present\n */\n useEffect(() => {\n if (twoFaErrorResponse) {\n onTwoFactorAuthRequired(twoFaErrorResponse)\n }\n }, [onTwoFactorAuthRequired, twoFaErrorResponse])\n\n /* When the step changes, clear the old error message. */\n useEffect(() => {\n setErrorMessage(undefined)\n }, [step])\n\n async function finishLogin(loginResponse: LoginResponse) {\n await SynapseClient.setAccessTokenCookie(loginResponse.accessToken)\n localStorage.setItem(\n AUTHENTICATION_RECEIPT_LOCALSTORAGE_KEY,\n loginResponse.authenticationReceipt ?? '',\n )\n setStep('LOGGED_IN')\n\n if (sessionCallback) {\n sessionCallback()\n }\n }\n\n const {\n mutate: mutateLoginWithUsernameAndPassword,\n isPending: loginWithUsernameAndPasswordIsPending,\n } = useMutation<\n LoginResponse | TwoFactorAuthErrorResponse,\n SynapseClientError,\n { username: string; password: string; authenticationReceipt: string | null }\n >({\n mutationFn: ({ username, password, authenticationReceipt }) =>\n SynapseClient.login(username, password, authenticationReceipt),\n onError: error => {\n setErrorMessage(error.reason)\n const { errorResponse } = error\n if (\n errorResponse &&\n 'errorCode' in errorResponse &&\n errorResponse.errorCode ==\n ErrorResponseCode.PASSWORD_RESET_VIA_EMAIL_REQUIRED\n ) {\n window.location.assign(changePasswordUrl.toString())\n }\n },\n onSuccess: async loginResponse => {\n if (loginResponse) {\n if (instanceOfTwoFactorAuthErrorResponse(loginResponse)) {\n setStep('VERIFICATION_CODE')\n setTwoFaErrorResponse(loginResponse)\n } else {\n await finishLogin(loginResponse)\n }\n }\n },\n })\n\n const {\n mutate: mutateLoginWith2FACode,\n isPending: loginWith2FACodeIsPending,\n } = useMutation<LoginResponse, SynapseClientError, TwoFactorAuthLoginRequest>(\n {\n mutationFn: SynapseClient.loginWith2fa,\n onError: e => {\n setErrorMessage(e.reason)\n if (e.reason.includes('The provided code is invalid')) {\n // The most common cause of an invalid TOTP code is clock skew between the user's device and the server.\n setErrorMessage(`${e.reason} ${TOTP_CLOCK_SKEW_ERROR_APPENDAGE}`)\n } else if (\n // The twoFaToken wasn't transmitted correctly\n e.reason.includes('The provided twoFaToken is invalid') ||\n // The user waited too long to enter the code.\n e.reason.includes('Token has expired')\n ) {\n console.warn(e)\n // Instruct the user refresh to start over.\n setErrorMessage(\n 'Something went wrong. Refresh the page and try again.',\n )\n // If the 2FA token is in the search parameters, remove it so the user doesn't just get the same error again.\n if (window.location.href.includes('twoFaToken')) {\n window.history.replaceState(\n {},\n document.title,\n // using regex because SWC hashbang doesn't work with URLSearchParams\n window.location.href.replaceAll(\n /(twoFaToken|userId)=[^&]*&?/g,\n '',\n ),\n )\n }\n }\n },\n onSuccess: finishLogin,\n },\n )\n\n const {\n mutate: resetTwoFactorAuth,\n isSuccess: twoFactorAuthResetIsSuccess,\n isPending: twoFactorAuthResetIsPending,\n } = useResetTwoFactorAuth({\n onError: e => {\n setErrorMessage(e.reason)\n },\n })\n\n const submitUsernameAndPassword: UseLoginReturn['submitUsernameAndPassword'] =\n (username, password) => {\n setErrorMessage(undefined)\n const authenticationReceipt = localStorage.getItem(\n AUTHENTICATION_RECEIPT_LOCALSTORAGE_KEY,\n )\n mutateLoginWithUsernameAndPassword({\n username,\n password,\n authenticationReceipt,\n })\n }\n\n function verifyTwoFaErrorIsPresent(\n twoFaErrorResponse: TwoFactorAuthErrorResponse | null | undefined,\n ): twoFaErrorResponse is TwoFactorAuthErrorResponse {\n if (twoFaErrorResponse == null) {\n // This type of error could happen if the 2FA component exists on its own route, and the user directly navigates to it without first logging in with credentials/OAuth\n setErrorMessage(\n 'You did not first log in with your password or a third-party identity provider.',\n )\n return false\n }\n return true\n }\n\n const submitOneTimePassword: UseLoginReturn['submitOneTimePassword'] = (\n code,\n otpType = step === 'RECOVERY_CODE' ? 'RECOVERY_CODE' : 'TOTP',\n ) => {\n setErrorMessage(undefined)\n if (verifyTwoFaErrorIsPresent(twoFaErrorResponse)) {\n const request: TwoFactorAuthLoginRequest = {\n userId: twoFaErrorResponse.userId!,\n twoFaToken: twoFaErrorResponse.twoFaToken!,\n otpCode: code,\n otpType: otpType,\n }\n mutateLoginWith2FACode(request)\n }\n }\n\n const beginTwoFactorAuthReset: UseLoginReturn['beginTwoFactorAuthReset'] = (\n twoFaResetEndpoint: string,\n ) => {\n setErrorMessage(undefined)\n if (verifyTwoFaErrorIsPresent(twoFaErrorResponse)) {\n const request: TwoFactorAuthResetRequest = {\n userId: twoFaErrorResponse.userId!,\n twoFaToken: twoFaErrorResponse.twoFaToken,\n twoFaResetEndpoint: twoFaResetEndpoint,\n }\n resetTwoFactorAuth(request)\n }\n }\n\n return {\n step,\n onStepChange: setStep,\n submitUsernameAndPassword,\n submitOneTimePassword: submitOneTimePassword,\n errorMessage,\n loginIsPending:\n loginWithUsernameAndPasswordIsPending || loginWith2FACodeIsPending,\n beginTwoFactorAuthReset,\n twoFactorAuthResetIsPending,\n twoFactorAuthResetIsSuccess,\n }\n}\n"],"mappings":";;;;;;;;;;;;AAqDA,IAAM,IAA+D;CACnE;CACA;CACA;CACA;AACF;AAKA,SAAwB,EAAS,GAAuC;CACtE,IAAM,EACJ,qBAAkB,GAClB,oBAAoB,GACpB,6BAA0B,MACxB,GACE,CAAC,GAAM,KAAW,EAAiC,oBAAoB,GACvE,CAAC,GAAc,KAAmB,EAA6B,GAC/D,CAAC,GAAoB,KAAyB,EAElD,GAEI,IAAoB,EAAc,iBAAiB;CAqEzD,AAjEA,QAAgB;EACd,AAAI,KACF,EAAsB,CAA2B;CAErD,GAAG,CAAC,CAA2B,CAAC,GAUhC,QAAgB;EAGd,IAAM,EAAE,oBAAiB,IAFA,IAAI,OAAO,SAAS,IAEpB;EACzB,IAAI,GAAc;GAChB,IAAM,IAAS,EAAa,IAAI,QAAQ,GAClC,IAAa,EAAa,IAAI,YAAY;GAChD,AAAI,KAAU,MACZ,EAAsB;IACpB,WAAW,EAAkB;IAC7B,QAAQ;IACR,QAAQ,SAAS,GAAQ,EAAE;IAC3B;IACA,cACE;GACJ,CAAC,GACI,EAA8B,SAAS,CAAI,KAC9C,EAAQ,mBAAmB;EAGjC;CACF,GAAG,CAAC,CAAI,CAAC,GAST,QAAgB;EACd,AAAI,MACF,EAAsB,CAAkB,GACnC,EAA8B,SAAS,CAAI,KAC9C,EAAQ,mBAAmB;CAKjC,GAAG,CAAC,CAAkB,CAAC,GAKvB,QAAgB;EACd,AAAI,KACF,EAAwB,CAAkB;CAE9C,GAAG,CAAC,GAAyB,CAAkB,CAAC,GAGhD,QAAgB;EACd,EAAgB,KAAA,CAAS;CAC3B,GAAG,CAAC,CAAI,CAAC;CAET,eAAe,EAAY,GAA8B;EAQvD,AAPA,MAAM,EAAc,qBAAqB,EAAc,WAAW,GAClE,aAAa,QACX,GACA,EAAc,yBAAyB,EACzC,GACA,EAAQ,WAAW,GAEf,KACF,EAAgB;CAEpB;CAEA,IAAM,EACJ,QAAQ,GACR,WAAW,MACT,EAIF;EACA,aAAa,EAAE,aAAU,aAAU,+BACjC,EAAc,MAAM,GAAU,GAAU,CAAqB;EAC/D,UAAS,MAAS;GAChB,EAAgB,EAAM,MAAM;GAC5B,IAAM,EAAE,qBAAkB;GAC1B,AACE,KACA,eAAe,KACf,EAAc,aACZ,EAAkB,qCAEpB,OAAO,SAAS,OAAO,EAAkB,SAAS,CAAC;EAEvD;EACA,WAAW,OAAM,MAAiB;GAChC,AAAI,MACE,EAAqC,CAAa,KACpD,EAAQ,mBAAmB,GAC3B,EAAsB,CAAa,KAEnC,MAAM,EAAY,CAAa;EAGrC;CACF,CAAC,GAEK,EACJ,QAAQ,GACR,WAAW,MACT,EACF;EACE,YAAY,EAAc;EAC1B,UAAS,MAAK;GAEZ,AADA,EAAgB,EAAE,MAAM,GACpB,EAAE,OAAO,SAAS,8BAA8B,IAElD,EAAgB,GAAG,EAAE,OAAO,GAAG,GAAiC,KAGhE,EAAE,OAAO,SAAS,oCAAoC,KAEtD,EAAE,OAAO,SAAS,mBAAmB,OAErC,QAAQ,KAAK,CAAC,GAEd,EACE,uDACF,GAEI,OAAO,SAAS,KAAK,SAAS,YAAY,KAC5C,OAAO,QAAQ,aACb,CAAC,GACD,SAAS,OAET,OAAO,SAAS,KAAK,WACnB,gCACA,EACF,CACF;EAGN;EACA,WAAW;CACb,CACF,GAEM,EACJ,QAAQ,GACR,WAAW,GACX,WAAW,MACT,EAAsB,EACxB,UAAS,MAAK;EACZ,EAAgB,EAAE,MAAM;CAC1B,EACF,CAAC,GAEK,KACH,GAAU,MAAa;EAKtB,AAJA,EAAgB,KAAA,CAAS,GAIzB,EAAmC;GACjC;GACA;GACA,uBAN4B,aAAa,QACzC,CAKA;EACF,CAAC;CACH;CAEF,SAAS,EACP,GACkD;EAQlD,OAPI,KAAsB,QAExB,EACE,iFACF,GACO,MAEF;CACT;CAgCA,OAAO;EACL;EACA,cAAc;EACd;EACuB,wBAjCvB,GACA,IAAU,MAAS,kBAAkB,kBAAkB,WACpD;GAEH,AADA,EAAgB,KAAA,CAAS,GACrB,EAA0B,CAAkB,KAO9C,EAAuB;IALrB,QAAQ,EAAmB;IAC3B,YAAY,EAAmB;IAC/B,SAAS;IACA;GAEY,CAAO;EAElC;EAqBE;EACA,gBACE,KAAyC;EAC3C,0BArBA,MACG;GAEH,AADA,EAAgB,KAAA,CAAS,GACrB,EAA0B,CAAkB,KAM9C,EAAmB;IAJjB,QAAQ,EAAmB;IAC3B,YAAY,EAAmB;IACX;GAEH,CAAO;EAE9B;EAWE;EACA;CACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synapse-react-client",
3
- "version": "4.0.23",
3
+ "version": "4.0.25",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -120,10 +120,10 @@
120
120
  "xss": "^1.0.15",
121
121
  "zod": "^4.4.3",
122
122
  "@sage-bionetworks/markdown-it-container": "4.0.1",
123
+ "@sage-bionetworks/synapse-client": "0.0.18",
123
124
  "markdown-it-synapse": "2.0.0",
124
- "markdown-it-synapse-table": "1.0.8",
125
125
  "@sage-bionetworks/synapse-types": "0.0.7",
126
- "@sage-bionetworks/synapse-client": "0.0.18"
126
+ "markdown-it-synapse-table": "1.0.8"
127
127
  },
128
128
  "peerDependencies": {
129
129
  "@sage-bionetworks/aridhia-client": "*",