From d8e38b72197f396a1c5a8d46722dc7f1a696a428 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 27 Dec 2024 19:37:08 +0400 Subject: [PATCH 1/7] Invalidate only remote backend queries when user go online --- app/gui/src/dashboard/App.tsx | 2 +- app/gui/src/dashboard/services/LocalBackend.ts | 3 ++- app/gui/src/dashboard/services/RemoteBackend.ts | 4 +++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 46293fc1f2b4..4b3d2d9a4692 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -243,7 +243,7 @@ export default function App(props: AppProps) { const { mutate: executeBackgroundUpdate } = useMutation({ mutationKey: ['refetch-queries', { isOffline }], scope: { id: 'refetch-queries' }, - mutationFn: () => queryClient.refetchQueries({ type: 'all' }), + mutationFn: () => queryClient.refetchQueries({ type: 'all', queryKey: [RemoteBackend.type] }), networkMode: 'online', onError: () => { toastify.toast.error(getText('refetchQueriesError'), { diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index ea3099b657d6..73c3cc679b08 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -100,7 +100,8 @@ export function extractTypeAndId(id: Id): AssetTypeA * This is used instead of the cloud backend API when managing local projects from the dashboard. */ export default class LocalBackend extends Backend { - readonly type = backend.BackendType.local + static readonly type = backend.BackendType.local + readonly type = LocalBackend.type /** All files that have been uploaded to the Project Manager. */ uploadedFiles: Map = new Map() private readonly projectManager: ProjectManager diff --git a/app/gui/src/dashboard/services/RemoteBackend.ts b/app/gui/src/dashboard/services/RemoteBackend.ts index 464530dc5a77..2c1a257b8fc4 100644 --- a/app/gui/src/dashboard/services/RemoteBackend.ts +++ b/app/gui/src/dashboard/services/RemoteBackend.ts @@ -110,7 +110,9 @@ interface RemoteBackendPostOptions { /** Class for sending requests to the Cloud backend API endpoints. */ export default class RemoteBackend extends Backend { - readonly type = backend.BackendType.remote + static readonly type = backend.BackendType.remote + + readonly type = RemoteBackend.type private user: object.Mutable | null = null /** From 3c58924051d6ff4f994479178f82e0c954fe390d Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Mon, 30 Dec 2024 17:57:14 +0400 Subject: [PATCH 2/7] Fix logging out after going online --- app/common/src/queryClient.ts | 2 + app/gui/src/dashboard/App.tsx | 18 +- .../dashboard/authentication/cognito.mock.ts | 8 +- .../src/dashboard/authentication/cognito.ts | 78 +++++- app/gui/src/dashboard/layouts/InfoMenu.tsx | 5 +- .../DeleteUserAccountSettingsSection.tsx | 3 +- .../src/dashboard/layouts/Settings/index.tsx | 5 +- app/gui/src/dashboard/layouts/UserMenu.tsx | 5 +- .../authentication/ConfirmRegistration.tsx | 6 +- .../pages/authentication/ForgotPassword.tsx | 15 +- .../dashboard/pages/authentication/Login.tsx | 6 +- .../pages/authentication/Registration.tsx | 4 +- .../pages/authentication/ResetPassword.tsx | 8 +- .../pages/authentication/RestoreAccount.tsx | 26 +- .../src/dashboard/providers/AuthProvider.tsx | 228 ++-------------- .../dashboard/providers/SessionProvider.tsx | 247 ++++++++++++++++-- .../__test__/SessionProvider.test.tsx | 99 ++++--- 17 files changed, 441 insertions(+), 322 deletions(-) diff --git a/app/common/src/queryClient.ts b/app/common/src/queryClient.ts index 4ad6802a177a..9268c4c8ecf7 100644 --- a/app/common/src/queryClient.ts +++ b/app/common/src/queryClient.ts @@ -99,6 +99,7 @@ export function createQueryClient( onSuccess: (_data, _variables, _context, mutation) => { const shouldAwaitInvalidates = mutation.meta?.awaitInvalidates ?? false const invalidates = mutation.meta?.invalidates ?? [] + const invalidatesToAwait = (() => { if (Array.isArray(shouldAwaitInvalidates)) { return shouldAwaitInvalidates @@ -106,6 +107,7 @@ export function createQueryClient( return shouldAwaitInvalidates ? invalidates : [] } })() + const invalidatesToIgnore = invalidates.filter( queryKey => !invalidatesToAwait.includes(queryKey), ) diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 4b3d2d9a4692..8d165ed9b32c 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -305,8 +305,9 @@ export interface AppRouterProps extends AppProps { * component as the component that defines the provider. */ function AppRouter(props: AppRouterProps) { - const { isAuthenticationDisabled, shouldShowDashboard } = props + const { shouldShowDashboard } = props const { onAuthenticated, projectManagerInstance } = props + const httpClient = useHttpClientStrict() const logger = useLogger() const navigate = router.useNavigate() @@ -409,8 +410,6 @@ function AppRouter(props: AppRouterProps) { const authService = useInitAuthService(props) - const userSession = authService.cognito.userSession.bind(authService.cognito) - const refreshUserSession = authService.cognito.refreshUserSession.bind(authService.cognito) const registerAuthEventListener = authService.registerAuthEventListener React.useEffect(() => { @@ -543,18 +542,15 @@ function AppRouter(props: AppRouterProps) { return ( { + localStorage.clearUserSpecificEntries() + }} + authService={authService.cognito} mainPageUrl={mainPageUrl} - userSession={userSession} registerAuthEventListener={registerAuthEventListener} - refreshUserSession={refreshUserSession} > - + {/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here * due to modals being in `TheModal`. */} diff --git a/app/gui/src/dashboard/authentication/cognito.mock.ts b/app/gui/src/dashboard/authentication/cognito.mock.ts index fef09a482f3d..0e47b0549487 100644 --- a/app/gui/src/dashboard/authentication/cognito.mock.ts +++ b/app/gui/src/dashboard/authentication/cognito.mock.ts @@ -35,10 +35,6 @@ import type * as amplify from '@aws-amplify/auth' import type * as cognito from 'amazon-cognito-identity-js' import * as results from 'ts-results' -import type * as loggerProvider from '#/providers/LoggerProvider' - -import type * as service from '#/authentication/service' - import * as original from './cognito.js' import * as listen from './listen.mock.js' @@ -75,9 +71,9 @@ export class Cognito { /** Create a new Cognito wrapper. */ constructor( - private readonly logger: loggerProvider.Logger, + private readonly logger: null, private readonly supportsDeepLinks: boolean, - private readonly amplifyConfig: service.AmplifyConfig, + private readonly amplifyConfig: null, ) { const username = localStorage.getItem(MOCK_EMAIL_KEY) if (username != null) { diff --git a/app/gui/src/dashboard/authentication/cognito.ts b/app/gui/src/dashboard/authentication/cognito.ts index 3a5daf7a45ff..32e18fa062d7 100644 --- a/app/gui/src/dashboard/authentication/cognito.ts +++ b/app/gui/src/dashboard/authentication/cognito.ts @@ -74,8 +74,12 @@ interface UserAttributes { } /* eslint-enable @typescript-eslint/naming-convention */ -/** The type of multi-factor authentication (MFA) that the user has set up. */ -export type MfaType = 'NOMFA' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'TOTP' +/** The type of multi-factor authentication (MFA) including non-specified MFA */ +export type MfaType = MfaProtectionTypes | 'NOMFA' +/** + * MFA protection types that the user can set up. + */ +export type MfaProtectionTypes = 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' /** * The type of challenge that the user is currently facing after signing in. @@ -184,6 +188,70 @@ interface CognitoError { readonly message: string } +/** + * Return type for Confirm sign up endpoint + */ +export type ConfirmSignInReturn = Promise | results.Ok> + +/** + * Interface that represents Auth Provider API + * Currently, it's tightly coupled with Cognito, but in the future, it should be decoupled from + * Cognito and be able to be used with other Auth Providers. + * + * Currently used in unit tests to mock the Auth Provider API + */ +export interface ISessionProvider { + readonly userSession: () => Promise + readonly organizationId: () => Promise + readonly email: () => Promise + readonly signUp: ( + username: string, + password: string, + organizationId: string | null, + ) => Promise | results.Ok> + readonly confirmSignUp: ( + email: string, + code: string, + ) => Promise | results.Ok> + readonly signInWithGoogle: () => Promise + readonly signInWithGitHub: () => Promise + readonly signInWithPassword: ( + username: string, + password: string, + ) => Promise | results.Ok> + readonly refreshUserSession: () => Promise + readonly signOut: () => Promise + readonly forgotPassword: ( + email: string, + ) => Promise | results.Ok> + readonly forgotPasswordSubmit: ( + email: string, + code: string, + password: string, + ) => Promise | results.Ok> + readonly changePassword: ( + oldPassword: string, + newPassword: string, + ) => Promise | results.Ok> + readonly setupTOTP: () => Promise | results.Ok> + readonly verifyTotpSetup: ( + totpToken: string, + ) => Promise | results.Ok> + readonly updateMFAPreference: ( + mfaMethod: MfaType, + ) => Promise | results.Ok> + readonly getMFAPreference: () => Promise | results.Ok> + readonly verifyTotpToken: ( + totpToken: string, + ) => Promise | results.Ok> + readonly saveAccessToken: (accessTokenPayload: saveAccessToken.AccessToken | null) => void + readonly confirmSignIn: ( + user: cognito.CognitoUser, + otp: string, + mfaType: MfaProtectionTypes, + ) => Promise | results.Ok> +} + // =============== // === Cognito === // =============== @@ -193,7 +261,7 @@ interface CognitoError { * This way, the methods don't throw all errors, but define exactly which errors they return. * The caller can then handle them via pattern matching on the {@link results.Result} type. */ -export class Cognito { +export class Cognito implements ISessionProvider { /** Create a new Cognito wrapper. */ constructor( private readonly logger: loggerProvider.Logger, @@ -514,8 +582,8 @@ export class Cognito { async confirmSignIn( user: amplify.CognitoUser, confirmationCode: string, - mfaType: 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA', - ) { + mfaType: MfaProtectionTypes, + ): ConfirmSignInReturn { const result = await results.Result.wrapAsync(() => amplify.Auth.confirmSignIn(user, confirmationCode, mfaType), ) diff --git a/app/gui/src/dashboard/layouts/InfoMenu.tsx b/app/gui/src/dashboard/layouts/InfoMenu.tsx index 05b39b9db017..8f5268554c32 100644 --- a/app/gui/src/dashboard/layouts/InfoMenu.tsx +++ b/app/gui/src/dashboard/layouts/InfoMenu.tsx @@ -9,6 +9,7 @@ import SvgMask from '#/components/SvgMask' import AboutModal from '#/modals/AboutModal' import { useAuth } from '#/providers/AuthProvider' import { useSetModal } from '#/providers/ModalProvider' +import { useSessionAPI } from '#/providers/SessionProvider.tsx' import { useText } from '#/providers/TextProvider' // ================ @@ -23,7 +24,9 @@ export interface InfoMenuProps { /** A menu containing info about the app. */ export default function InfoMenu(props: InfoMenuProps) { const { hidden = false } = props - const { signOut, session } = useAuth() + + const { signOut } = useSessionAPI() + const { session } = useAuth() const { setModal } = useSetModal() const { getText } = useText() diff --git a/app/gui/src/dashboard/layouts/Settings/DeleteUserAccountSettingsSection.tsx b/app/gui/src/dashboard/layouts/Settings/DeleteUserAccountSettingsSection.tsx index 11cda7307c4a..a32c57ab64d6 100644 --- a/app/gui/src/dashboard/layouts/Settings/DeleteUserAccountSettingsSection.tsx +++ b/app/gui/src/dashboard/layouts/Settings/DeleteUserAccountSettingsSection.tsx @@ -16,7 +16,7 @@ import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal' /** Settings tab for deleting the current user. */ export default function DeleteUserAccountSettingsSection() { - const { signOut, deleteUser } = authProvider.useAuth() + const { deleteUser } = authProvider.useAuth() const { getText } = textProvider.useText() return ( @@ -37,7 +37,6 @@ export default function DeleteUserAccountSettingsSection() { { await deleteUser() - await signOut() }} /> diff --git a/app/gui/src/dashboard/layouts/Settings/index.tsx b/app/gui/src/dashboard/layouts/Settings/index.tsx index 32650c6e68a5..a4bc9e0dcdd2 100644 --- a/app/gui/src/dashboard/layouts/Settings/index.tsx +++ b/app/gui/src/dashboard/layouts/Settings/index.tsx @@ -12,9 +12,10 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useSearchParamsState } from '#/hooks/searchParamsStateHooks' import { useToastAndLog } from '#/hooks/toastAndLogHooks' import SearchBar from '#/layouts/SearchBar' -import { useAuth, useFullUserSession } from '#/providers/AuthProvider' +import { useFullUserSession } from '#/providers/AuthProvider' import { useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider' import { useLocalStorageState } from '#/providers/LocalStorageProvider' +import { useSessionAPI } from '#/providers/SessionProvider' import { useText } from '#/providers/TextProvider' import type Backend from '#/services/Backend' import { Path } from '#/services/ProjectManager' @@ -53,7 +54,7 @@ export default function Settings() { includesPredicate(Object.values(SettingsTabType)), ) const { user, accessToken } = useFullUserSession() - const { changePassword } = useAuth() + const { changePassword } = useSessionAPI() const { getText } = useText() const toastAndLog = useToastAndLog() const [query, setQuery] = React.useState('') diff --git a/app/gui/src/dashboard/layouts/UserMenu.tsx b/app/gui/src/dashboard/layouts/UserMenu.tsx index 1057a0d00226..d2c723f915c7 100644 --- a/app/gui/src/dashboard/layouts/UserMenu.tsx +++ b/app/gui/src/dashboard/layouts/UserMenu.tsx @@ -5,13 +5,14 @@ import MenuEntry from '#/components/MenuEntry' import FocusArea from '#/components/styled/FocusArea' import { useToastAndLog } from '#/hooks/toastAndLogHooks' import AboutModal from '#/modals/AboutModal' -import { useAuth, useFullUserSession } from '#/providers/AuthProvider' +import { useFullUserSession } from '#/providers/AuthProvider' import { useLocalBackend } from '#/providers/BackendProvider' import { useSetModal } from '#/providers/ModalProvider' import { useText } from '#/providers/TextProvider' import { Plan } from '#/services/Backend' import { download } from '#/utilities/download' import { getDownloadUrl } from '#/utilities/github' +import { useSessionAPI } from '../providers/SessionProvider' /** Props for a {@link UserMenu}. */ export interface UserMenuProps { @@ -26,7 +27,7 @@ export default function UserMenu(props: UserMenuProps) { const { hidden = false, goToSettingsPage, onSignOut } = props const localBackend = useLocalBackend() - const { signOut } = useAuth() + const { signOut } = useSessionAPI() const { user } = useFullUserSession() const { setModal, unsetModal } = useSetModal() const { getText } = useText() diff --git a/app/gui/src/dashboard/pages/authentication/ConfirmRegistration.tsx b/app/gui/src/dashboard/pages/authentication/ConfirmRegistration.tsx index c1b2b3b45342..d63ee7d1368a 100644 --- a/app/gui/src/dashboard/pages/authentication/ConfirmRegistration.tsx +++ b/app/gui/src/dashboard/pages/authentication/ConfirmRegistration.tsx @@ -11,10 +11,10 @@ import { Result } from '#/components/Result' import { Button, ButtonGroup } from '#/components/AriaComponents' import { useMounted } from '#/hooks/mountHooks' -import * as authProvider from '#/providers/AuthProvider' import { useText } from '#/providers/TextProvider' import { unsafeWriteValue } from '#/utilities/write' import { useMutation } from '@tanstack/react-query' +import { useSessionAPI } from '../../providers/SessionProvider' import AuthenticationPage from './AuthenticationPage' // =========================== @@ -23,7 +23,7 @@ import AuthenticationPage from './AuthenticationPage' /** An empty component redirecting users based on the backend response to user registration. */ export default function ConfirmRegistration() { - const auth = authProvider.useAuth() + const { confirmSignUp } = useSessionAPI() const { getText } = useText() const navigate = router.useNavigate() @@ -36,7 +36,7 @@ export default function ConfirmRegistration() { const confirmRegistrationMutation = useMutation({ mutationKey: ['confirmRegistration'], mutationFn: (params: { email: string; verificationCode: string }) => - auth.confirmSignUp(params.email, params.verificationCode), + confirmSignUp(params.email, params.verificationCode), onSuccess: () => { if (redirectUrl != null) { unsafeWriteValue(window.location, 'href', redirectUrl) diff --git a/app/gui/src/dashboard/pages/authentication/ForgotPassword.tsx b/app/gui/src/dashboard/pages/authentication/ForgotPassword.tsx index b8878674679c..e403b85a130b 100644 --- a/app/gui/src/dashboard/pages/authentication/ForgotPassword.tsx +++ b/app/gui/src/dashboard/pages/authentication/ForgotPassword.tsx @@ -14,10 +14,10 @@ import GoBackIcon from '#/assets/go_back.svg' import { Form, Input } from '#/components/AriaComponents' import Link from '#/components/Link' import AuthenticationPage from '#/pages/authentication/AuthenticationPage' -import { useAuth } from '#/providers/AuthProvider' import { useLocalBackend } from '#/providers/BackendProvider' +import { useSessionAPI } from '#/providers/SessionProvider' import { type GetText, useText } from '#/providers/TextProvider' -import { useLocation } from 'react-router' +import { useLocation, useNavigate } from 'react-router' /** Create the schema for this form. */ function createForgotPasswordFormSchema(getText: GetText) { @@ -32,9 +32,12 @@ function createForgotPasswordFormSchema(getText: GetText) { /** A form for users to request for their password to be reset. */ export default function ForgotPassword() { - const { forgotPassword } = useAuth() + const { forgotPassword } = useSessionAPI() const location = useLocation() const { getText } = useText() + + const navigate = useNavigate() + const localBackend = useLocalBackend() const supportsOffline = localBackend != null @@ -54,7 +57,11 @@ export default function ForgotPassword() { /> } supportsOffline={supportsOffline} - onSubmit={({ email }) => forgotPassword(email)} + onSubmit={({ email }) => { + return forgotPassword(email).then(() => { + navigate(LOGIN_PATH) + }) + }} > @@ -35,7 +35,7 @@ const GITHUB_ICON = export default function Login() { const location = router.useLocation() const navigate = router.useNavigate() - const { signInWithGoogle, signInWithGitHub, signInWithPassword, cognito } = useAuth() + const { signInWithGoogle, signInWithGitHub, signInWithPassword, confirmSignIn } = useSessionAPI() const { getText } = useText() const query = new URLSearchParams(location.search) @@ -177,7 +177,7 @@ export default function Login() { schema={(z) => z.object({ otp: z.string().min(6).max(6) })} onSubmit={async ({ otp }, formInstance) => { if (user) { - const res = await cognito.confirmSignIn(user, otp, 'SOFTWARE_TOKEN_MFA') + const res = await confirmSignIn(user, otp) if (res.ok) { navigate(DASHBOARD_PATH) diff --git a/app/gui/src/dashboard/pages/authentication/Registration.tsx b/app/gui/src/dashboard/pages/authentication/Registration.tsx index d22afbbb5e65..b0f9f528f2a6 100644 --- a/app/gui/src/dashboard/pages/authentication/Registration.tsx +++ b/app/gui/src/dashboard/pages/authentication/Registration.tsx @@ -19,12 +19,12 @@ import { } from '#/modals/AgreementsModal' import AuthenticationPage from '#/pages/authentication/AuthenticationPage' import { passwordWithPatternSchema } from '#/pages/authentication/schemas' -import { useAuth } from '#/providers/AuthProvider' import { useLocalBackend } from '#/providers/BackendProvider' import { useLocalStorage } from '#/providers/LocalStorageProvider' import { useText } from '#/providers/TextProvider' import LocalStorage from '#/utilities/LocalStorage' import { useSuspenseQuery } from '@tanstack/react-query' +import { useSessionAPI } from '../../providers/SessionProvider' // ============================ // === Global configuration === @@ -50,7 +50,7 @@ const CONFIRM_SIGN_IN_INTERVAL = 5_000 /** A form for users to register an account. */ export default function Registration() { - const { signUp, confirmSignUp, signInWithPassword } = useAuth() + const { signUp, confirmSignUp, signInWithPassword } = useSessionAPI() const location = useLocation() const { localStorage } = useLocalStorage() diff --git a/app/gui/src/dashboard/pages/authentication/ResetPassword.tsx b/app/gui/src/dashboard/pages/authentication/ResetPassword.tsx index d1781d8932b9..de3f4d9faea5 100644 --- a/app/gui/src/dashboard/pages/authentication/ResetPassword.tsx +++ b/app/gui/src/dashboard/pages/authentication/ResetPassword.tsx @@ -17,10 +17,10 @@ import Link from '#/components/Link' import { useToastAndLog } from '#/hooks/toastAndLogHooks' import AuthenticationPage from '#/pages/authentication/AuthenticationPage' import { passwordWithPatternSchema } from '#/pages/authentication/schemas' -import { useAuth } from '#/providers/AuthProvider' import { useLocalBackend } from '#/providers/BackendProvider' import { type GetText, useText } from '#/providers/TextProvider' import { PASSWORD_REGEX } from '#/utilities/validation' +import { useSessionAPI } from '../../providers/SessionProvider' /** Create the schema for this form. */ function createResetPasswordFormSchema(getText: GetText) { @@ -51,7 +51,7 @@ function createResetPasswordFormSchema(getText: GetText) { /** A form for users to reset their password. */ export default function ResetPassword() { - const { resetPassword } = useAuth() + const { resetPassword } = useSessionAPI() const { getText } = useText() const location = router.useLocation() const navigate = router.useNavigate() @@ -86,7 +86,9 @@ export default function ResetPassword() { /> } onSubmit={({ email, verificationCode, newPassword }) => - resetPassword(email, verificationCode, newPassword) + resetPassword(email, verificationCode, newPassword).then(() => { + navigate(LOGIN_PATH) + }) } > { + navigate(LOGIN_PATH) + }, + }) const restoreAccountMutation = reactQuery.useMutation({ mutationFn: () => restoreUser(), }) @@ -37,14 +47,15 @@ export default function RestoreAccount() { {getText('restoreAccount')} +

{getText('restoreAccountDescription')}

{ - restoreAccountMutation.mutate() + onPress={async () => { + await restoreAccountMutation.mutateAsync() }} loading={restoreAccountMutation.isPending} isDisabled={restoreAccountMutation.isPending} @@ -53,12 +64,13 @@ export default function RestoreAccount() { > {getText('restoreAccountSubmit')} + { - signOutMutation.mutate() + onPress={async () => { + await signOutMutation.mutateAsync() }} > {getText('signOutShortcut')} diff --git a/app/gui/src/dashboard/providers/AuthProvider.tsx b/app/gui/src/dashboard/providers/AuthProvider.tsx index 52904c585e2c..c74f09eb5fb7 100644 --- a/app/gui/src/dashboard/providers/AuthProvider.tsx +++ b/app/gui/src/dashboard/providers/AuthProvider.tsx @@ -23,21 +23,13 @@ import * as gtagHooks from '#/hooks/gtagHooks' import * as backendProvider from '#/providers/BackendProvider' import * as localStorageProvider from '#/providers/LocalStorageProvider' -import * as modalProvider from '#/providers/ModalProvider' import * as sessionProvider from '#/providers/SessionProvider' import * as textProvider from '#/providers/TextProvider' -import * as ariaComponents from '#/components/AriaComponents' -import * as resultComponent from '#/components/Result' - import * as backendModule from '#/services/Backend' import type RemoteBackend from '#/services/RemoteBackend' -import * as errorModule from '#/utilities/error' - -import * as cognitoModule from '#/authentication/cognito' -import type * as authServiceModule from '#/authentication/service' -import { unsafeWriteValue } from '#/utilities/write' +import type * as cognitoModule from '#/authentication/cognito' // =================== // === UserSession === @@ -95,23 +87,8 @@ export type UserSession = FullUserSession | PartialUserSession * See `Cognito` for details on each of the authentication functions. */ interface AuthContextType { - readonly signUp: (email: string, password: string, organizationId: string | null) => Promise readonly authQueryKey: reactQuery.QueryKey - readonly confirmSignUp: (email: string, code: string) => Promise readonly setUsername: (username: string) => Promise - readonly signInWithGoogle: () => Promise - readonly signInWithGitHub: () => Promise - readonly signInWithPassword: ( - email: string, - password: string, - ) => Promise<{ - readonly challenge: cognitoModule.UserSessionChallenge - readonly user: cognitoModule.CognitoUser - }> - readonly forgotPassword: (email: string) => Promise - readonly changePassword: (oldPassword: string, newPassword: string) => Promise - readonly resetPassword: (email: string, code: string, password: string) => Promise - readonly signOut: () => Promise /** @deprecated Never use this function. Prefer particular functions like `setUsername` or `deleteUser`. */ readonly setUser: (user: Partial) => void readonly deleteUser: () => Promise @@ -131,7 +108,6 @@ interface AuthContextType { readonly isUserDeleted: () => boolean /** Return `true` if the user is soft deleted. */ readonly isUserSoftDeleted: () => boolean - readonly cognito: cognitoModule.Cognito } const AuthContext = React.createContext(null) @@ -144,7 +120,6 @@ const AuthContext = React.createContext(null) function createUsersMeQuery( session: cognitoModule.UserSession | null, remoteBackend: RemoteBackend, - performLogout: () => Promise, ) { return reactQuery.queryOptions({ queryKey: [remoteBackend.type, 'usersMe', session?.clientId] as const, @@ -153,28 +128,17 @@ function createUsersMeQuery( return Promise.resolve(null) } - return remoteBackend - .usersMe() - .then((user) => { - return user == null ? - ({ type: UserSessionType.partial, ...session } satisfies PartialUserSession) - : ({ type: UserSessionType.full, user, ...session } satisfies FullUserSession) - }) - .catch((error) => { - if (error instanceof backendModule.NotAuthorizedError) { - return performLogout().then(() => null) - } - - throw error - }) + return remoteBackend.usersMe().then((user) => { + return user == null ? + ({ type: UserSessionType.partial, ...session } satisfies PartialUserSession) + : ({ type: UserSessionType.full, user, ...session } satisfies FullUserSession) + }) }, }) } /** Props for an {@link AuthProvider}. */ export interface AuthProviderProps { - readonly shouldStartInOfflineMode: boolean - readonly authService: authServiceModule.AuthService /** Callback to execute once the user has authenticated successfully. */ readonly onAuthenticated: (accessToken: string | null) => void readonly children: React.ReactNode @@ -182,15 +146,11 @@ export interface AuthProviderProps { /** A React provider for the Cognito API. */ export default function AuthProvider(props: AuthProviderProps) { - const { authService, onAuthenticated } = props - const { children } = props + const { onAuthenticated, children } = props + const remoteBackend = backendProvider.useRemoteBackend() - const { cognito } = authService - const { session, sessionQueryKey } = sessionProvider.useSession() - const { localStorage } = localStorageProvider.useLocalStorage() + const { session, organizationId, signOut } = sessionProvider.useSession() const { getText } = textProvider.useText() - const { unsetModal } = modalProvider.useSetModal() - const navigate = router.useNavigate() const toastId = React.useId() const queryClient = reactQuery.useQueryClient() @@ -201,36 +161,7 @@ export default function AuthProvider(props: AuthProviderProps) { gtag.event(name, params) }, []) - const performLogout = useEventCallback(async () => { - await cognito.signOut() - - const parentDomain = location.hostname.replace(/^[^.]*\./, '') - unsafeWriteValue(document, 'cookie', `logged_in=no;max-age=0;domain=${parentDomain}`) - gtagEvent('cloud_sign_out') - cognito.saveAccessToken(null) - localStorage.clearUserSpecificEntries() - sentry.setUser(null) - - await queryClient.invalidateQueries({ queryKey: sessionQueryKey }) - await queryClient.clearWithPersister() - - return Promise.resolve() - }) - - const logoutMutation = reactQuery.useMutation({ - mutationKey: [remoteBackend.type, 'usersMe', 'logout', session?.clientId] as const, - mutationFn: performLogout, - // If the User Menu is still visible, it breaks when `userSession` is set to `null`. - onMutate: unsetModal, - onSuccess: () => toast.toast.success(getText('signOutSuccess')), - onError: () => toast.toast.error(getText('signOutError')), - meta: { invalidates: [sessionQueryKey], awaitInvalidates: true }, - }) - - const usersMeQueryOptions = createUsersMeQuery(session, remoteBackend, async () => { - await performLogout() - toast.toast.info(getText('userNotAuthorizedError')) - }) + const usersMeQueryOptions = createUsersMeQuery(session, remoteBackend) const usersMeQuery = reactQuery.useSuspenseQuery(usersMeQueryOptions) const userData = usersMeQuery.data @@ -267,58 +198,6 @@ export default function AuthProvider(props: AuthProviderProps) { }) } - const signUp = useEventCallback( - async (username: string, password: string, organizationId: string | null) => { - gtagEvent('cloud_sign_up') - const result = await cognito.signUp(username, password, organizationId) - - if (result.err) { - throw new Error(result.val.message) - } else { - return - } - }, - ) - - const confirmSignUp = useEventCallback(async (email: string, code: string) => { - gtagEvent('cloud_confirm_sign_up') - const result = await cognito.confirmSignUp(email, code) - - if (result.err) { - switch (result.val.type) { - case cognitoModule.CognitoErrorType.userAlreadyConfirmed: - case cognitoModule.CognitoErrorType.userNotFound: { - return - } - default: { - throw new errorModule.UnreachableCaseError(result.val.type) - } - } - } - }) - - const signInWithPassword = useEventCallback(async (email: string, password: string) => { - gtagEvent('cloud_sign_in', { provider: 'Email' }) - - const result = await cognito.signInWithPassword(email, password) - - if (result.ok) { - const user = result.unwrap() - - const challenge = user.challengeName ?? 'NO_CHALLENGE' - - if (['SMS_MFA', 'SOFTWARE_TOKEN_MFA'].includes(challenge)) { - return { challenge, user } as const - } - - return queryClient - .invalidateQueries({ queryKey: sessionQueryKey }) - .then(() => ({ challenge, user }) as const) - } else { - throw new Error(result.val.message) - } - }) - const refetchSession = usersMeQuery.refetch const setUsername = useEventCallback(async (username: string) => { @@ -327,14 +206,13 @@ export default function AuthProvider(props: AuthProviderProps) { if (userData?.type === UserSessionType.full) { await updateUserMutation.mutateAsync({ username }) } else { - const organizationId = await cognito.organizationId() + const orgId = await organizationId() const email = session?.email ?? '' await createUserMutation.mutateAsync({ userName: username, userEmail: backendModule.EmailAddress(email), - organizationId: - organizationId != null ? backendModule.OrganizationId(organizationId) : null, + organizationId: orgId != null ? backendModule.OrganizationId(orgId) : null, }) } // Wait until the backend returns a value from `users/me`, @@ -349,6 +227,7 @@ export default function AuthProvider(props: AuthProviderProps) { const deleteUser = useEventCallback(async () => { await deleteUserMutation.mutateAsync() + await signOut() toastSuccess(getText('deleteUserSuccess')) @@ -379,37 +258,6 @@ export default function AuthProvider(props: AuthProviderProps) { } }) - const forgotPassword = useEventCallback(async (email: string) => { - const result = await cognito.forgotPassword(email) - if (result.ok) { - navigate(appUtils.LOGIN_PATH) - return - } else { - throw new Error(result.val.message) - } - }) - - const resetPassword = useEventCallback(async (email: string, code: string, password: string) => { - const result = await cognito.forgotPasswordSubmit(email, code, password) - - if (result.ok) { - navigate(appUtils.LOGIN_PATH) - return - } else { - throw new Error(result.val.message) - } - }) - - const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => { - const result = await cognito.changePassword(oldPassword, newPassword) - - if (result.err) { - throw new Error(result.val.message) - } - - return result.ok - }) - const isUserMarkedForDeletion = useEventCallback( () => !!(userData && 'user' in userData && userData.user.removeAt), ) @@ -466,63 +314,19 @@ export default function AuthProvider(props: AuthProviderProps) { }, [userData, onAuthenticated]) const value: AuthContextType = { - signUp, - confirmSignUp, + refetchSession, + session: userData, setUsername, isUserMarkedForDeletion, isUserDeleted, isUserSoftDeleted, restoreUser, deleteUser, - cognito, - signInWithGoogle: useEventCallback(() => { - gtagEvent('cloud_sign_in', { provider: 'Google' }) - - return cognito - .signInWithGoogle() - .then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey })) - .then( - () => true, - () => false, - ) - }), - signInWithGitHub: useEventCallback(() => { - gtagEvent('cloud_sign_in', { provider: 'GitHub' }) - - return cognito - .signInWithGitHub() - .then(() => queryClient.invalidateQueries({ queryKey: sessionQueryKey })) - .then( - () => true, - () => false, - ) - }), - signInWithPassword, - forgotPassword, - resetPassword, - changePassword, - refetchSession, - session: userData, - signOut: logoutMutation.mutateAsync, setUser, authQueryKey: usersMeQueryOptions.queryKey, } - return ( - - {children} - - - - - - ) + return {children} } // =============== diff --git a/app/gui/src/dashboard/providers/SessionProvider.tsx b/app/gui/src/dashboard/providers/SessionProvider.tsx index a3931a1c33e7..ef153e33120a 100644 --- a/app/gui/src/dashboard/providers/SessionProvider.tsx +++ b/app/gui/src/dashboard/providers/SessionProvider.tsx @@ -4,19 +4,33 @@ */ import * as React from 'react' +import * as sentry from '@sentry/react' import * as reactQuery from '@tanstack/react-query' import invariant from 'tiny-invariant' -import * as eventCallback from '#/hooks/eventCallbackHooks' - import * as httpClientProvider from '#/providers/HttpClientProvider' import * as errorModule from '#/utilities/error' import type * as cognito from '#/authentication/cognito' +import { + CognitoErrorType, + type CognitoUser, + type ConfirmSignInReturn, + type ISessionProvider, + type UserSessionChallenge, +} from '#/authentication/cognito' import * as listen from '#/authentication/listen' +import { Dialog } from '#/components/AriaComponents' +import { Result } from '#/components/Result' +import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useOffline } from '#/hooks/offlineHooks' import { useToastAndLog } from '#/hooks/toastAndLogHooks' +import { unsafeWriteValue } from '#/utilities/write' +import * as gtag from 'enso-common/src/gtag' +import { toast } from 'react-toastify' +import { useSetModal } from './ModalProvider' +import { useText } from './TextProvider' // ====================== // === SessionContext === @@ -25,7 +39,23 @@ import { useToastAndLog } from '#/hooks/toastAndLogHooks' /** State contained in a {@link SessionContext}. */ interface SessionContextType { readonly session: cognito.UserSession | null - readonly sessionQueryKey: reactQuery.QueryKey + readonly signUp: (email: string, password: string, organizationId: string | null) => Promise + readonly confirmSignUp: (email: string, code: string) => Promise + readonly signInWithGoogle: () => Promise + readonly signInWithGitHub: () => Promise + readonly signInWithPassword: ( + email: string, + password: string, + ) => Promise<{ + readonly challenge: UserSessionChallenge + readonly user: CognitoUser + }> + readonly confirmSignIn: (user: CognitoUser, otp: string) => ConfirmSignInReturn + readonly forgotPassword: (email: string) => Promise + readonly changePassword: (oldPassword: string, newPassword: string) => Promise + readonly resetPassword: (email: string, code: string, password: string) => Promise + readonly signOut: () => Promise + readonly organizationId: () => Promise } const SessionContext = React.createContext(null) @@ -51,47 +81,43 @@ export interface SessionProviderProps { */ readonly mainPageUrl: URL readonly registerAuthEventListener: listen.ListenFunction | null - readonly userSession: (() => Promise) | null - readonly saveAccessToken?: ((accessToken: cognito.UserSession) => void) | null - readonly refreshUserSession: (() => Promise) | null + readonly authService: ISessionProvider + readonly onLogout?: () => Promise | void + readonly children: React.ReactNode | ((props: SessionContextType) => React.ReactNode) } /** Create a query for the user session. */ -function createSessionQuery(userSession: (() => Promise) | null) { +function createSessionQuery(authService: ISessionProvider) { return reactQuery.queryOptions({ queryKey: ['userSession'], - queryFn: () => userSession?.().catch(() => null) ?? null, + queryFn: () => authService.userSession().catch(() => null), }) } /** A React provider for the session of the authenticated user. */ export default function SessionProvider(props: SessionProviderProps) { - const { - mainPageUrl, - children, - userSession, - registerAuthEventListener, - refreshUserSession, - saveAccessToken, - } = props + const { mainPageUrl, children, registerAuthEventListener, authService, onLogout } = props + + const { unsetModal } = useSetModal() + const { getText } = useText() // stabilize the callback so that it doesn't change on every render - const saveAccessTokenEventCallback = eventCallback.useEventCallback( - (accessToken: cognito.UserSession) => saveAccessToken?.(accessToken), - ) + const saveAccessTokenEventCallback = useEventCallback((accessToken: cognito.UserSession) => { + authService.saveAccessToken(accessToken) + }) const httpClient = httpClientProvider.useHttpClient() const queryClient = reactQuery.useQueryClient() const toastAndLog = useToastAndLog() - const sessionQuery = createSessionQuery(userSession) + const sessionQuery = createSessionQuery(authService) const session = reactQuery.useSuspenseQuery(sessionQuery) const refreshUserSessionMutation = reactQuery.useMutation({ mutationKey: ['refreshUserSession', { expireAt: session.data?.expireAt }], - mutationFn: async () => refreshUserSession?.() ?? null, + mutationFn: async () => authService.refreshUserSession(), onSuccess: (data) => { if (data) { httpClient?.setSessionToken(data.accessToken) @@ -102,7 +128,142 @@ export default function SessionProvider(props: SessionProviderProps) { // Something went wrong with the refresh token, so we need to sign the user out. toastAndLog('sessionExpiredError', error) queryClient.setQueryData(sessionQuery.queryKey, null) + return logoutMutation.mutateAsync() + }, + }) + + const logoutMutation = reactQuery.useMutation({ + mutationKey: ['session', 'logout', session.data?.clientId] as const, + mutationFn: async () => { + await authService.signOut() + + gtag.event('cloud_sign_out') + const parentDomain = location.hostname.replace(/^[^.]*\./, '') + unsafeWriteValue(document, 'cookie', `logged_in=no;max-age=0;domain=${parentDomain}`) + + authService.saveAccessToken(null) + + await queryClient.invalidateQueries({ queryKey: sessionQuery.queryKey }) + await queryClient.clearWithPersister() + }, + // If the User Menu is still visible, it breaks when `userSession` is set to `null`. + onMutate: unsetModal, + onSuccess: async () => { + await onLogout?.() + sentry.setUser(null) + + return toast.success(getText('signOutSuccess')) + }, + onError: () => toast.error(getText('signOutError')), + }) + + const signUp = useEventCallback( + async (username: string, password: string, organizationId: string | null) => { + gtag.event('cloud_sign_up') + const result = await authService.signUp(username, password, organizationId) + + if (result.err) { + throw new Error(result.val.message) + } else { + return + } }, + ) + + const confirmSignUp = useEventCallback(async (email: string, code: string) => { + gtag.event('cloud_confirm_sign_up') + const result = await authService.confirmSignUp(email, code) + + if (result.err) { + switch (result.val.type) { + case CognitoErrorType.userAlreadyConfirmed: + case CognitoErrorType.userNotFound: { + return + } + default: { + throw new errorModule.UnreachableCaseError(result.val.type) + } + } + } + }) + + const signInWithPassword = useEventCallback(async (email: string, password: string) => { + gtag.event('cloud_sign_in', { provider: 'Email' }) + + const result = await authService.signInWithPassword(email, password) + + if (result.ok) { + const user = result.unwrap() + + const challenge = user.challengeName ?? 'NO_CHALLENGE' + + if (['SMS_MFA', 'SOFTWARE_TOKEN_MFA'].includes(challenge)) { + return { challenge, user } as const + } + + return queryClient + .invalidateQueries({ queryKey: sessionQuery.queryKey }) + .then(() => ({ challenge, user }) as const) + } else { + throw new Error(result.val.message) + } + }) + + const signInWithGoogle = useEventCallback(() => { + gtag.event('cloud_sign_in', { provider: 'Google' }) + + return authService + .signInWithGoogle() + .then(() => queryClient.invalidateQueries({ queryKey: sessionQuery.queryKey })) + .then( + () => true, + () => false, + ) + }) + + const signInWithGitHub = useEventCallback(() => { + gtag.event('cloud_sign_in', { provider: 'GitHub' }) + + return authService + .signInWithGitHub() + .then(() => queryClient.invalidateQueries({ queryKey: sessionQuery.queryKey })) + .then( + () => true, + () => false, + ) + }) + + const confirmSignIn = useEventCallback((user: CognitoUser, otp: string) => + authService.confirmSignIn(user, otp, 'SOFTWARE_TOKEN_MFA'), + ) + + const forgotPassword = useEventCallback(async (email: string) => { + const result = await authService.forgotPassword(email) + if (result.ok) { + return null + } else { + throw new Error(result.val.message) + } + }) + + const resetPassword = useEventCallback(async (email: string, code: string, password: string) => { + const result = await authService.forgotPasswordSubmit(email, code, password) + + if (result.ok) { + return null + } else { + throw new Error(result.val.message) + } + }) + + const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => { + const result = await authService.changePassword(oldPassword, newPassword) + + if (result.err) { + throw new Error(result.val.message) + } + + return result.ok }) if (session.data) { @@ -142,6 +303,8 @@ export default function SessionProvider(props: SessionProviderProps) { [registerAuthEventListener, mainPageUrl, queryClient, sessionQuery.queryKey], ) + const organizationId = useEventCallback(() => authService.organizationId()) + React.useEffect(() => { if (session.data) { // Save access token so can it be reused by backend services @@ -150,12 +313,24 @@ export default function SessionProvider(props: SessionProviderProps) { }, [session.data, saveAccessTokenEventCallback]) const sessionContextValue = { + signUp, session: session.data, - sessionQueryKey: sessionQuery.queryKey, - } + confirmSignUp, + signInWithPassword, + signInWithGitHub, + signInWithGoogle, + confirmSignIn, + forgotPassword, + resetPassword, + changePassword, + signOut: logoutMutation.mutateAsync, + organizationId, + } satisfies SessionContextType return ( + {typeof children === 'function' ? children(sessionContextValue) : children} + {session.data && ( )} - {typeof children === 'function' ? children(sessionContextValue) : children} + + + ) } @@ -229,14 +412,24 @@ export function useSession() { return context } +/** + * Returns API to work with a session. + */ +export function useSessionAPI(): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { session, ...api } = useSession() + + return api +} + /** * React context hook returning the session of the authenticated user. - * @throws {invariant} if the session is not defined. + * @throws {Error} if the session is not defined. */ export function useSessionStrict() { - const { session, sessionQueryKey } = useSession() + const { session } = useSession() invariant(session != null, 'Session must be defined') - return { session, sessionQueryKey } as const + return { session } as const } diff --git a/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx index 6e4157c57da6..079354bf2104 100644 --- a/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx +++ b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx @@ -1,15 +1,26 @@ -import type { UserSession } from '#/authentication/cognito' +import type { + AmplifyError, + ConfirmSignUpError, + ForgotPasswordSubmitError, + ISessionProvider, + MfaType, + SignUpError, + UserSession, +} from '#/authentication/cognito' import { render, screen, waitFor } from '#/test' import { Rfc3339DateTime } from '#/utilities/dateTime' import HttpClient from '#/utilities/HttpClient' +import { uniqueString } from 'enso-common/src/utilities/uniqueString' import { Suspense } from 'react' +import { Result } from 'ts-results' import { beforeEach, describe, expect, it, vi } from 'vitest' import { HttpClientProvider } from '../HttpClientProvider' import SessionProvider from '../SessionProvider' -describe('SessionProvider', () => { - const mainPageUrl = new URL('https://enso.dev') - const userSession = vi.fn<[], Promise>(() => +class MockAuthService implements ISessionProvider { + saveAccessToken = vi.fn() + refreshUserSession = vi.fn(() => Promise.resolve(null)) + userSession = vi.fn<[], Promise>(() => Promise.resolve({ email: 'test@test.com', accessToken: 'accessToken', @@ -19,30 +30,60 @@ describe('SessionProvider', () => { clientId: 'clientId', }), ) - const refreshUserSession = vi.fn(() => Promise.resolve(null)) + email = vi.fn().mockReturnValue('example@email.com') + changePassword = vi.fn() + forgotPassword = vi.fn() + organizationId = vi.fn().mockReturnValue(`organization-${uniqueString()}`) + confirmSignIn = vi.fn() + confirmSignUp = vi.fn(() => Promise.resolve(Result.wrap(() => {}))) + forgotPasswordSubmit = vi.fn(() => + Promise.resolve(Result.wrap(() => {})), + ) + setupTOTP = vi.fn(() => + Promise.resolve( + Result.wrap<{ secret: string; url: string }, AmplifyError>(() => ({ + secret: 'secret', + url: 'url', + })), + ), + ) + getMFAPreference = vi.fn(() => + Promise.resolve(Result.wrap(() => 'NOMFA' as const)), + ) + signInWithGitHub = vi.fn(() => Promise.resolve()) + signInWithGoogle = vi.fn(() => Promise.resolve()) + signOut = vi.fn(() => Promise.resolve()) + signUp = vi.fn(() => Promise.resolve(Result.wrap(() => {}))) + updateMFAPreference = vi.fn() + signInWithPassword = vi.fn() + verifyTotpSetup = vi.fn() + verifyTotpToken = vi.fn() +} + +describe('SessionProvider', () => { + const mainPageUrl = new URL('https://enso.dev') const registerAuthEventListener = vi.fn() - const saveAccessToken = vi.fn() + + const authService = new MockAuthService() beforeEach(() => { vi.clearAllMocks() }) it('Should retrieve the user session', async () => { - const { getByText } = render( + const { getByText, debug } = render( Loading...
}>
Hello
, ) - expect(userSession).toBeCalled() + expect(authService.userSession).toBeCalled() expect(getByText(/Loading/)).toBeInTheDocument() await waitFor(() => { @@ -59,11 +100,9 @@ describe('SessionProvider', () => { Loading...}>
Hello
@@ -77,9 +116,9 @@ describe('SessionProvider', () => { }) it('Should refresh the expired user session', async () => { - userSession.mockReturnValueOnce( + authService.userSession.mockReturnValueOnce( Promise.resolve({ - ...(await userSession()), + ...(await authService.userSession()), // 24 hours from now expireAt: Rfc3339DateTime(new Date(Date.now() - 1).toJSON()), }), @@ -88,32 +127,30 @@ describe('SessionProvider', () => { render( Loading...}>
Hello
, ) - expect(refreshUserSession).not.toBeCalled() - expect(userSession).toBeCalledTimes(2) + expect(authService.refreshUserSession).not.toBeCalled() + expect(authService.userSession).toBeCalledTimes(2) await waitFor(() => { - expect(refreshUserSession).toBeCalledTimes(1) + expect(authService.refreshUserSession).toBeCalledTimes(1) expect(screen.getByText(/Hello/)).toBeInTheDocument() - expect(userSession).toBeCalledTimes(3) + expect(authService.userSession).toBeCalledTimes(3) }) }) it('Should refresh not stale user session', { timeout: 5_000 }, async () => { - userSession.mockReturnValueOnce( + authService.userSession.mockReturnValueOnce( Promise.resolve({ - ...(await userSession()), + ...(await authService.userSession()), expireAt: Rfc3339DateTime(new Date(Date.now() + 1_500).toJSON()), }), ) @@ -123,11 +160,9 @@ describe('SessionProvider', () => { render( Loading...}> {({ session: sessionFromContext }) => { session = sessionFromContext @@ -137,9 +172,11 @@ describe('SessionProvider', () => { , ) + vi.useFakeTimers().runAllTimers().useRealTimers() + await waitFor( () => { - expect(refreshUserSession).toBeCalledTimes(1) + expect(authService.refreshUserSession).toBeCalledTimes(1) expect(session).not.toBeNull() // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(new Date(session!.expireAt).getTime()).toBeGreaterThan(Date.now()) @@ -152,11 +189,9 @@ describe('SessionProvider', () => { render( Loading...}>
Hello
From 640dd117c51469a7bbaed4c4b4705e37c0bf61dd Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Mon, 30 Dec 2024 18:02:32 +0400 Subject: [PATCH 3/7] FIxes --- app/gui/src/dashboard/providers/AuthProvider.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/gui/src/dashboard/providers/AuthProvider.tsx b/app/gui/src/dashboard/providers/AuthProvider.tsx index f9d8a83f4ca3..a224909b8661 100644 --- a/app/gui/src/dashboard/providers/AuthProvider.tsx +++ b/app/gui/src/dashboard/providers/AuthProvider.tsx @@ -210,10 +210,7 @@ export default function AuthProvider(props: AuthProviderProps) { const orgId = await organizationId() const email = session?.email ?? '' - invariant( - orgId == null || isOrganizationId(orgId), - 'Invalid organization ID', - ) + invariant(orgId == null || isOrganizationId(orgId), 'Invalid organization ID') await createUserMutation.mutateAsync({ userName: username, From 8b6e2db45985440fa68422881f1129886c651174 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Mon, 30 Dec 2024 19:02:13 +0400 Subject: [PATCH 4/7] Improve types --- .../src/dashboard/authentication/cognito.ts | 36 ++++++++++----- .../layouts/Settings/SetupTwoFaForm.tsx | 45 +++++-------------- .../dashboard/providers/SessionProvider.tsx | 41 +++++++++++++++++ 3 files changed, 78 insertions(+), 44 deletions(-) diff --git a/app/gui/src/dashboard/authentication/cognito.ts b/app/gui/src/dashboard/authentication/cognito.ts index 32e18fa062d7..266f01eb66fc 100644 --- a/app/gui/src/dashboard/authentication/cognito.ts +++ b/app/gui/src/dashboard/authentication/cognito.ts @@ -75,7 +75,7 @@ interface UserAttributes { /* eslint-enable @typescript-eslint/naming-convention */ /** The type of multi-factor authentication (MFA) including non-specified MFA */ -export type MfaType = MfaProtectionTypes | 'NOMFA' +export type MfaType = MfaProtectionTypes | 'NOMFA' | 'TOTP' /** * MFA protection types that the user can set up. */ @@ -191,7 +191,19 @@ interface CognitoError { /** * Return type for Confirm sign up endpoint */ -export type ConfirmSignInReturn = Promise | results.Ok> +export type ConfirmSignInReturn = Promise< + results.Err | results.Ok +> + +/** + * Return type for Setup TOTP endpoint + */ +export interface SetupTOTPReturn { + /** The URL to scan the QR code */ + readonly url: string + /** The secret to use for the TOTP */ + readonly secret: string +} /** * Interface that represents Auth Provider API @@ -233,23 +245,23 @@ export interface ISessionProvider { oldPassword: string, newPassword: string, ) => Promise | results.Ok> - readonly setupTOTP: () => Promise | results.Ok> + readonly setupTOTP: () => Promise | results.Ok> readonly verifyTotpSetup: ( totpToken: string, ) => Promise | results.Ok> readonly updateMFAPreference: ( mfaMethod: MfaType, - ) => Promise | results.Ok> - readonly getMFAPreference: () => Promise | results.Ok> + ) => Promise | results.Ok> + readonly getMFAPreference: () => Promise | results.Ok> readonly verifyTotpToken: ( totpToken: string, - ) => Promise | results.Ok> + ) => Promise | results.Ok> readonly saveAccessToken: (accessTokenPayload: saveAccessToken.AccessToken | null) => void readonly confirmSignIn: ( user: cognito.CognitoUser, otp: string, mfaType: MfaProtectionTypes, - ) => Promise | results.Ok> + ) => ConfirmSignInReturn } // =============== @@ -536,9 +548,9 @@ export class Cognito implements ISessionProvider { const cognitoUserResult = await currentAuthenticatedUser() if (cognitoUserResult.ok) { const cognitoUser = cognitoUserResult.unwrap() - const result = await results.Result.wrapAsync( - async () => await amplify.Auth.setPreferredMFA(cognitoUser, mfaMethod), - ) + const result = await results.Result.wrapAsync(async () => { + await amplify.Auth.setPreferredMFA(cognitoUser, mfaMethod) + }) return result.mapErr(intoAmplifyErrorOrThrow) } else { return results.Err(cognitoUserResult.val) @@ -571,7 +583,9 @@ export class Cognito implements ISessionProvider { const cognitoUser = cognitoUserResult.unwrap() return ( - await results.Result.wrapAsync(() => amplify.Auth.verifyTotpToken(cognitoUser, totpToken)) + await results.Result.wrapAsync(() => + amplify.Auth.verifyTotpToken(cognitoUser, totpToken).then(() => true), + ) ).mapErr(intoAmplifyErrorOrThrow) } else { return results.Err(cognitoUserResult.val) diff --git a/app/gui/src/dashboard/layouts/Settings/SetupTwoFaForm.tsx b/app/gui/src/dashboard/layouts/Settings/SetupTwoFaForm.tsx index acea41d0e431..ec41c330a437 100644 --- a/app/gui/src/dashboard/layouts/Settings/SetupTwoFaForm.tsx +++ b/app/gui/src/dashboard/layouts/Settings/SetupTwoFaForm.tsx @@ -22,7 +22,7 @@ import { } from '#/components/AriaComponents' import { ErrorBoundary } from '#/components/ErrorBoundary' import { Suspense } from '#/components/Suspense' -import { useAuth } from '#/providers/AuthProvider' +import { useSessionAPI } from '#/providers/SessionProvider' import { useText } from '#/providers/TextProvider' import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { lazy } from 'react' @@ -39,31 +39,17 @@ const LazyQRCode = lazy(() => */ export function SetupTwoFaForm() { const { getText } = useText() - const { cognito } = useAuth() + const { getMFAPreference, updateMFAPreference, verifyTotpToken } = useSessionAPI() const { data } = useSuspenseQuery({ queryKey: ['twoFaPreference'], - queryFn: () => - cognito.getMFAPreference().then((res) => { - if (res.err) { - throw res.val - } else { - return res.unwrap() - } - }), + queryFn: () => getMFAPreference(), }) const MFAEnabled = data !== 'NOMFA' const updateMFAPreferenceMutation = useMutation({ - mutationFn: (preference: MfaType) => - cognito.updateMFAPreference(preference).then((res) => { - if (res.err) { - throw res.val - } else { - return res.unwrap() - } - }), + mutationFn: (preference: MfaType) => updateMFAPreference(preference), meta: { invalidates: [['twoFaPreference']] }, }) @@ -100,11 +86,11 @@ export function SetupTwoFaForm() { formOptions={{ mode: 'onSubmit' }} method="dialog" onSubmit={({ otp }) => - cognito.verifyTotpToken(otp).then((res) => { - if (res.ok) { + verifyTotpToken(otp).then((passed) => { + if (passed) { return updateMFAPreferenceMutation.mutateAsync('NOMFA') } else { - throw res.val + throw new Error('Invalid OTP') } }) } @@ -139,11 +125,11 @@ export function SetupTwoFaForm() { defaultValues={{ enabled: false, display: 'QR' }} onSubmit={async ({ enabled, otp }) => { if (enabled) { - return cognito.verifyTotpToken(otp).then((res) => { - if (res.ok) { + return verifyTotpToken(otp).then((passed) => { + if (passed) { return updateMFAPreferenceMutation.mutateAsync('TOTP') } else { - throw res.val + throw new Error('Invalid OTP') } }) } @@ -170,19 +156,12 @@ export function SetupTwoFaForm() { /** Two Factor Authentication Setup Form. */ function TwoFa() { - const { cognito } = useAuth() + const { setupTOTP } = useSessionAPI() const { getText } = useText() const { data } = useSuspenseQuery({ queryKey: ['setupTOTP'], - queryFn: () => - cognito.setupTOTP().then((res) => { - if (res.err) { - throw res.val - } else { - return res.unwrap() - } - }), + queryFn: () => setupTOTP(), }) return ( diff --git a/app/gui/src/dashboard/providers/SessionProvider.tsx b/app/gui/src/dashboard/providers/SessionProvider.tsx index ef153e33120a..ffc059b540c9 100644 --- a/app/gui/src/dashboard/providers/SessionProvider.tsx +++ b/app/gui/src/dashboard/providers/SessionProvider.tsx @@ -56,6 +56,10 @@ interface SessionContextType { readonly resetPassword: (email: string, code: string, password: string) => Promise readonly signOut: () => Promise readonly organizationId: () => Promise + readonly getMFAPreference: () => Promise + readonly updateMFAPreference: (mfaType: cognito.MfaType) => Promise + readonly verifyTotpToken: (otp: string) => Promise + readonly setupTOTP: () => Promise } const SessionContext = React.createContext(null) @@ -305,6 +309,39 @@ export default function SessionProvider(props: SessionProviderProps) { const organizationId = useEventCallback(() => authService.organizationId()) + const getMFAPreference = useEventCallback(async () => { + const result = await authService.getMFAPreference() + if (result.err) { + throw result.val + } else { + return result.unwrap() + } + }) + + const updateMFAPreference = useEventCallback(async (mfaType: cognito.MfaType) => { + const result = await authService.updateMFAPreference(mfaType) + + if (result.err) { + throw result.val + } + }) + const verifyTotpToken = useEventCallback(async (otp: string) => { + const result = await authService.verifyTotpToken(otp) + if (result.err) { + throw result.val + } else { + return result.unwrap() + } + }) + const setupTOTP = useEventCallback(async () => { + const result = await authService.setupTOTP() + if (result.err) { + throw result.val + } else { + return result.unwrap() + } + }) + React.useEffect(() => { if (session.data) { // Save access token so can it be reused by backend services @@ -325,6 +362,10 @@ export default function SessionProvider(props: SessionProviderProps) { changePassword, signOut: logoutMutation.mutateAsync, organizationId, + getMFAPreference, + updateMFAPreference, + verifyTotpToken, + setupTOTP, } satisfies SessionContextType return ( From 63ecc7691b11050ca95ed34a14e5a2b9f55a2312 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Mon, 30 Dec 2024 19:13:17 +0400 Subject: [PATCH 5/7] Remove unused method --- .../src/dashboard/providers/__test__/SessionProvider.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx index 079354bf2104..860f1f9c6bae 100644 --- a/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx +++ b/app/gui/src/dashboard/providers/__test__/SessionProvider.test.tsx @@ -71,7 +71,7 @@ describe('SessionProvider', () => { }) it('Should retrieve the user session', async () => { - const { getByText, debug } = render( + const { getByText } = render( Loading...}> Date: Mon, 30 Dec 2024 19:20:35 +0400 Subject: [PATCH 6/7] Fix project icon size --- app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx b/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx index 8d580e96c13c..915ba9833f6e 100644 --- a/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx +++ b/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx @@ -178,7 +178,7 @@ export default function ProjectIcon(props: ProjectIconProps) { case backendModule.ProjectState.created: return ( Date: Tue, 14 Jan 2025 17:49:21 +0400 Subject: [PATCH 7/7] Fix lint --- app/ide-desktop/client/tasks/signArchivesMacOs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ide-desktop/client/tasks/signArchivesMacOs.ts b/app/ide-desktop/client/tasks/signArchivesMacOs.ts index db7753c1212c..86d3c2e9cf17 100644 --- a/app/ide-desktop/client/tasks/signArchivesMacOs.ts +++ b/app/ide-desktop/client/tasks/signArchivesMacOs.ts @@ -217,7 +217,7 @@ class ArchiveToSign implements Signable { const meta = 'META-INF/MANIFEST.MF' try { run('jar', ['-cfm', TEMPORARY_ARCHIVE_PATH, meta, '.'], workingDir) - } catch (err) { + } catch { run('jar', ['-cf', TEMPORARY_ARCHIVE_PATH, '.'], workingDir) } } else {