From 0aa7d7ee4d969ec8e8f9d376e72741dca324fdf6 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 31 Mar 2023 22:49:34 +1000 Subject: [PATCH] Fix prettier config; run prettier (#6132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix prettier config; run prettier * add workaround for double re-render * add missing fixme --------- Co-authored-by: Paweł Buchowski Co-authored-by: Nikita Pekin Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .prettierrc.yaml | 5 +- app/ide-desktop/lib/content/src/index.ts | 11 +- .../src/authentication/components/common.tsx | 72 +-- .../components/confirmRegistration.tsx | 76 ++- .../components/forgotPassword.tsx | 152 +++--- .../src/authentication/components/login.tsx | 276 +++++------ .../components/registration.tsx | 250 +++++----- .../components/resetPassword.tsx | 301 ++++++------ .../authentication/components/setUsername.tsx | 110 ++--- .../src/authentication/listen.tsx | 40 +- .../src/authentication/providers/auth.tsx | 460 +++++++++--------- .../src/authentication/providers/session.tsx | 205 ++++---- .../src/authentication/service.tsx | 337 +++++++------ .../src/authentication/src/components/app.tsx | 194 ++++---- .../src/authentication/src/components/svg.tsx | 61 ++- .../src/dashboard/components/dashboard.tsx | 22 +- .../authentication/src/dashboard/service.tsx | 130 +++-- .../src/authentication/src/hooks.tsx | 74 +-- .../dashboard/src/authentication/src/http.tsx | 171 +++---- .../src/authentication/src/index.tsx | 42 +- .../authentication/src/providers/logger.tsx | 24 +- app/ide-desktop/lib/dashboard/src/index.tsx | 10 +- 22 files changed, 1476 insertions(+), 1547 deletions(-) diff --git a/.prettierrc.yaml b/.prettierrc.yaml index e4c18df828..369758a7eb 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -1,8 +1,9 @@ overrides: - files: - "*.[j|t]s" - - "*.mjs" - - "*.cjs" + - "*.[j|t]sx" + - "*.m[j|t]s" + - "*.c[j|t]s" options: printWidth: 100 tabWidth: 4 diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index 3fc612a431..80a520d53b 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -153,9 +153,18 @@ class Main { * and one for the desktop. Once these are merged, we can't hardcode the * platform here, and need to detect it from the environment. */ const platform = authentication.Platform.desktop + /** FIXME [PB]: https://github.com/enso-org/cloud-v2/issues/366 + * React hooks rerender themselves multiple times. It is resulting in multiple + * Enso main scene being initialized. As a temporary workaround we check whether + * appInstance was already ran. Target solution should move running appInstance + * where it will be called only once. */ + let appInstanceRan = false const onAuthenticated = () => { hideAuth() - void appInstance.run() + if (!appInstanceRan) { + appInstanceRan = true + void appInstance.run() + } } authentication.run(logger, platform, onAuthenticated) } else { 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 9a8882b81b..836c23abe9 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 @@ -3,25 +3,25 @@ * For example, this file contains the {@link SvgIcon} component, which is used by the * `Registration` and `Login` components. */ -import * as fontawesome from "@fortawesome/react-fontawesome"; -import * as fontawesomeIcons from "@fortawesome/free-brands-svg-icons"; +import * as fontawesome from '@fortawesome/react-fontawesome' +import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons' -import * as icons from "../../components/svg"; +import * as icons from '../../components/svg' // ============= // === Input === // ============= export function Input(props: React.InputHTMLAttributes) { - return ( - - ); + return ( + + ) } // =============== @@ -29,22 +29,22 @@ export function Input(props: React.InputHTMLAttributes) { // =============== interface SvgIconProps { - data: string; + data: string } export function SvgIcon(props: SvgIconProps) { - return ( -
- - - -
- ); + return ( +
+ + + +
+ ) } // ======================= @@ -52,18 +52,18 @@ export function SvgIcon(props: SvgIconProps) { // ======================= interface FontAwesomeIconProps { - icon: fontawesomeIcons.IconDefinition; + icon: fontawesomeIcons.IconDefinition } export function FontAwesomeIcon(props: FontAwesomeIconProps) { - return ( - - - - ); + return ( + + + + ) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx index 1ce44991c9..2ae35a057a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/confirmRegistration.tsx @@ -1,62 +1,60 @@ /** @file Registration confirmation page for when a user clicks the confirmation link set to their * email address. */ -import * as react from "react"; -import * as router from "react-router-dom"; -import toast from "react-hot-toast"; +import * as react from 'react' +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 loggerProvider from "../../providers/logger"; +import * as app from '../../components/app' +import * as auth from '../providers/auth' +import * as loggerProvider from '../../providers/logger' // ================= // === Constants === // ================= const REGISTRATION_QUERY_PARAMS = { - verificationCode: "verification_code", - email: "email", -} as const; + verificationCode: 'verification_code', + email: 'email', +} as const // ============================ // === Confirm Registration === // ============================ function ConfirmRegistration() { - const logger = loggerProvider.useLogger(); - const { confirmSignUp } = auth.useAuth(); - const { search } = router.useLocation(); - const navigate = router.useNavigate(); + const logger = loggerProvider.useLogger() + const { confirmSignUp } = auth.useAuth() + const { search } = router.useLocation() + const navigate = router.useNavigate() - const { verificationCode, email } = parseUrlSearchParams(search); + const { verificationCode, email } = parseUrlSearchParams(search) - react.useEffect(() => { - if (!email || !verificationCode) { - navigate(app.LOGIN_PATH); - } else { - confirmSignUp(email, verificationCode) - .then(() => { - navigate(app.LOGIN_PATH + search.toString()); - }) - .catch((error) => { - logger.error("Error while confirming sign-up", error); - toast.error( - "Something went wrong! Please try again or contact the administrators." - ); - navigate(app.LOGIN_PATH); - }); - } - }, []); + react.useEffect(() => { + if (!email || !verificationCode) { + navigate(app.LOGIN_PATH) + } else { + confirmSignUp(email, verificationCode) + .then(() => { + navigate(app.LOGIN_PATH + search.toString()) + }) + .catch(error => { + logger.error('Error while confirming sign-up', error) + toast.error( + 'Something went wrong! Please try again or contact the administrators.' + ) + navigate(app.LOGIN_PATH) + }) + } + }, []) - return <>; + return <> } function parseUrlSearchParams(search: string) { - const query = new URLSearchParams(search); - const verificationCode = query.get( - REGISTRATION_QUERY_PARAMS.verificationCode - ); - const email = query.get(REGISTRATION_QUERY_PARAMS.email); - return { verificationCode, email }; + const query = new URLSearchParams(search) + const verificationCode = query.get(REGISTRATION_QUERY_PARAMS.verificationCode) + const email = query.get(REGISTRATION_QUERY_PARAMS.email) + return { verificationCode, email } } -export default ConfirmRegistration; +export default ConfirmRegistration 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 index b83b1d6e84..7577f2a5dc 100644 --- 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 @@ -1,93 +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 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"; +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 { forgotPassword } = auth.useAuth() - const [email, bindEmail] = hooks.useInput(""); + const [email, bindEmail] = hooks.useInput('') - return ( -
-
-
- Forgot Your Password? -
-
-
{ - await forgotPassword(email); - })} - > -
- -
- - - -
-
-
- + > +
+ Forgot Your Password? +
+
+ { + await forgotPassword(email) + })} + > +
+ +
+ + + +
+
+
+ +
+ +
+
+ + + + + Go back to login + +
-
-
- - - - - Go back to login - -
-
-
- ); + ) } -export default ForgotPassword; +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 faf4f7f723..de1b37698f 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 @@ -1,170 +1,162 @@ /** @file Login component responsible for rendering and interactions in sign in flow. */ -import * as fontawesomeIcons from "@fortawesome/free-brands-svg-icons"; -import * as router from "react-router-dom"; +import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons' +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"; +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 BUTTON_CLASS_NAME = - "relative mt-6 border rounded-md py-2 text-sm text-gray-800 " + - "bg-gray-100 hover:bg-gray-200"; + 'relative mt-6 border rounded-md py-2 text-sm text-gray-800 ' + 'bg-gray-100 hover:bg-gray-200' const LOGIN_QUERY_PARAMS = { - email: "email", -} as const; + email: 'email', +} as const // ============= // === Login === // ============= function Login() { - const { search } = router.useLocation(); - const { signInWithGoogle, signInWithGitHub, signInWithPassword } = - auth.useAuth(); + const { search } = router.useLocation() + const { signInWithGoogle, signInWithGitHub, signInWithPassword } = auth.useAuth() - const initialEmail = parseUrlSearchParams(search); + const initialEmail = parseUrlSearchParams(search) - const [email, bindEmail] = hooks.useInput(initialEmail ?? ""); - const [password, bindPassword] = hooks.useInput(""); + const [email, bindEmail] = hooks.useInput(initialEmail ?? '') + const [password, bindPassword] = hooks.useInput('') - return ( -
-
-
- Login To Your Account -
- - -
-
- - Or Login With Email - -
-
-
-
- signInWithPassword(email, password) - )} - > -
- -
- - - -
-
-
- -
- - - -
-
- -
-
- - Forgot Your Password? - -
-
- -
- + > +
+ Login To Your Account +
+ + +
+
+ + Or Login With Email + +
+
+
+ + signInWithPassword(email, password) + )} + > +
+ +
+ + + +
+
+
+ +
+ + + +
+
+ +
+
+ + Forgot Your Password? + +
+
+ +
+ +
+ +
+
+ + + + + You don't have an account? + +
-
-
- - - - - You don't have an account? - -
-
-
- ); + ) } function parseUrlSearchParams(search: string) { - const query = new URLSearchParams(search); - const email = query.get(LOGIN_QUERY_PARAMS.email); - return email; + const query = new URLSearchParams(search) + const email = query.get(LOGIN_QUERY_PARAMS.email) + return email } -export default Login; +export default Login diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx index 28354197d9..4a34976f71 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx @@ -1,138 +1,138 @@ /** @file Registration container responsible for rendering and interactions in sign up flow. */ -import * as router from "react-router-dom"; -import toast from "react-hot-toast"; +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"; +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' // ==================== // === Registration === // ==================== function Registration() { - const { signUp } = auth.useAuth(); - const [email, bindEmail] = hooks.useInput(""); - const [password, bindPassword] = hooks.useInput(""); - const [confirmPassword, bindConfirmPassword] = hooks.useInput(""); + const { signUp } = auth.useAuth() + const [email, bindEmail] = hooks.useInput('') + const [password, bindPassword] = hooks.useInput('') + const [confirmPassword, bindConfirmPassword] = hooks.useInput('') - const handleSubmit = () => { - /** The password & confirm password fields must match. */ - if (password !== confirmPassword) { - toast.error("Passwords do not match."); - return Promise.resolve(); - } else { - return signUp(email, password); - } - }; - - return ( -
-
{ + /** The password & confirm password fields must match. */ + if (password !== confirmPassword) { + toast.error('Passwords do not match.') + return Promise.resolve() + } else { + return signUp(email, password) } - > -
- Create new account + } + + return ( +
+
+
+ Create new account +
+ +
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+ +
+ +
+
+
+
+ + + + + Already have an account? + +
- -
-
- -
- - - -
-
-
- -
- - - -
-
-
- -
- - - -
-
- -
- -
-
-
-
- - - - - Already have an account? - -
-
- ); + ) } -export default Registration; +export default 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 index 27e7a430df..4d4e90e756 100644 --- 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 @@ -1,181 +1,178 @@ /** @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 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"; +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; + email: 'email', + verificationCode: 'verification_code', +} as const // ===================== // === ResetPassword === // ===================== function ResetPassword() { - const { resetPassword } = auth.useAuth(); - const { search } = router.useLocation(); + const { resetPassword } = auth.useAuth() + const { search } = router.useLocation() - const { verificationCode: initialCode, email: initialEmail } = - parseUrlSearchParams(search); + 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 [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 ( -
-
{ + if (newPassword !== newPasswordConfirm) { + toast.error('Passwords do not match') + return Promise.resolve() + } else { + return resetPassword(email, code, newPassword) } - > -
- Reset Your Password -
-
-
-
- -
- + } - -
-
-
- -
- - - -
-
-
- -
- - - -
-
-
- -
- - - -
-
-
- + > +
+ Reset Your Password +
+
+ +
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + + + + Go back to login + +
-
-
- - - - - 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 }; + 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; +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 0a8f854a54..a590145176 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 @@ -1,72 +1,72 @@ /** @file Container responsible for rendering and interactions in setting username flow, after * 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"; +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' // =================== // === SetUsername === // =================== function SetUsername() { - const { setUsername } = auth.useAuth(); - const { accessToken, email } = auth.usePartialUserSession(); + const { setUsername } = auth.useAuth() + const { accessToken, email } = auth.usePartialUserSession() - const [username, bindUsername] = hooks.useInput(""); + const [username, bindUsername] = hooks.useInput('') - return ( -
-
-
- Set your username -
-
-
- setUsername(accessToken, username, email) - )} - > -
-
- - - -
-
-
- + > +
+ Set your username +
+
+ + setUsername(accessToken, username, email) + )} + > +
+
+ + + +
+
+
+ +
+ +
-
-
-
- ); + ) } -export default SetUsername; +export default SetUsername diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/listen.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/listen.tsx index bee9d16cdf..6e4a6f14b1 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/listen.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/listen.tsx @@ -3,14 +3,14 @@ * Listening to authentication events is necessary to update the authentication state of the * application. For example, if the user signs out, we want to clear the authentication state so * that the login screen is rendered. */ -import * as amplify from "@aws-amplify/core"; +import * as amplify from '@aws-amplify/core' // ================= // === Constants === // ================= /** Name of the string identifying the "hub" that AWS Amplify issues authentication events on. */ -const AUTHENTICATION_HUB = "auth"; +const AUTHENTICATION_HUB = 'auth' // ================= // === AuthEvent === @@ -21,19 +21,19 @@ const AUTHENTICATION_HUB = "auth"; * These are issues by AWS Amplify when it detects a change in authentication state. For example, * when the user signs in or signs out by accessing a page like `enso://auth?code=...&state=...`. */ export enum AuthEvent { - /** Issued when the user has passed custom OAuth state parameters to some other auth event. */ - customOAuthState = "customOAuthState", - /** Issued when the user completes the sign-in process (via federated identity provider). */ - cognitoHostedUi = "cognitoHostedUI", - /** Issued when the user completes the sign-in process (via email/password). */ - signIn = "signIn", - /** Issued when the user signs out. */ - signOut = "signOut", + /** Issued when the user has passed custom OAuth state parameters to some other auth event. */ + customOAuthState = 'customOAuthState', + /** Issued when the user completes the sign-in process (via federated identity provider). */ + cognitoHostedUi = 'cognitoHostedUI', + /** Issued when the user completes the sign-in process (via email/password). */ + signIn = 'signIn', + /** Issued when the user signs out. */ + signOut = 'signOut', } /** Returns `true` if the given `string` is an {@link AuthEvent}. */ function isAuthEvent(value: string): value is AuthEvent { - return Object.values(AuthEvent).includes(value); + return Object.values(AuthEvent).includes(value) } // ================================= @@ -43,26 +43,24 @@ function isAuthEvent(value: string): value is AuthEvent { /** Callback called in response to authentication state changes. * * @see {@link Api["listen"]} */ -export type ListenerCallback = (event: AuthEvent, data?: unknown) => void; +export type ListenerCallback = (event: AuthEvent, data?: unknown) => void /** Unsubscribes the {@link ListenerCallback} from authentication state changes. * * @see {@link Api["listen"]} */ -type UnsubscribeFunction = () => void; +type UnsubscribeFunction = () => void /** Used to subscribe to {@link AuthEvent}s. * * Returns a function that MUST be called before re-subscribing, * to avoid memory leaks or duplicate event handlers. */ -export type ListenFunction = ( - listener: ListenerCallback -) => UnsubscribeFunction; +export type ListenFunction = (listener: ListenerCallback) => UnsubscribeFunction export function registerAuthEventListener(listener: ListenerCallback) { - const callback: amplify.HubCallback = (data) => { - if (isAuthEvent(data.payload.event)) { - listener(data.payload.event, data.payload.data); + const callback: amplify.HubCallback = data => { + if (isAuthEvent(data.payload.event)) { + listener(data.payload.event, data.payload.data) + } } - }; - return amplify.Hub.listen(AUTHENTICATION_HUB, callback); + return amplify.Hub.listen(AUTHENTICATION_HUB, callback) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx index 5a92f373f2..4abed93fd4 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/auth.tsx @@ -3,31 +3,31 @@ * Provides an `AuthProvider` component that wraps the entire application, and a `useAuth` hook that * can be used from any React component to access the currently logged-in user's session data. The * hook also provides methods for registering a user, logging in, logging out, etc. */ -import * as react from "react"; -import * as router from "react-router-dom"; -import toast from "react-hot-toast"; +import * as react from 'react' +import * as router from 'react-router-dom' +import toast from 'react-hot-toast' -import * as app from "../../components/app"; -import * as authServiceModule from "../service"; -import * as backendService from "../../dashboard/service"; -import * as errorModule from "../../error"; -import * as loggerProvider from "../../providers/logger"; -import * as sessionProvider from "./session"; +import * as app from '../../components/app' +import * as authServiceModule from '../service' +import * as backendService from '../../dashboard/service' +import * as errorModule from '../../error' +import * as loggerProvider from '../../providers/logger' +import * as sessionProvider from './session' // ================= // === Constants === // ================= const MESSAGES = { - signUpSuccess: "We have sent you an email with further instructions!", - confirmSignUpSuccess: "Your account has been confirmed! Please log in.", - setUsernameSuccess: "Your username has been set!", - signInWithPasswordSuccess: "Successfully logged in!", - forgotPasswordSuccess: "We have sent you an email with further instructions!", - resetPasswordSuccess: "Successfully reset password!", - signOutSuccess: "Successfully logged out!", - pleaseWait: "Please wait...", -} as const; + signUpSuccess: 'We have sent you an email with further instructions!', + confirmSignUpSuccess: 'Your account has been confirmed! Please log in.', + setUsernameSuccess: 'Your username has been set!', + signInWithPasswordSuccess: 'Successfully logged in!', + forgotPasswordSuccess: 'We have sent you an email with further instructions!', + resetPasswordSuccess: 'Successfully reset password!', + signOutSuccess: 'Successfully logged out!', + pleaseWait: 'Please wait...', +} as const // ============= // === Types === @@ -35,19 +35,19 @@ const MESSAGES = { // === UserSession === -export type UserSession = FullUserSession | PartialUserSession; +export type UserSession = FullUserSession | PartialUserSession /** Object containing the currently signed-in user's session data. */ export interface FullUserSession { - /** A discriminator for TypeScript to be able to disambiguate between this interface and other - * `UserSession` variants. */ - variant: "full"; - /** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */ - accessToken: string; - /** User's email address. */ - email: string; - /** User's organization information. */ - organization: backendService.Organization; + /** A discriminator for TypeScript to be able to disambiguate between this interface and other + * `UserSession` variants. */ + variant: 'full' + /** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */ + accessToken: string + /** User's email address. */ + email: string + /** User's organization information. */ + organization: backendService.Organization } /** Object containing the currently signed-in user's session data, if the user has not yet set their @@ -57,13 +57,13 @@ export interface FullUserSession { * their account. Otherwise, this type is identical to the `Session` type. This type should ONLY be * used by the `SetUsername` component. */ export interface PartialUserSession { - /** A discriminator for TypeScript to be able to disambiguate between this interface and other - * `UserSession` variants. */ - variant: "partial"; - /** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */ - accessToken: string; - /** User's email address. */ - email: string; + /** A discriminator for TypeScript to be able to disambiguate between this interface and other + * `UserSession` variants. */ + variant: 'partial' + /** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */ + accessToken: string + /** User's email address. */ + email: string } // =================== @@ -78,27 +78,19 @@ export interface PartialUserSession { * * See {@link Cognito} for details on each of the authentication functions. */ interface AuthContextType { - signUp: (email: string, password: string) => Promise; - confirmSignUp: (email: string, code: string) => Promise; - setUsername: ( - accessToken: string, - username: string, - email: string - ) => Promise; - signInWithGoogle: () => Promise; - signInWithGitHub: () => Promise; - signInWithPassword: (email: string, password: string) => Promise; - forgotPassword: (email: string) => Promise; - resetPassword: ( - email: string, - code: string, - password: string - ) => Promise; - signOut: () => Promise; - /** Session containing the currently authenticated user's authentication information. - * - * If the user has not signed in, the session will be `null`. */ - session: UserSession | null; + signUp: (email: string, password: string) => Promise + confirmSignUp: (email: string, code: string) => Promise + setUsername: (accessToken: string, username: string, email: string) => Promise + signInWithGoogle: () => Promise + signInWithGitHub: () => Promise + signInWithPassword: (email: string, password: string) => Promise + forgotPassword: (email: string) => Promise + resetPassword: (email: string, code: string, password: string) => Promise + signOut: () => Promise + /** Session containing the currently authenticated user's authentication information. + * + * If the user has not signed in, the session will be `null`. */ + session: UserSession | null } // Eslint doesn't like headings. @@ -126,192 +118,186 @@ interface AuthContextType { * So changing the cast would provide no safety guarantees, and would require us to introduce null * checks everywhere we use the context. */ // eslint-disable-next-line no-restricted-syntax -const AuthContext = react.createContext({} as AuthContextType); +const AuthContext = react.createContext({} as AuthContextType) // ==================== // === AuthProvider === // ==================== export interface AuthProviderProps { - authService: authServiceModule.AuthService; - /** Callback to execute once the user has authenticated successfully. */ - onAuthenticated: () => void; - children: react.ReactNode; + authService: authServiceModule.AuthService + /** Callback to execute once the user has authenticated successfully. */ + onAuthenticated: () => void + children: react.ReactNode } export function AuthProvider(props: AuthProviderProps) { - const { authService, children } = props; - const { cognito } = authService; - const { session } = sessionProvider.useSession(); - const logger = loggerProvider.useLogger(); - const navigate = router.useNavigate(); - const onAuthenticated = react.useCallback(props.onAuthenticated, []); - const [initialized, setInitialized] = react.useState(false); - const [userSession, setUserSession] = react.useState( - null - ); + const { authService, children } = props + const { cognito } = authService + const { session } = sessionProvider.useSession() + const logger = loggerProvider.useLogger() + const navigate = router.useNavigate() + const onAuthenticated = react.useCallback(props.onAuthenticated, []) + const [initialized, setInitialized] = react.useState(false) + const [userSession, setUserSession] = react.useState(null) - /** Fetch the JWT access token from the session via the AWS Amplify library. - * - * When invoked, retrieves the access token (if available) from the storage method chosen when - * Amplify was configured (e.g. local storage). If the token is not available, return `undefined`. - * If the token has expired, automatically refreshes the token and returns the new token. */ - react.useEffect(() => { - const fetchSession = async () => { - if (session.none) { - setInitialized(true); - setUserSession(null); - } else { - const { accessToken, email } = session.val; + /** Fetch the JWT access token from the session via the AWS Amplify library. + * + * When invoked, retrieves the access token (if available) from the storage method chosen when + * Amplify was configured (e.g. local storage). If the token is not available, return `undefined`. + * If the token has expired, automatically refreshes the token and returns the new token. */ + react.useEffect(() => { + const fetchSession = async () => { + if (session.none) { + setInitialized(true) + setUserSession(null) + } else { + const { accessToken, email } = session.val - const backend = backendService.createBackend(accessToken, logger); - const organization = await backend.getUser(); - let newUserSession: UserSession; - if (!organization) { - newUserSession = { - variant: "partial", - email, - accessToken, - }; - } else { - newUserSession = { - variant: "full", - email, - accessToken, - organization, - }; + const backend = backendService.createBackend(accessToken, logger) + const organization = await backend.getUser() + let newUserSession: UserSession + if (!organization) { + newUserSession = { + variant: 'partial', + email, + accessToken, + } + } else { + newUserSession = { + variant: 'full', + email, + accessToken, + organization, + } - /** Execute the callback that should inform the Electron app that the user has logged in. - * This is done to transition the app from the authentication/dashboard view to the IDE. */ - onAuthenticated(); + /** Execute the callback that should inform the Electron app that the user has logged in. + * This is done to transition the app from the authentication/dashboard view to the IDE. */ + onAuthenticated() + } + + setUserSession(newUserSession) + setInitialized(true) + } } - setUserSession(newUserSession); - setInitialized(true); - } - }; + fetchSession().catch(error => { + if (isUserFacingError(error)) { + toast.error(error.message) + } else { + logger.error(error) + } + }) + }, [session]) - fetchSession().catch((error) => { - if (isUserFacingError(error)) { - toast.error(error.message); - } else { - logger.error(error); - } - }); - }, [session]); - - const withLoadingToast = - (action: (...args: T) => Promise) => - async (...args: T) => { - const loadingToast = toast.loading(MESSAGES.pleaseWait); - try { - await action(...args); - } finally { - toast.dismiss(loadingToast); - } - }; - - const signUp = (username: string, password: string) => - cognito.signUp(username, password).then((result) => { - if (result.ok) { - toast.success(MESSAGES.signUpSuccess); - } else { - toast.error(result.val.message); - } - }); - - const confirmSignUp = async (email: string, code: string) => - cognito.confirmSignUp(email, code).then((result) => { - if (result.err) { - switch (result.val.kind) { - case "UserAlreadyConfirmed": - break; - default: - throw new errorModule.UnreachableCaseError(result.val.kind); - } - } - - toast.success(MESSAGES.confirmSignUpSuccess); - navigate(app.LOGIN_PATH); - }); - - const signInWithPassword = async (email: string, password: string) => - cognito.signInWithPassword(email, password).then((result) => { - if (result.ok) { - toast.success(MESSAGES.signInWithPasswordSuccess); - } else { - if (result.val.kind === "UserNotFound") { - navigate(app.REGISTRATION_PATH); + const withLoadingToast = + (action: (...args: T) => Promise) => + async (...args: T) => { + const loadingToast = toast.loading(MESSAGES.pleaseWait) + try { + await action(...args) + } finally { + toast.dismiss(loadingToast) + } } - toast.error(result.val.message); - } - }); + const signUp = (username: string, password: string) => + cognito.signUp(username, password).then(result => { + if (result.ok) { + toast.success(MESSAGES.signUpSuccess) + } else { + toast.error(result.val.message) + } + }) - const setUsername = async ( - accessToken: string, - username: string, - email: string - ) => { - const body: backendService.SetUsernameRequestBody = { - userName: username, - userEmail: email, - }; + const confirmSignUp = async (email: string, code: string) => + cognito.confirmSignUp(email, code).then(result => { + if (result.err) { + switch (result.val.kind) { + case 'UserAlreadyConfirmed': + break + default: + throw new errorModule.UnreachableCaseError(result.val.kind) + } + } - /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343 - * The API client is reinitialised on every request. That is an inefficient way of usage. - * Fix it by using React context and implementing it as a singleton. */ - const backend = backendService.createBackend(accessToken, logger); + toast.success(MESSAGES.confirmSignUpSuccess) + navigate(app.LOGIN_PATH) + }) - await backend.setUsername(body); - navigate(app.DASHBOARD_PATH); - toast.success(MESSAGES.setUsernameSuccess); - }; + const signInWithPassword = async (email: string, password: string) => + cognito.signInWithPassword(email, password).then(result => { + if (result.ok) { + toast.success(MESSAGES.signInWithPasswordSuccess) + } else { + if (result.val.kind === 'UserNotFound') { + navigate(app.REGISTRATION_PATH) + } - const forgotPassword = async (email: string) => - cognito.forgotPassword(email).then((result) => { - if (result.ok) { - toast.success(MESSAGES.forgotPasswordSuccess); - navigate(app.RESET_PASSWORD_PATH); - } else { - toast.error(result.val.message); - } - }); + toast.error(result.val.message) + } + }) - const resetPassword = async (email: string, code: string, password: string) => - cognito.forgotPasswordSubmit(email, code, password).then((result) => { - if (result.ok) { - toast.success(MESSAGES.resetPasswordSuccess); - navigate(app.LOGIN_PATH); - } else { - toast.error(result.val.message); - } - }); + const setUsername = async (accessToken: string, username: string, email: string) => { + const body: backendService.SetUsernameRequestBody = { + userName: username, + userEmail: email, + } - const signOut = () => - cognito.signOut().then(() => { - toast.success(MESSAGES.signOutSuccess); - }); + /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343 + * The API client is reinitialised on every request. That is an inefficient way of usage. + * Fix it by using React context and implementing it as a singleton. */ + const backend = backendService.createBackend(accessToken, logger) - const value = { - signUp: withLoadingToast(signUp), - confirmSignUp: withLoadingToast(confirmSignUp), - setUsername, - signInWithGoogle: cognito.signInWithGoogle.bind(cognito), - signInWithGitHub: cognito.signInWithGitHub.bind(cognito), - signInWithPassword: withLoadingToast(signInWithPassword), - forgotPassword: withLoadingToast(forgotPassword), - resetPassword: withLoadingToast(resetPassword), - signOut, - session: userSession, - }; + await backend.setUsername(body) + navigate(app.DASHBOARD_PATH) + toast.success(MESSAGES.setUsernameSuccess) + } - return ( - - {/* Only render the underlying app after we assert for the presence of a current user. */} - {initialized && children} - - ); + const forgotPassword = async (email: string) => + cognito.forgotPassword(email).then(result => { + if (result.ok) { + toast.success(MESSAGES.forgotPasswordSuccess) + navigate(app.RESET_PASSWORD_PATH) + } else { + toast.error(result.val.message) + } + }) + + const resetPassword = async (email: string, code: string, password: string) => + cognito.forgotPasswordSubmit(email, code, password).then(result => { + if (result.ok) { + toast.success(MESSAGES.resetPasswordSuccess) + navigate(app.LOGIN_PATH) + } else { + toast.error(result.val.message) + } + }) + + const signOut = () => + cognito.signOut().then(() => { + toast.success(MESSAGES.signOutSuccess) + }) + + const value = { + signUp: withLoadingToast(signUp), + confirmSignUp: withLoadingToast(confirmSignUp), + setUsername, + signInWithGoogle: cognito.signInWithGoogle.bind(cognito), + signInWithGitHub: cognito.signInWithGitHub.bind(cognito), + signInWithPassword: withLoadingToast(signInWithPassword), + forgotPassword: withLoadingToast(forgotPassword), + resetPassword: withLoadingToast(resetPassword), + signOut, + session: userSession, + } + + return ( + + {/* Only render the underlying app after we assert for the presence of a current user. */} + {initialized && children} + + ) } /** Type of an error containing a `string`-typed `message` field. @@ -319,13 +305,13 @@ export function AuthProvider(props: AuthProviderProps) { * Many types of errors fall into this category. We use this type to check if an error can be safely * displayed to the user. */ interface UserFacingError { - /** The user-facing error message. */ - message: string; + /** The user-facing error message. */ + message: string } /** Returns `true` if the value is a {@link UserFacingError}. */ function isUserFacingError(value: unknown): value is UserFacingError { - return typeof value === "object" && value != null && "message" in value; + return typeof value === 'object' && value != null && 'message' in value } // =============== @@ -337,7 +323,7 @@ function isUserFacingError(value: unknown): value is UserFacingError { * Only the hook is exported, and not the context, because we only want to use the hook directly and * never the context component. */ export function useAuth() { - return react.useContext(AuthContext); + return react.useContext(AuthContext) } // ======================= @@ -346,13 +332,13 @@ export function useAuth() { // eslint-disable-next-line @typescript-eslint/naming-convention export function ProtectedLayout() { - const { session } = useAuth(); + const { session } = useAuth() - if (!session) { - return ; - } else { - return ; - } + if (!session) { + return + } else { + return + } } // =================== @@ -361,15 +347,15 @@ export function ProtectedLayout() { // eslint-disable-next-line @typescript-eslint/naming-convention export function GuestLayout() { - const { session } = useAuth(); + const { session } = useAuth() - if (session?.variant === "partial") { - return ; - } else if (session?.variant === "full") { - return ; - } else { - return ; - } + if (session?.variant === 'partial') { + return + } else if (session?.variant === 'full') { + return + } else { + return + } } // ============================= @@ -377,7 +363,7 @@ export function GuestLayout() { // ============================= export function usePartialUserSession() { - return router.useOutletContext(); + return router.useOutletContext() } // ========================== @@ -385,5 +371,5 @@ export function usePartialUserSession() { // ========================== export function useFullUserSession() { - return router.useOutletContext(); + return router.useOutletContext() } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx index ef367b0e4b..be1fbd181b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx @@ -1,139 +1,136 @@ /** @file Provider for the {@link SessionContextType}, which contains information about the * currently authenticated user's session. */ -import * as react from "react"; +import * as react from 'react' -import * as results from "ts-results"; +import * as results from 'ts-results' -import * as cognito from "../cognito"; -import * as error from "../../error"; -import * as hooks from "../../hooks"; -import * as listen from "../listen"; +import * as cognito from '../cognito' +import * as error from '../../error' +import * as hooks from '../../hooks' +import * as listen from '../listen' // ====================== // === SessionContext === // ====================== interface SessionContextType { - session: results.Option; + session: results.Option } /** See {@link AuthContext} for safety details. */ const SessionContext = react.createContext( - // eslint-disable-next-line no-restricted-syntax - {} as SessionContextType -); + // eslint-disable-next-line no-restricted-syntax + {} as SessionContextType +) // ======================= // === SessionProvider === // ======================= interface SessionProviderProps { - /** URL that the content of the app is served at, by Electron. - * - * This **must** be the actual page that the content is served at, otherwise the OAuth flow will - * not work and will redirect the user to a blank page. If this is the correct URL, no redirect - * will occur (which is the desired behaviour). - * - * The URL includes a scheme, hostname, and port (e.g., `http://localhost:8080`). The port is not - * known ahead of time, since the content may be served on any free port. Thus, the URL is - * obtained by reading the window location at the time that authentication is instantiated. This - * is guaranteed to be the correct location, since authentication is instantiated when the content - * is initially served. */ - mainPageUrl: URL; - registerAuthEventListener: listen.ListenFunction; - userSession: () => Promise>; - children: react.ReactNode; + /** URL that the content of the app is served at, by Electron. + * + * This **must** be the actual page that the content is served at, otherwise the OAuth flow will + * not work and will redirect the user to a blank page. If this is the correct URL, no redirect + * will occur (which is the desired behaviour). + * + * The URL includes a scheme, hostname, and port (e.g., `http://localhost:8080`). The port is not + * known ahead of time, since the content may be served on any free port. Thus, the URL is + * obtained by reading the window location at the time that authentication is instantiated. This + * is guaranteed to be the correct location, since authentication is instantiated when the content + * is initially served. */ + mainPageUrl: URL + registerAuthEventListener: listen.ListenFunction + userSession: () => Promise> + children: react.ReactNode } export function SessionProvider(props: SessionProviderProps) { - const { mainPageUrl, children, userSession, registerAuthEventListener } = - props; + const { mainPageUrl, children, userSession, registerAuthEventListener } = props - /** Flag used to avoid rendering child components until we've fetched the user's session at least - * once. Avoids flash of the login screen when the user is already logged in. */ - const [initialized, setInitialized] = react.useState(false); + /** Flag used to avoid rendering child components until we've fetched the user's session at least + * once. Avoids flash of the login screen when the user is already logged in. */ + const [initialized, setInitialized] = react.useState(false) - /** Produces a new object every time. - * This is not equal to any other empty object because objects are compared by reference. - * Because it is not equal to the old value, React re-renders the component. */ - function newRefresh() { - return {}; - } + /** Produces a new object every time. + * This is not equal to any other empty object because objects are compared by reference. + * Because it is not equal to the old value, React re-renders the component. */ + function newRefresh() { + return {} + } - /** State that, when set, forces a refresh of the user session. This is useful when a - * user has just logged in (so their cached credentials are out of date). Should be used via the - * `refreshSession` function. */ - const [refresh, setRefresh] = react.useState(newRefresh()); + /** State that, when set, forces a refresh of the user session. This is useful when a + * user has just logged in (so their cached credentials are out of date). Should be used via the + * `refreshSession` function. */ + const [refresh, setRefresh] = react.useState(newRefresh()) - /** Forces a refresh of the user session. - * - * Should be called after any operation that **will** (not **might**) change the user's session. - * For example, this should be called after signing out. Calling this will result in a re-render - * of the whole page, which is why it should only be done when necessary. */ - const refreshSession = () => { - setRefresh(newRefresh()); - }; + /** Forces a refresh of the user session. + * + * Should be called after any operation that **will** (not **might**) change the user's session. + * For example, this should be called after signing out. Calling this will result in a re-render + * of the whole page, which is why it should only be done when necessary. */ + const refreshSession = () => { + setRefresh(newRefresh()) + } - /** Register an async effect that will fetch the user's session whenever the `refresh` state is - * incremented. This is useful when a user has just logged in (as their cached credentials are - * out of date, so this will update them). */ - const session = hooks.useAsyncEffect( - results.None, - async () => { - const innerSession = await userSession(); - setInitialized(true); - return innerSession; - }, - [refresh, userSession] - ); + /** Register an async effect that will fetch the user's session whenever the `refresh` state is + * incremented. This is useful when a user has just logged in (as their cached credentials are + * out of date, so this will update them). */ + const session = hooks.useAsyncEffect( + results.None, + async () => { + const innerSession = await userSession() + setInitialized(true) + return innerSession + }, + [refresh, userSession] + ) - /** Register an effect that will listen for authentication events. When the event occurs, we - * will refresh or clear the user's session, forcing a re-render of the page with the new - * session. - * - * For example, if a user clicks the signout button, this will clear the user's session, which - * means we want the login screen to render (which is a child of this provider). */ - react.useEffect(() => { - const listener: listen.ListenerCallback = (event) => { - switch (event) { - case listen.AuthEvent.signIn: - case listen.AuthEvent.signOut: { - refreshSession(); - break; + /** Register an effect that will listen for authentication events. When the event occurs, we + * will refresh or clear the user's session, forcing a re-render of the page with the new + * session. + * + * For example, if a user clicks the signout button, this will clear the user's session, which + * means we want the login screen to render (which is a child of this provider). */ + react.useEffect(() => { + const listener: listen.ListenerCallback = event => { + switch (event) { + case listen.AuthEvent.signIn: + case listen.AuthEvent.signOut: { + refreshSession() + break + } + case listen.AuthEvent.customOAuthState: + case listen.AuthEvent.cognitoHostedUi: { + /** AWS Amplify doesn't provide a way to set the redirect URL for the OAuth flow, so + * we have to hack it by replacing the URL in the browser's history. This is done + * because otherwise the user will be redirected to a URL like `enso://auth`, which + * will not work. + * + * See: + * https://github.com/aws-amplify/amplify-js/issues/3391#issuecomment-756473970 */ + window.history.replaceState({}, '', mainPageUrl) + refreshSession() + break + } + default: { + throw new error.UnreachableCaseError(event) + } + } } - case listen.AuthEvent.customOAuthState: - case listen.AuthEvent.cognitoHostedUi: { - /** AWS Amplify doesn't provide a way to set the redirect URL for the OAuth flow, so - * we have to hack it by replacing the URL in the browser's history. This is done - * because otherwise the user will be redirected to a URL like `enso://auth`, which - * will not work. - * - * See: - * https://github.com/aws-amplify/amplify-js/issues/3391#issuecomment-756473970 */ - window.history.replaceState({}, "", mainPageUrl); - refreshSession(); - break; - } - default: { - throw new error.UnreachableCaseError(event); - } - } - }; - const cancel = registerAuthEventListener(listener); - /** Return the `cancel` function from the `useEffect`, which ensures that the listener is - * cleaned up between renders. This must be done because the `useEffect` will be called - * multiple times during the lifetime of the component. */ - return cancel; - }, [registerAuthEventListener]); + const cancel = registerAuthEventListener(listener) + /** Return the `cancel` function from the `useEffect`, which ensures that the listener is + * cleaned up between renders. This must be done because the `useEffect` will be called + * multiple times during the lifetime of the component. */ + return cancel + }, [registerAuthEventListener]) - const value = { session }; + const value = { session } - return ( - - {initialized && children} - - ); + return ( + {initialized && children} + ) } // ================== @@ -141,5 +138,5 @@ export function SessionProvider(props: SessionProviderProps) { // ================== export function useSession() { - return react.useContext(SessionContext); + return react.useContext(SessionContext) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx index 42e4fb8e16..1c0eb81bbe 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/service.tsx @@ -1,18 +1,18 @@ /** @file Provides an {@link AuthService} which consists of an underyling {@link Cognito} API * wrapper, along with some convenience callbacks to make URL redirects for the authentication flows * work with Electron. */ -import * as amplify from "@aws-amplify/auth"; +import * as amplify from '@aws-amplify/auth' -import * as common from "enso-common"; +import * as common from 'enso-common' -import * as app from "../components/app"; -import * as auth from "./config"; -import * as cognito from "./cognito"; -import * as config from "../config"; -import * as listen from "./listen"; -import * as loggerProvider from "../providers/logger"; -import * as platformModule from "../platform"; -import * as utils from "../utils"; +import * as app from '../components/app' +import * as auth from './config' +import * as cognito from './cognito' +import * as config from '../config' +import * as listen from './listen' +import * as loggerProvider from '../providers/logger' +import * as platformModule from '../platform' +import * as utils from '../utils' // ================= // === Constants === @@ -20,67 +20,59 @@ import * as utils from "../utils"; /** Pathname of the {@link URL} for deep links to the sign in page, after a redirect from a * federated identity provider. */ -const SIGN_IN_PATHNAME = "//auth"; +const SIGN_IN_PATHNAME = '//auth' /** Pathname of the {@link URL} for deep links to the sign out page, after a redirect from a * federated identity provider. */ -const SIGN_OUT_PATHNAME = "//auth"; +const SIGN_OUT_PATHNAME = '//auth' /** Pathname of the {@link URL} for deep links to the registration confirmation page, after a * redirect from an account verification email. */ -const CONFIRM_REGISTRATION_PATHNAME = "//auth/confirmation"; +const CONFIRM_REGISTRATION_PATHNAME = '//auth/confirmation' /** Pathname of the {@link URL} for deep links to the login page, after a redirect from a reset * password email. */ -const LOGIN_PATHNAME = "//auth/login"; +const LOGIN_PATHNAME = '//auth/login' /** URL used as the OAuth redirect when running in the desktop app. */ -const DESKTOP_REDIRECT = utils.brand( - `${common.DEEP_LINK_SCHEME}://auth` -); +const DESKTOP_REDIRECT = utils.brand(`${common.DEEP_LINK_SCHEME}://auth`) /** Map from platform to the OAuth redirect URL that should be used for that platform. */ const PLATFORM_TO_CONFIG: Record< - platformModule.Platform, - Pick + platformModule.Platform, + Pick > = { - [platformModule.Platform.desktop]: { - redirectSignIn: DESKTOP_REDIRECT, - redirectSignOut: DESKTOP_REDIRECT, - }, - [platformModule.Platform.cloud]: { - redirectSignIn: config.ACTIVE_CONFIG.cloudRedirect, - redirectSignOut: config.ACTIVE_CONFIG.cloudRedirect, - }, -}; + [platformModule.Platform.desktop]: { + redirectSignIn: DESKTOP_REDIRECT, + redirectSignOut: DESKTOP_REDIRECT, + }, + [platformModule.Platform.cloud]: { + redirectSignIn: config.ACTIVE_CONFIG.cloudRedirect, + redirectSignOut: config.ACTIVE_CONFIG.cloudRedirect, + }, +} const BASE_AMPLIFY_CONFIG = { - region: auth.AWS_REGION, - scope: auth.OAUTH_SCOPES, - responseType: auth.OAUTH_RESPONSE_TYPE, -} satisfies Partial; + region: auth.AWS_REGION, + scope: auth.OAUTH_SCOPES, + responseType: auth.OAUTH_RESPONSE_TYPE, +} satisfies Partial /** Collection of configuration details for Amplify user pools, sorted by deployment environment. */ const AMPLIFY_CONFIGS = { - /** Configuration for @pbuchu's Cognito user pool. */ - pbuchu: { - userPoolId: utils.brand("eu-west-1_jSF1RbgPK"), - userPoolWebClientId: utils.brand( - "1bnib0jfon3aqc5g3lkia2infr" - ), - domain: utils.brand( - "pb-enso-domain.auth.eu-west-1.amazoncognito.com" - ), - ...BASE_AMPLIFY_CONFIG, - } satisfies Partial, - /** Configuration for the production Cognito user pool. */ - production: { - userPoolId: utils.brand("eu-west-1_9Kycu2SbD"), - userPoolWebClientId: utils.brand( - "4j9bfs8e7415erf82l129v0qhe" - ), - domain: utils.brand( - "production-enso-domain.auth.eu-west-1.amazoncognito.com" - ), - ...BASE_AMPLIFY_CONFIG, - } satisfies Partial, -}; + /** Configuration for @pbuchu's Cognito user pool. */ + pbuchu: { + userPoolId: utils.brand('eu-west-1_jSF1RbgPK'), + userPoolWebClientId: utils.brand('1bnib0jfon3aqc5g3lkia2infr'), + domain: utils.brand('pb-enso-domain.auth.eu-west-1.amazoncognito.com'), + ...BASE_AMPLIFY_CONFIG, + } satisfies Partial, + /** Configuration for the production Cognito user pool. */ + production: { + userPoolId: utils.brand('eu-west-1_9Kycu2SbD'), + userPoolWebClientId: utils.brand('4j9bfs8e7415erf82l129v0qhe'), + domain: utils.brand( + 'production-enso-domain.auth.eu-west-1.amazoncognito.com' + ), + ...BASE_AMPLIFY_CONFIG, + } satisfies Partial, +} // ================== // === AuthConfig === @@ -88,15 +80,15 @@ const AMPLIFY_CONFIGS = { /** Configuration for the authentication service. */ export interface AuthConfig { - /** Logger for the authentication service. */ - logger: loggerProvider.Logger; - /** Whether the application is running on a desktop (i.e., versus in the Cloud). */ - platform: platformModule.Platform; - /** Function to navigate to a given (relative) URL. - * - * Used to redirect to pages like the password reset page with the query parameters set in the - * URL (e.g., `?verification_code=...`). */ - navigate: (url: string) => void; + /** Logger for the authentication service. */ + logger: loggerProvider.Logger + /** Whether the application is running on a desktop (i.e., versus in the Cloud). */ + platform: platformModule.Platform + /** Function to navigate to a given (relative) URL. + * + * Used to redirect to pages like the password reset page with the query parameters set in the + * URL (e.g., `?verification_code=...`). */ + navigate: (url: string) => void } // =================== @@ -105,10 +97,10 @@ export interface AuthConfig { /** API for the authentication service. */ export interface AuthService { - /** @see {@link cognito.Cognito}. */ - cognito: cognito.Cognito; - /** @see {@link listen.ListenFunction} */ - registerAuthEventListener: listen.ListenFunction; + /** @see {@link cognito.Cognito}. */ + cognito: cognito.Cognito + /** @see {@link listen.ListenFunction} */ + registerAuthEventListener: listen.ListenFunction } /** Creates an instance of the authentication service. @@ -118,49 +110,49 @@ export interface AuthService { * This function should only be called once, and the returned service should be used throughout the * application. This is because it performs global configuration of the Amplify library. */ export function initAuthService(authConfig: AuthConfig): AuthService { - const { logger, platform, navigate } = authConfig; - const amplifyConfig = loadAmplifyConfig(logger, platform, navigate); - const cognitoClient = new cognito.Cognito(logger, platform, amplifyConfig); - return { - cognito: cognitoClient, - registerAuthEventListener: listen.registerAuthEventListener, - }; + const { logger, platform, navigate } = authConfig + const amplifyConfig = loadAmplifyConfig(logger, platform, navigate) + const cognitoClient = new cognito.Cognito(logger, platform, amplifyConfig) + return { + cognito: cognitoClient, + registerAuthEventListener: listen.registerAuthEventListener, + } } function loadAmplifyConfig( - logger: loggerProvider.Logger, - platform: platformModule.Platform, - navigate: (url: string) => void + logger: loggerProvider.Logger, + platform: platformModule.Platform, + navigate: (url: string) => void ): auth.AmplifyConfig { - /** Load the environment-specific Amplify configuration. */ - const baseConfig = AMPLIFY_CONFIGS[config.ENVIRONMENT]; - let urlOpener = null; - if (platform === platformModule.Platform.desktop) { - /** If we're running on the desktop, we want to override the default URL opener for OAuth - * flows. This is because the default URL opener opens the URL in the desktop app itself, - * but we want the user to be sent to their system browser instead. The user should be sent - * to their system browser because: - * - * - users trust their system browser with their credentials more than they trust our app; - * - our app can keep itself on the relevant page until the user is sent back to it (i.e., - * we avoid unnecessary reloads/refreshes caused by redirects. */ - urlOpener = openUrlWithExternalBrowser; + /** Load the environment-specific Amplify configuration. */ + const baseConfig = AMPLIFY_CONFIGS[config.ENVIRONMENT] + let urlOpener = null + if (platform === platformModule.Platform.desktop) { + /** If we're running on the desktop, we want to override the default URL opener for OAuth + * flows. This is because the default URL opener opens the URL in the desktop app itself, + * but we want the user to be sent to their system browser instead. The user should be sent + * to their system browser because: + * + * - users trust their system browser with their credentials more than they trust our app; + * - our app can keep itself on the relevant page until the user is sent back to it (i.e., + * we avoid unnecessary reloads/refreshes caused by redirects. */ + urlOpener = openUrlWithExternalBrowser - /** To handle redirects back to the application from the system browser, we also need to - * register a custom URL handler. */ - setDeepLinkHandler(logger, navigate); - } - /** Load the platform-specific Amplify configuration. */ - const platformConfig = PLATFORM_TO_CONFIG[platform]; - return { - ...baseConfig, - ...platformConfig, - urlOpener, - }; + /** To handle redirects back to the application from the system browser, we also need to + * register a custom URL handler. */ + setDeepLinkHandler(logger, navigate) + } + /** Load the platform-specific Amplify configuration. */ + const platformConfig = PLATFORM_TO_CONFIG[platform] + return { + ...baseConfig, + ...platformConfig, + urlOpener, + } } function openUrlWithExternalBrowser(url: string) { - window.authenticationApi.openUrlInSystemBrowser(url); + window.authenticationApi.openUrlInSystemBrowser(url) } /** Set the callback that will be invoked when a deep link to the application is opened. @@ -180,84 +172,81 @@ function openUrlWithExternalBrowser(url: string) { * * All URLs that don't have a pathname that starts with {@link AUTHENTICATION_PATHNAME_BASE} will be * ignored by this handler. */ -function setDeepLinkHandler( - logger: loggerProvider.Logger, - navigate: (url: string) => void -) { - const onDeepLink = (url: string) => { - const parsedUrl = new URL(url); +function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: string) => void) { + const onDeepLink = (url: string) => { + const parsedUrl = new URL(url) - switch (parsedUrl.pathname) { - /** If the user is being redirected after clicking the registration confirmation link in their - * email, then the URL will be for the confirmation page path. */ - case CONFIRM_REGISTRATION_PATHNAME: { - const redirectUrl = `${app.CONFIRM_REGISTRATION_PATH}${parsedUrl.search}`; - navigate(redirectUrl); - break; - } - /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/339 - * Don't use `enso://auth` for both authentication redirect & signout redirect so we don't - * have to disambiguate between the two on the `DASHBOARD_PATH`. */ - case SIGN_OUT_PATHNAME: - case SIGN_IN_PATHNAME: - /** If the user is being redirected after a sign-out, then no query args will be present. */ - if (parsedUrl.search === "") { - navigate(app.LOGIN_PATH); - } else { - handleAuthResponse(url); + switch (parsedUrl.pathname) { + /** If the user is being redirected after clicking the registration confirmation link in their + * email, then the URL will be for the confirmation page path. */ + case CONFIRM_REGISTRATION_PATHNAME: { + const redirectUrl = `${app.CONFIRM_REGISTRATION_PATH}${parsedUrl.search}` + navigate(redirectUrl) + break + } + /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/339 + * Don't use `enso://auth` for both authentication redirect & signout redirect so we don't + * have to disambiguate between the two on the `DASHBOARD_PATH`. */ + case SIGN_OUT_PATHNAME: + case SIGN_IN_PATHNAME: + /** If the user is being redirected after a sign-out, then no query args will be present. */ + if (parsedUrl.search === '') { + navigate(app.LOGIN_PATH) + } else { + handleAuthResponse(url) + } + break + /** If the user is being redirected after finishing the password reset flow, then the URL will + * be for the login page. */ + case LOGIN_PATHNAME: + navigate(app.LOGIN_PATH) + break + /** If the user is being redirected from a password reset email, then we need to navigate to + * the password reset page, with the verification code and email passed in the URL so they can + * be filled in automatically. */ + case app.RESET_PASSWORD_PATH: { + const resetPasswordRedirectUrl = `${app.RESET_PASSWORD_PATH}${parsedUrl.search}` + navigate(resetPasswordRedirectUrl) + break + } + default: + logger.error(`${url} is an unrecognized deep link. Ignoring.`) } - break; - /** If the user is being redirected after finishing the password reset flow, then the URL will - * be for the login page. */ - case LOGIN_PATHNAME: - navigate(app.LOGIN_PATH); - break; - /** If the user is being redirected from a password reset email, then we need to navigate to - * the password reset page, with the verification code and email passed in the URL so they can - * be filled in automatically. */ - case app.RESET_PASSWORD_PATH: { - const resetPasswordRedirectUrl = `${app.RESET_PASSWORD_PATH}${parsedUrl.search}`; - navigate(resetPasswordRedirectUrl); - break; - } - default: - logger.error(`${url} is an unrecognized deep link. Ignoring.`); } - }; - window.authenticationApi.setDeepLinkHandler(onDeepLink); + window.authenticationApi.setDeepLinkHandler(onDeepLink) } /** When the user is being redirected from a federated identity provider, then we need to pass the * URL to the Amplify library, which will parse the URL and complete the OAuth flow. */ function handleAuthResponse(url: string) { - void (async () => { - /** Temporarily override the `window.location` object so that Amplify doesn't try to call - * `window.location.replaceState` (which doesn't work in the renderer process because of - * Electron's `webSecurity`). This is a hack, but it's the only way to get Amplify to work - * with a custom URL protocol in Electron. - * - * # Safety - * - * It is safe to disable the `unbound-method` lint here because we intentionally want to use - * the original `window.history.replaceState` function, which is not bound to the - * `window.history` object. */ - // eslint-disable-next-line @typescript-eslint/unbound-method - const replaceState = window.history.replaceState; - window.history.replaceState = () => false; - try { - /** # Safety - * - * It is safe to disable the `no-unsafe-call` lint here because we know that the `Auth` object - * has the `_handleAuthResponse` method, and we know that it is safe to call it with the `url` - * argument. There is no way to prove this to the TypeScript compiler, because these methods - * are intentionally not part of the public AWS Amplify API. */ - // @ts-expect-error `_handleAuthResponse` is a private method without typings. - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - await amplify.Auth._handleAuthResponse(url); - } finally { - /** Restore the original `window.location.replaceState` function. */ - window.history.replaceState = replaceState; - } - })(); + void (async () => { + /** Temporarily override the `window.location` object so that Amplify doesn't try to call + * `window.location.replaceState` (which doesn't work in the renderer process because of + * Electron's `webSecurity`). This is a hack, but it's the only way to get Amplify to work + * with a custom URL protocol in Electron. + * + * # Safety + * + * It is safe to disable the `unbound-method` lint here because we intentionally want to use + * the original `window.history.replaceState` function, which is not bound to the + * `window.history` object. */ + // eslint-disable-next-line @typescript-eslint/unbound-method + const replaceState = window.history.replaceState + window.history.replaceState = () => false + try { + /** # Safety + * + * It is safe to disable the `no-unsafe-call` lint here because we know that the `Auth` object + * has the `_handleAuthResponse` method, and we know that it is safe to call it with the `url` + * argument. There is no way to prove this to the TypeScript compiler, because these methods + * are intentionally not part of the public AWS Amplify API. */ + // @ts-expect-error `_handleAuthResponse` is a private method without typings. + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + await amplify.Auth._handleAuthResponse(url) + } finally { + /** Restore the original `window.location.replaceState` function. */ + window.history.replaceState = replaceState + } + })() } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx index ae1746f564..3384ed577a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx @@ -34,41 +34,41 @@ * {@link router.Route}s require fully authenticated users (c.f. * {@link authProvider.FullUserSession}). */ -import * as react from "react"; -import * as router from "react-router-dom"; -import * as toast from "react-hot-toast"; +import * as react from 'react' +import * as router from 'react-router-dom' +import * as toast from 'react-hot-toast' -import * as authProvider from "../authentication/providers/auth"; -import * as authService from "../authentication/service"; -import * as loggerProvider from "../providers/logger"; -import * as platformModule from "../platform"; -import * as session from "../authentication/providers/session"; -import ConfirmRegistration from "../authentication/components/confirmRegistration"; -import Dashboard from "../dashboard/components/dashboard"; -import ForgotPassword from "../authentication/components/forgotPassword"; -import Login from "../authentication/components/login"; -import Registration from "../authentication/components/registration"; -import ResetPassword from "../authentication/components/resetPassword"; -import SetUsername from "../authentication/components/setUsername"; +import * as authProvider from '../authentication/providers/auth' +import * as authService from '../authentication/service' +import * as loggerProvider from '../providers/logger' +import * as platformModule from '../platform' +import * as session from '../authentication/providers/session' +import ConfirmRegistration from '../authentication/components/confirmRegistration' +import Dashboard from '../dashboard/components/dashboard' +import ForgotPassword from '../authentication/components/forgotPassword' +import Login from '../authentication/components/login' +import Registration from '../authentication/components/registration' +import ResetPassword from '../authentication/components/resetPassword' +import SetUsername from '../authentication/components/setUsername' // ================= // === Constants === // ================= /** Path to the root of the app (i.e., the Cloud dashboard). */ -export const DASHBOARD_PATH = "/"; +export const DASHBOARD_PATH = '/' /** Path to the login page. */ -export const LOGIN_PATH = "/login"; +export const LOGIN_PATH = '/login' /** Path to the registration page. */ -export const REGISTRATION_PATH = "/registration"; +export const REGISTRATION_PATH = '/registration' /** Path to the confirm registration page. */ -export const CONFIRM_REGISTRATION_PATH = "/confirmation"; +export const CONFIRM_REGISTRATION_PATH = '/confirmation' /** Path to the forgot password page. */ -export const FORGOT_PASSWORD_PATH = "/forgot-password"; +export const FORGOT_PASSWORD_PATH = '/forgot-password' /** Path to the reset password page. */ -export const RESET_PASSWORD_PATH = "/password-reset"; +export const RESET_PASSWORD_PATH = '/password-reset' /** Path to the set username page. */ -export const SET_USERNAME_PATH = "/set-username"; +export const SET_USERNAME_PATH = '/set-username' // =========== // === App === @@ -76,10 +76,10 @@ export const SET_USERNAME_PATH = "/set-username"; /** Global configuration for the `App` component. */ export interface AppProps { - /** Logger to use for logging. */ - logger: loggerProvider.Logger; - platform: platformModule.Platform; - onAuthenticated: () => void; + /** Logger to use for logging. */ + logger: loggerProvider.Logger + platform: platformModule.Platform + onAuthenticated: () => void } /** Component called by the parent module, returning the root React component for this @@ -88,23 +88,21 @@ export interface AppProps { * This component handles all the initialization and rendering of the app, and manages the app's * routes. It also initializes an `AuthProvider` that will be used by the rest of the app. */ function App(props: AppProps) { - const { platform } = props; - // This is a React component even though it does not contain JSX. - // eslint-disable-next-line no-restricted-syntax - const Router = - platform === platformModule.Platform.desktop - ? router.MemoryRouter - : router.BrowserRouter; - /** Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` - * will redirect the user between the login/register pages and the dashboard. */ - return ( - <> - - - - - - ); + const { platform } = props + // This is a React component even though it does not contain JSX. + // eslint-disable-next-line no-restricted-syntax + const Router = + platform === platformModule.Platform.desktop ? router.MemoryRouter : router.BrowserRouter + /** Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` + * will redirect the user between the login/register pages and the dashboard. */ + return ( + <> + + + + + + ) } // ================= @@ -117,66 +115,54 @@ function App(props: AppProps) { * because the {@link AppRouter} relies on React hooks, which can't be used in the same React * component as the component that defines the provider. */ function AppRouter(props: AppProps) { - const { logger, onAuthenticated } = props; - const navigate = router.useNavigate(); - const mainPageUrl = new URL(window.location.href); - const memoizedAuthService = react.useMemo(() => { - const authConfig = { navigate, ...props }; - return authService.initAuthService(authConfig); - }, [navigate, props]); - const userSession = memoizedAuthService.cognito.userSession.bind( - memoizedAuthService.cognito - ); - const registerAuthEventListener = - memoizedAuthService.registerAuthEventListener; - return ( - - - - - - {/* Login & registration pages are visible to unauthenticated users. */} - }> - } - /> - } /> - - {/* Protected pages are visible to authenticated users. */} - }> - } /> - } - /> - - {/* Other pages are visible to unauthenticated and authenticated users. */} - } - /> - } - /> - } - /> - - - - - - ); + const { logger, onAuthenticated } = props + const navigate = router.useNavigate() + const mainPageUrl = new URL(window.location.href) + const memoizedAuthService = react.useMemo(() => { + const authConfig = { navigate, ...props } + return authService.initAuthService(authConfig) + }, [navigate, props]) + const userSession = memoizedAuthService.cognito.userSession.bind(memoizedAuthService.cognito) + const registerAuthEventListener = memoizedAuthService.registerAuthEventListener + return ( + + + + + + {/* Login & registration pages are visible to unauthenticated users. */} + }> + } /> + } /> + + {/* Protected pages are visible to authenticated users. */} + }> + } /> + } /> + + {/* Other pages are visible to unauthenticated and authenticated users. */} + } + /> + } + /> + } /> + + + + + + ) } -export default App; +export default App diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx index cb36cc1c26..c93c757c3b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx @@ -10,23 +10,22 @@ /** Path data for the SVG icons used in app. */ export const PATHS = { - /** Path data for the `@` icon SVG. */ - at: - "M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 " + - "8.959 0 01-4.5 1.207", - /** Path data for the lock icon SVG. */ - lock: - "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 " + - "0 00-8 0v4h8z", - /** Path data for the "right arrow" icon SVG. */ - rightArrow: "M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z", - /** Path data for the "create account" icon SVG. */ - createAccount: - "M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z", - /** Path data for the "go back" icon SVG. */ - goBack: - "M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1", -} as const; + /** Path data for the `@` icon SVG. */ + at: + 'M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 ' + + '8.959 0 01-4.5 1.207', + /** Path data for the lock icon SVG. */ + lock: + 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 ' + + '0 00-8 0v4h8z', + /** Path data for the "right arrow" icon SVG. */ + rightArrow: 'M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z', + /** Path data for the "create account" icon SVG. */ + createAccount: + 'M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z', + /** Path data for the "go back" icon SVG. */ + goBack: 'M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1', +} as const // =========== // === Svg === @@ -34,7 +33,7 @@ export const PATHS = { /** Props for the `Svg` component. */ interface Props { - data: string; + data: string } /** Component for rendering SVG icons. @@ -42,17 +41,17 @@ interface Props { * @param props - Extra props for the SVG path. The `props.data` field in particular contains the * SVG path data. */ export function Svg(props: Props) { - return ( - - - - ); + return ( + + + + ) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx index 50f86469b2..81ce42f593 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx @@ -1,22 +1,22 @@ /** @file Main dashboard component, responsible for listing user's projects as well as other * interactive components. */ -import * as auth from "../../authentication/providers/auth"; +import * as auth from '../../authentication/providers/auth' // ================= // === Dashboard === // ================= function Dashboard() { - const { signOut } = auth.useAuth(); - const { accessToken } = auth.useFullUserSession(); - return ( - <> -

