From 0fc8683108bc1e7113d79cc6fa926673025218f4 Mon Sep 17 00:00:00 2001 From: Nikita Pekin Date: Mon, 20 Mar 2023 22:41:57 -0400 Subject: [PATCH] Cognito auth 6/7 - add signout (#5867) --- .../src/authentication/cognito.ts | 152 ++++++++++++++- .../src/authentication/components/common.tsx | 16 ++ .../components/forgotPassword.tsx | 93 +++++++++ .../src/authentication/components/login.tsx | 21 +- .../components/registration.tsx | 17 +- .../components/resetPassword.tsx | 181 ++++++++++++++++++ .../authentication/components/setUsername.tsx | 23 +-- .../src/authentication/config.ts | 2 +- .../src/authentication/listen.tsx | 2 + .../src/authentication/providers/auth.tsx | 38 ++++ .../src/authentication/providers/session.tsx | 7 +- .../src/authentication/service.tsx | 74 ++++--- .../src/authentication/src/components/app.tsx | 14 ++ .../src/authentication/src/components/svg.tsx | 10 +- .../src/dashboard/components/dashboard.tsx | 2 + 15 files changed, 584 insertions(+), 68 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts index cf90a6786a93..6ecb755c3086 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts @@ -49,10 +49,15 @@ const GITHUB_PROVIDER = 'Github' const MESSAGES = { signInWithPassword: { - userNotFound: 'User not found. Please register first.', + userNotFound: 'Username not found. Please register first.', userNotConfirmed: 'User is not confirmed. Please check your email for a confirmation link.', incorrectUsernameOrPassword: 'Incorrect username or password.', }, + forgotPassword: { + userNotFound: 'Username not found. Please register first.', + userNotConfirmed: + 'Cannot reset password for user with an unverified email. Please verify your email first.', + }, } // ==================== @@ -98,6 +103,25 @@ function intoAmplifyErrorOrThrow(error: unknown): AmplifyError { } } +// ================= +// === AuthError === +// ================= + +/** Object returned by the AWS Amplify library when an auth error occurs. */ +interface AuthError { + name: string + log: string +} + +/** Hints to TypeScript if we can safely cast an `unknown` error to an `AuthError`. */ +function isAuthError(error: unknown): error is AuthError { + if (error && typeof error === 'object') { + return 'name' in error && 'log' in error + } else { + return false + } +} + // =============== // === Cognito === // =============== @@ -107,7 +131,6 @@ function intoAmplifyErrorOrThrow(error: unknown): AmplifyError { * The caller can then handle them via pattern matching on the {@link results.Result} type. */ export class Cognito { constructor( - // @ts-expect-error This will be used in a future PR. private readonly logger: loggerProvider.Logger, private readonly platform: platformModule.Platform, amplifyConfig: config.AmplifyConfig @@ -169,6 +192,29 @@ export class Cognito { return signInWithPassword(username, password) } + /** Signs out the current user. */ + signOut() { + return signOut(this.logger) + } + + /** Sends a password reset email. + * + * The user will be able to reset their password by following the link in the email, which takes + * them to the "reset password" page of the application. The verification code will be filled in + * automatically. */ + forgotPassword(email: string) { + return forgotPassword(email) + } + + /** Submits a new password for the given email address. + * + * The user will have received a verification code in an email, which they will have entered on + * the "reset password" page of the application. This function will submit the new password + * along with the verification code, changing the user's password. */ + forgotPasswordSubmit(email: string, code: string, password: string) { + return forgotPasswordSubmit(email, code, password) + } + /** We want to signal to Amplify to fire a "custom state change" event when the user is * redirected back to the application after signing in via an external identity provider. This * is done so we get a chance to fix the location history. The location history is the history @@ -294,7 +340,7 @@ const SIGN_UP_USERNAME_EXISTS_ERROR = { } as const const SIGN_UP_INVALID_PARAMETER_ERROR = { - internalCode: 'InvalidParameterEx[ception', + internalCode: 'InvalidParameterException', kind: 'InvalidParameter', } as const @@ -428,3 +474,103 @@ function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInWithPass throw error } } + +// ====================== +// === ForgotPassword === +// ====================== +const FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR = { + internalCode: 'InvalidParameterException', + kind: 'UserNotConfirmed', + message: + 'Cannot reset password for the user as there is no registered/verified email or phone_number', +} as const + +async function forgotPassword(email: string) { + return results.Result.wrapAsync(async () => { + await amplify.Auth.forgotPassword(email) + }) + .then(result => result.mapErr(intoAmplifyErrorOrThrow)) + .then(result => result.mapErr(intoForgotPasswordErrorOrThrow)) +} + +type ForgotPasswordErrorKind = 'UserNotConfirmed' | 'UserNotFound' + +export interface ForgotPasswordError { + kind: ForgotPasswordErrorKind + message: string +} + +function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordError { + if (error.code === 'UserNotFoundException') { + return { + kind: 'UserNotFound', + message: MESSAGES.forgotPassword.userNotFound, + } + } else if ( + error.code === FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.internalCode && + error.message === FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.message + ) { + return { + kind: FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.kind, + message: MESSAGES.forgotPassword.userNotConfirmed, + } + } else { + throw error + } +} + +// ============================ +// === ForgotPasswordSubmit === +// ============================ + +async function forgotPasswordSubmit(email: string, code: string, password: string) { + return results.Result.wrapAsync(async () => { + await amplify.Auth.forgotPasswordSubmit(email, code, password) + }).then(result => result.mapErr(intoForgotPasswordSubmitErrorOrThrow)) +} + +type ForgotPasswordSubmitErrorKind = 'AmplifyError' | 'AuthError' + +export interface ForgotPasswordSubmitError { + kind: ForgotPasswordSubmitErrorKind + message: string +} + +function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSubmitError { + if (isAuthError(error)) { + return { + kind: 'AuthError', + message: error.log, + } + } else if (isAmplifyError(error)) { + return { + kind: 'AmplifyError', + message: error.message, + } + } else { + throw error + } +} + +// =============== +// === SignOut === +// =============== + +async function signOut(logger: loggerProvider.Logger) { + // FIXME [NP]: https://github.com/enso-org/cloud-v2/issues/341 + // For some reason, the redirect back to the IDE from the browser doesn't work correctly so this + // `await` throws a timeout error. As a workaround, we catch this error and force a refresh of + // the session manually by running the `signOut` again. This works because Amplify will see that + // we've already signed out and clear the cache accordingly. Ideally we should figure out how + // to fix the redirect and remove this `catch`. This has the unintended consequence of catching + // any other errors that might occur during sign out, that we really shouldn't be catching. This + // also has the unintended consequence of delaying the sign out process by a few seconds (until + // the timeout occurs). + try { + await amplify.Auth.signOut() + } catch (error) { + logger.error('Sign out failed', error) + } finally { + await amplify.Auth.signOut() + } +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/common.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/common.tsx index c282a1465ac6..9a8882b81b9e 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/common.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/common.tsx @@ -8,6 +8,22 @@ import * as fontawesomeIcons from "@fortawesome/free-brands-svg-icons"; import * as icons from "../../components/svg"; +// ============= +// === Input === +// ============= + +export function Input(props: React.InputHTMLAttributes) { + return ( + + ); +} + // =============== // === SvgIcon === // =============== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx new file mode 100644 index 000000000000..b83b1d6e84ee --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx @@ -0,0 +1,93 @@ +/** @file Container responsible for rendering and interactions in first half of forgot password + * flow. */ +import * as router from "react-router-dom"; + +import * as app from "../../components/app"; +import * as auth from "../providers/auth"; +import * as common from "./common"; +import * as hooks from "../../hooks"; +import * as icons from "../../components/svg"; +import * as utils from "../../utils"; + +// ====================== +// === ForgotPassword === +// ====================== + +function ForgotPassword() { + const { forgotPassword } = auth.useAuth(); + + const [email, bindEmail] = hooks.useInput(""); + + return ( +
+
+
+ Forgot Your Password? +
+
+
{ + await forgotPassword(email); + })} + > +
+ +
+ + + +
+
+
+ +
+
+
+
+ + + + + Go back to login + +
+
+
+ ); +} + +export default ForgotPassword; diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx index 1f5f3d4b6f10..faf4f7f72345 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx @@ -17,10 +17,6 @@ const BUTTON_CLASS_NAME = "relative mt-6 border rounded-md py-2 text-sm text-gray-800 " + "bg-gray-100 hover:bg-gray-200"; -const INPUT_CLASS_NAME = - "text-sm sm:text-base placeholder-gray-500 pl-10 pr-4 rounded-lg border " + - "border-gray-400 w-full py-2 focus:outline-none focus:border-blue-400"; - const LOGIN_QUERY_PARAMS = { email: "email", } as const; @@ -87,13 +83,12 @@ function Login() {
-
@@ -108,18 +103,28 @@ function Login() {
-
+
+
+ + Forgot Your Password? + +
+
+
@@ -80,12 +71,11 @@ function Registration() {
-
@@ -100,12 +90,11 @@ function Registration() {
-
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx new file mode 100644 index 000000000000..27e7a430dfa2 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/resetPassword.tsx @@ -0,0 +1,181 @@ +/** @file Container responsible for rendering and interactions in second half of forgot password + * flow. */ +import * as router from "react-router-dom"; +import toast from "react-hot-toast"; + +import * as app from "../../components/app"; +import * as auth from "../providers/auth"; +import * as common from "./common"; +import * as hooks from "../../hooks"; +import * as icons from "../../components/svg"; +import * as utils from "../../utils"; + +// ================= +// === Constants === +// ================= + +const RESET_PASSWORD_QUERY_PARAMS = { + email: "email", + verificationCode: "verification_code", +} as const; + +// ===================== +// === ResetPassword === +// ===================== + +function ResetPassword() { + const { resetPassword } = auth.useAuth(); + const { search } = router.useLocation(); + + const { verificationCode: initialCode, email: initialEmail } = + parseUrlSearchParams(search); + + const [email, bindEmail] = hooks.useInput(initialEmail ?? ""); + const [code, bindCode] = hooks.useInput(initialCode ?? ""); + const [newPassword, bindNewPassword] = hooks.useInput(""); + const [newPasswordConfirm, bindNewPasswordConfirm] = hooks.useInput(""); + + const handleSubmit = () => { + if (newPassword !== newPasswordConfirm) { + toast.error("Passwords do not match"); + return Promise.resolve(); + } else { + return resetPassword(email, code, newPassword); + } + }; + + return ( +
+
+
+ Reset Your Password +
+
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+
+
+
+ + + + + Go back to login + +
+
+
+ ); +} + +function parseUrlSearchParams(search: string) { + const query = new URLSearchParams(search); + const verificationCode = query.get( + RESET_PASSWORD_QUERY_PARAMS.verificationCode + ); + const email = query.get(RESET_PASSWORD_QUERY_PARAMS.email); + return { verificationCode, email }; +} + +export default ResetPassword; diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/setUsername.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/setUsername.tsx index c64b1b0ff3ab..0a8f854a544e 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/setUsername.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/setUsername.tsx @@ -2,6 +2,7 @@ * registration. */ import * as auth from "../providers/auth"; +import * as common from "./common"; import * as hooks from "../../hooks"; import * as icons from "../../components/svg"; import * as utils from "../../utils"; @@ -18,7 +19,12 @@ function SetUsername() { return (
-
+
Set your username
@@ -30,19 +36,13 @@ function SetUsername() { >
-
- -
+ -
@@ -51,8 +51,9 @@ function SetUsername() { ); }