This is a placeholder page for the cloud dashboard.

-

Access token: {accessToken}

- - - ); + const { signOut } = auth.useAuth() + const { accessToken } = auth.useFullUserSession() + return ( + <> +

This is a placeholder page for the cloud dashboard.

+

Access token: {accessToken}

+ + + ) } -export default Dashboard; +export default Dashboard diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.tsx index 731109dfc4..010184c969 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.tsx @@ -2,18 +2,18 @@ * * Each exported function in the {@link Backend} in this module corresponds to an API endpoint. The * functions are asynchronous and return a `Promise` that resolves to the response from the API. */ -import * as config from "../config"; -import * as http from "../http"; -import * as loggerProvider from "../providers/logger"; +import * as config from '../config' +import * as http from '../http' +import * as loggerProvider from '../providers/logger' // ================= // === Constants === // ================= /** Relative HTTP path to the "set username" endpoint of the Cloud backend API. */ -const SET_USER_NAME_PATH = "users"; +const SET_USER_NAME_PATH = 'users' /** Relative HTTP path to the "get user" endpoint of the Cloud backend API. */ -const GET_USER_PATH = "users/me"; +const GET_USER_PATH = 'users/me' // ============= // === Types === @@ -21,15 +21,15 @@ const GET_USER_PATH = "users/me"; /** A user/organization in the application. These are the primary owners of a project. */ export interface Organization { - id: string; - userEmail: string; - name: string; + id: string + userEmail: string + name: string } /** HTTP request body for the "set username" endpoint. */ export interface SetUsernameRequestBody { - userName: string; - userEmail: string; + userName: string + userEmail: string } // =============== @@ -38,63 +38,58 @@ export interface SetUsernameRequestBody { /** Class for sending requests to the Cloud backend API endpoints. */ export class Backend { - /** Creates a new instance of the {@link Backend} API client. - * - * @throws An error if the `Authorization` header is not set on the given `client`. */ - constructor( - private readonly client: http.Client, - private readonly logger: loggerProvider.Logger - ) { - /** All of our API endpoints are authenticated, so we expect the `Authorization` header to be - * set. */ - if (!this.client.defaultHeaders?.has("Authorization")) { - throw new Error("Authorization header not set."); + /** Creates a new instance of the {@link Backend} API client. + * + * @throws An error if the `Authorization` header is not set on the given `client`. */ + constructor( + private readonly client: http.Client, + private readonly logger: loggerProvider.Logger + ) { + /** All of our API endpoints are authenticated, so we expect the `Authorization` header to be + * set. */ + if (!this.client.defaultHeaders?.has('Authorization')) { + throw new Error('Authorization header not set.') + } } - } - /** Returns a {@link RequestBuilder} for an HTTP GET request to the given path. */ - get(path: string) { - return this.client.get(`${config.ACTIVE_CONFIG.apiUrl}/${path}`); - } + /** Returns a {@link RequestBuilder} for an HTTP GET request to the given path. */ + get(path: string) { + return this.client.get(`${config.ACTIVE_CONFIG.apiUrl}/${path}`) + } - /** Returns a {@link RequestBuilder} for an HTTP POST request to the given path. */ - post(path: string, payload: object) { - return this.client.post( - `${config.ACTIVE_CONFIG.apiUrl}/${path}`, - payload - ); - } + /** Returns a {@link RequestBuilder} for an HTTP POST request to the given path. */ + post(path: string, payload: object) { + return this.client.post(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload) + } - /** Logs the error that occurred and throws a new one with a more user-friendly message. */ - errorHandler(message: string) { - return (error: Error) => { - this.logger.error(error.message); - throw new Error(message); - }; - } + /** Logs the error that occurred and throws a new one with a more user-friendly message. */ + errorHandler(message: string) { + return (error: Error) => { + this.logger.error(error.message) + throw new Error(message) + } + } - /** Sets the username of the current user, on the Cloud backend API. */ - setUsername(body: SetUsernameRequestBody): Promise { - return this.post(SET_USER_NAME_PATH, body).then((response) => - response.json() - ); - } + /** Sets the username of the current user, on the Cloud backend API. */ + setUsername(body: SetUsernameRequestBody): Promise { + return this.post(SET_USER_NAME_PATH, body).then(response => response.json()) + } - /** Returns organization info for the current user, from the Cloud backend API. - * - * @returns `null` if status code 401 or 404 was received. */ - getUser(): Promise { - return this.get(GET_USER_PATH).then((response) => { - if ( - response.status === http.HttpStatus.unauthorized || - response.status === http.HttpStatus.notFound - ) { - return null; - } else { - return response.json(); - } - }); - } + /** Returns organization info for the current user, from the Cloud backend API. + * + * @returns `null` if status code 401 or 404 was received. */ + getUser(): Promise { + return this.get(GET_USER_PATH).then(response => { + if ( + response.status === http.HttpStatus.unauthorized || + response.status === http.HttpStatus.notFound + ) { + return null + } else { + return response.json() + } + }) + } } // ===================== @@ -107,12 +102,9 @@ export class Backend { * This is a hack to quickly create the backend in the format we want, until we get the provider * working. This should be removed entirely in favour of creating the backend once and using it from * the context. */ -export function createBackend( - accessToken: string, - logger: loggerProvider.Logger -): Backend { - const headers = new Headers(); - headers.append("Authorization", `Bearer ${accessToken}`); - const client = new http.Client(headers); - return new Backend(client, logger); +export function createBackend(accessToken: string, logger: loggerProvider.Logger): Backend { + const headers = new Headers() + headers.append('Authorization', `Bearer ${accessToken}`) + const client = new http.Client(headers) + return new Backend(client, logger) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx index eaa8292f77..91848eca65 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx @@ -1,7 +1,7 @@ /** @file Module containing common custom React hooks used throughout out Dashboard. */ -import * as react from "react"; +import * as react from 'react' -import * as loggerProvider from "./providers/logger"; +import * as loggerProvider from './providers/logger' // ============ // === Bind === @@ -21,8 +21,8 @@ import * as loggerProvider from "./providers/logger"; * * ``` */ interface Bind { - value: string; - onChange: (value: react.ChangeEvent) => void; + value: string + onChange: (value: react.ChangeEvent) => void } // ================ @@ -37,12 +37,12 @@ interface Bind { * use the `value` prop and the `onChange` event handler. However, this can be tedious to do for * every input field, so we can use a custom hook to handle this for us. */ export function useInput(initialValue: string): [string, Bind] { - const [value, setValue] = react.useState(initialValue); - const onChange = (event: react.ChangeEvent) => { - setValue(event.target.value); - }; - const bind = { value, onChange }; - return [value, bind]; + const [value, setValue] = react.useState(initialValue) + const onChange = (event: react.ChangeEvent) => { + setValue(event.target.value) + } + const bind = { value, onChange } + return [value, bind] } // ====================== @@ -65,37 +65,37 @@ export function useInput(initialValue: string): [string, Bind] { * @param deps - The list of dependencies that, when updated, trigger the asynchronous fetch. * @returns The current value of the state controlled by this hook. */ export function useAsyncEffect( - initialValue: T, - fetch: (signal: AbortSignal) => Promise, - deps?: react.DependencyList + initialValue: T, + fetch: (signal: AbortSignal) => Promise, + deps?: react.DependencyList ): T { - const logger = loggerProvider.useLogger(); - const [value, setValue] = react.useState(initialValue); + const logger = loggerProvider.useLogger() + const [value, setValue] = react.useState(initialValue) - react.useEffect(() => { - const controller = new AbortController(); - const { signal } = controller; + react.useEffect(() => { + const controller = new AbortController() + const { signal } = controller - /** Declare the async data fetching function. */ - const load = async () => { - const result = await fetch(signal); + /** Declare the async data fetching function. */ + const load = async () => { + const result = await fetch(signal) - /** Set state with the result only if this effect has not been aborted. This prevents race - * conditions by making it so that only the latest async fetch will update the state on - * completion. */ - if (!signal.aborted) { - setValue(result); - } - }; + /** Set state with the result only if this effect has not been aborted. This prevents race + * conditions by making it so that only the latest async fetch will update the state on + * completion. */ + if (!signal.aborted) { + setValue(result) + } + } - load().catch((error) => { - logger.error("Error while fetching data", error); - }); - /** Cancel any future `setValue` calls. */ - return () => { - controller.abort(); - }; - }, deps); + load().catch(error => { + logger.error('Error while fetching data', error) + }) + /** Cancel any future `setValue` calls. */ + return () => { + controller.abort() + } + }, deps) - return value; + return value } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx index eb5bd3c5a3..930a28031b 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/http.tsx @@ -8,10 +8,10 @@ /** HTTP status codes returned in a HTTP response. */ export enum HttpStatus { - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - unauthorized = 401, - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - notFound = 404, + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + unauthorized = 401, + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + notFound = 404, } // ================== @@ -20,10 +20,10 @@ export enum HttpStatus { /** HTTP method variants that can be used in an HTTP request. */ enum HttpMethod { - get = "GET", - post = "POST", - put = "PUT", - delete = "DELETE", + get = 'GET', + post = 'POST', + put = 'PUT', + delete = 'DELETE', } // ============== @@ -32,95 +32,82 @@ enum HttpMethod { /** A helper function to convert a `Blob` to a base64-encoded string. */ function blobToBase64(blob: Blob) { - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => { - resolve( - // This cast is always safe because we read as data URL (a string). - // eslint-disable-next-line no-restricted-syntax - (reader.result as string).replace( - /^data:application\/octet-stream;base64,/, - "" - ) - ); - }; - reader.readAsDataURL(blob); - }); + return new Promise(resolve => { + const reader = new FileReader() + reader.onload = () => { + resolve( + // This cast is always safe because we read as data URL (a string). + // eslint-disable-next-line no-restricted-syntax + (reader.result as string).replace(/^data:application\/octet-stream;base64,/, '') + ) + } + reader.readAsDataURL(blob) + }) } /** An HTTP client that can be used to create and send HTTP requests asynchronously. */ export class Client { - constructor( - /** A map of default headers that are included in every HTTP request sent by this client. - * - * This is useful for setting headers that are required for every request, like authentication - * tokens. */ - public defaultHeaders?: Headers - ) {} + constructor( + /** A map of default headers that are included in every HTTP request sent by this client. + * + * This is useful for setting headers that are required for every request, like authentication + * tokens. */ + public defaultHeaders?: Headers + ) {} - /** Sends an HTTP GET request to the specified URL. */ - get(url: string) { - return this.request(HttpMethod.get, url); - } - - /** Sends a JSON HTTP POST request to the specified URL. */ - post(url: string, payload: object) { - return this.request( - HttpMethod.post, - url, - JSON.stringify(payload), - "application/json" - ); - } - - /** Sends a base64-encoded binary HTTP POST request to the specified URL. */ - async postBase64(url: string, payload: Blob) { - return await this.request( - HttpMethod.post, - url, - await blobToBase64(payload), - "application/octet-stream" - ); - } - - /** Sends a JSON HTTP PUT request to the specified URL. */ - put(url: string, payload: object) { - return this.request( - HttpMethod.put, - url, - JSON.stringify(payload), - "application/json" - ); - } - - /** Sends an HTTP DELETE request to the specified URL. */ - delete(url: string) { - return this.request(HttpMethod.delete, url); - } - - /** Executes an HTTP request to the specified URL, with the given HTTP method. */ - private request( - method: HttpMethod, - url: string, - payload?: string, - mimetype?: string - ) { - const defaultHeaders = this.defaultHeaders ?? []; - const headers = new Headers(defaultHeaders); - if (payload) { - const contentType = mimetype ?? "application/json"; - headers.set("Content-Type", contentType); + /** Sends an HTTP GET request to the specified URL. */ + get(url: string) { + return this.request(HttpMethod.get, url) } - interface ResponseWithTypedJson extends Response { - json: () => Promise; + + /** Sends a JSON HTTP POST request to the specified URL. */ + post(url: string, payload: object) { + return this.request(HttpMethod.post, url, JSON.stringify(payload), 'application/json') + } + + /** Sends a base64-encoded binary HTTP POST request to the specified URL. */ + async postBase64(url: string, payload: Blob) { + return await this.request( + HttpMethod.post, + url, + await blobToBase64(payload), + 'application/octet-stream' + ) + } + + /** Sends a JSON HTTP PUT request to the specified URL. */ + put(url: string, payload: object) { + return this.request(HttpMethod.put, url, JSON.stringify(payload), 'application/json') + } + + /** Sends an HTTP DELETE request to the specified URL. */ + delete(url: string) { + return this.request(HttpMethod.delete, url) + } + + /** Executes an HTTP request to the specified URL, with the given HTTP method. */ + private request( + method: HttpMethod, + url: string, + payload?: string, + mimetype?: string + ) { + const defaultHeaders = this.defaultHeaders ?? [] + const headers = new Headers(defaultHeaders) + if (payload) { + const contentType = mimetype ?? 'application/json' + headers.set('Content-Type', contentType) + } + interface ResponseWithTypedJson extends Response { + json: () => Promise + } + // This is an UNSAFE type assertion, however this is a HTTP client + // and should only be used to query APIs with known response types. + // eslint-disable-next-line no-restricted-syntax + return fetch(url, { + method, + headers, + ...(payload ? { body: payload } : {}), + }) as Promise> } - // This is an UNSAFE type assertion, however this is a HTTP client - // and should only be used to query APIs with known response types. - // eslint-disable-next-line no-restricted-syntax - return fetch(url, { - method, - headers, - ...(payload ? { body: payload } : {}), - }) as Promise>; - } } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx index 69ab1de567..6f6368ad9a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/index.tsx @@ -10,19 +10,19 @@ // as per the above comment. // @ts-expect-error See above comment for why this import is needed. // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-restricted-syntax -import * as React from "react"; -import * as reactDOM from "react-dom/client"; +import * as React from 'react' +import * as reactDOM from 'react-dom/client' -import * as loggerProvider from "./providers/logger"; -import * as platformModule from "./platform"; -import App, * as app from "./components/app"; +import * as loggerProvider from './providers/logger' +import * as platformModule from './platform' +import App, * as app from './components/app' // ================= // === Constants === // ================= /** The `id` attribute of the root element that the app will be rendered into. */ -const ROOT_ELEMENT_ID = "dashboard"; +const ROOT_ELEMENT_ID = 'dashboard' // =========== // === run === @@ -36,23 +36,23 @@ const ROOT_ELEMENT_ID = "dashboard"; // This is not a React component even though it contains JSX. // eslint-disable-next-line no-restricted-syntax export function run( - /** Logger to use for logging. */ - logger: loggerProvider.Logger, - platform: platformModule.Platform, - onAuthenticated: () => void + /** Logger to use for logging. */ + logger: loggerProvider.Logger, + platform: platformModule.Platform, + onAuthenticated: () => void ) { - logger.log("Starting authentication/dashboard UI."); - /** The root element that the authentication/dashboard app will be rendered into. */ - const root = document.getElementById(ROOT_ELEMENT_ID); - if (root == null) { - logger.error(`Could not find root element with ID '${ROOT_ELEMENT_ID}'.`); - } else { - const props = { logger, platform, onAuthenticated }; - reactDOM.createRoot(root).render(); - } + logger.log('Starting authentication/dashboard UI.') + /** The root element that the authentication/dashboard app will be rendered into. */ + const root = document.getElementById(ROOT_ELEMENT_ID) + if (root == null) { + logger.error(`Could not find root element with ID '${ROOT_ELEMENT_ID}'.`) + } else { + const props = { logger, platform, onAuthenticated } + reactDOM.createRoot(root).render() + } } -export type AppProps = app.AppProps; +export type AppProps = app.AppProps // This export should be `PascalCase` because it is a re-export. // eslint-disable-next-line no-restricted-syntax -export const Platform = platformModule.Platform; +export const Platform = platformModule.Platform diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/providers/logger.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/providers/logger.tsx index 0bd6f1a822..e2649592b8 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/providers/logger.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/providers/logger.tsx @@ -1,6 +1,6 @@ /** @file Defines the React provider for the {@link Logger} interface, along with a hook to use the * provider via the shared React context. */ -import * as react from "react"; +import * as react from 'react' // ============== // === Logger === @@ -11,10 +11,10 @@ import * as react from "react"; * In the browser, this is the `Console` interface. In Electron, this is the `Logger` interface * provided by the EnsoGL packager. */ export interface Logger { - /** Logs a message to the console. */ - log: (message: unknown, ...optionalParams: unknown[]) => void; - /** Logs an error message to the console. */ - error: (message: unknown, ...optionalParams: unknown[]) => void; + /** Logs a message to the console. */ + log: (message: unknown, ...optionalParams: unknown[]) => void + /** Logs an error message to the console. */ + error: (message: unknown, ...optionalParams: unknown[]) => void } // ===================== @@ -23,22 +23,20 @@ export interface Logger { /** See {@link AuthContext} for safety details. */ // eslint-disable-next-line no-restricted-syntax -const LoggerContext = react.createContext({} as Logger); +const LoggerContext = react.createContext({} as Logger) // ====================== // === LoggerProvider === // ====================== interface LoggerProviderProps { - children: react.ReactNode; - logger: Logger; + children: react.ReactNode + logger: Logger } export function LoggerProvider(props: LoggerProviderProps) { - const { children, logger } = props; - return ( - {children} - ); + const { children, logger } = props + return {children} } // ================= @@ -46,5 +44,5 @@ export function LoggerProvider(props: LoggerProviderProps) { // ================= export function useLogger() { - return react.useContext(LoggerContext); + return react.useContext(LoggerContext) } diff --git a/app/ide-desktop/lib/dashboard/src/index.tsx b/app/ide-desktop/lib/dashboard/src/index.tsx index 6cfc1d792e..a892433fe1 100644 --- a/app/ide-desktop/lib/dashboard/src/index.tsx +++ b/app/ide-desktop/lib/dashboard/src/index.tsx @@ -1,15 +1,15 @@ /** @file Index file declaring main DOM structure for the app. */ -import * as authentication from "enso-authentication"; +import * as authentication from 'enso-authentication' -import * as platform from "./authentication/src/platform"; +import * as platform from './authentication/src/platform' -const logger = console; +const logger = console /** This package is a standalone React app (i.e., IDE deployed to the Cloud), so we're not * running on the desktop. */ -const PLATFORM = platform.Platform.cloud; +const PLATFORM = platform.Platform.cloud // The `onAuthenticated` parameter is required but we don't need it, so we pass an empty function. // eslint-disable-next-line @typescript-eslint/no-empty-function function onAuthenticated() {} -authentication.run(logger, PLATFORM, onAuthenticated); +authentication.run(logger, PLATFORM, onAuthenticated)