From 6693bdb5cd46dc814b28f0533710ea5c84002fc6 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 26 May 2023 01:44:15 +1000 Subject: [PATCH 1/5] Fallback to opened date when ordering projects (#6814) Fixes #6787 # Important Notes I can't get Project Manager compilation to work locally so I guess I'll be relying on CI to verify that it's working correctly? Of course, QA should be able to catch any problems too - the websocket API hasn't been changed so it should work out of the box with the current dashboard. --- .../RecentlyUsedProjectsOrdering.scala | 6 +-- .../protocol/ProjectManagementApiSpec.scala | 40 ++++++++++++------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/RecentlyUsedProjectsOrdering.scala b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/RecentlyUsedProjectsOrdering.scala index 9bddf5810c62..6107692163ef 100644 --- a/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/RecentlyUsedProjectsOrdering.scala +++ b/lib/scala/project-manager/src/main/scala/org/enso/projectmanager/service/RecentlyUsedProjectsOrdering.scala @@ -17,9 +17,9 @@ object RecentlyUsedProjectsOrdering extends Ordering[Project] { private val Equal = 0 override def compare(x: Project, y: Project): Int = { - val xOpened = x.lastOpened.getOrElse(OffsetDateTime.MIN) - val yOpened = y.lastOpened.getOrElse(OffsetDateTime.MIN) - val result = compareDates(xOpened, yOpened) + val xOpenedOrCreated = x.lastOpened.getOrElse(x.created) + val yOpenedOrCreated = y.lastOpened.getOrElse(y.created) + val result = compareDates(xOpenedOrCreated, yOpenedOrCreated) if (result == Equal) compareDates(x.created, y.created) else result } diff --git a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala index 8ec637455929..435fe19b28cc 100644 --- a/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala +++ b/lib/scala/project-manager/src/test/scala/org/enso/projectmanager/protocol/ProjectManagementApiSpec.scala @@ -877,22 +877,23 @@ class ProjectManagementApiSpec val creationTime = testClock.currentTime val fooId = createProject("Foo") val barId = createProject("Bar") - testClock.moveTimeForward() - openProject(fooId) - val fooOpenTime = testClock.currentTime + val bazId = createProject("Baz") testClock.moveTimeForward() openProject(barId) val barOpenTime = testClock.currentTime testClock.moveTimeForward() - val projectBazCreationTime = testClock.currentTime - val bazId = createProject("Baz") + openProject(bazId) + val bazOpenTime = testClock.currentTime + testClock.moveTimeForward() + val projectQuuxCreationTime = testClock.currentTime + val quuxId = createProject("Quux") //when client.send(json""" { "jsonrpc": "2.0", "method": "project/list", "id": 0, "params": { - "numberOfProjects": 3 + "numberOfProjects": 4 } } """) @@ -903,6 +904,22 @@ class ProjectManagementApiSpec "id":0, "result": { "projects": [ + { + "name": "Quux", + "namespace": "local", + "id": $quuxId, + "engineVersion": $engineToInstall, + "created": $projectQuuxCreationTime, + "lastOpened": null + }, + { + "name": "Baz", + "namespace": "local", + "id": $bazId, + "engineVersion": $engineToInstall, + "created": $creationTime, + "lastOpened": $bazOpenTime + }, { "name": "Bar", "namespace": "local", @@ -917,14 +934,6 @@ class ProjectManagementApiSpec "id": $fooId, "engineVersion": $engineToInstall, "created": $creationTime, - "lastOpened": $fooOpenTime - }, - { - "name": "Baz", - "namespace": "local", - "id": $bazId, - "engineVersion": $engineToInstall, - "created": $projectBazCreationTime, "lastOpened": null } ] @@ -932,11 +941,12 @@ class ProjectManagementApiSpec } """) //teardown - closeProject(fooId) closeProject(barId) + closeProject(bazId) deleteProject(fooId) deleteProject(barId) deleteProject(bazId) + deleteProject(quuxId) } "resolve clashing ids" taggedAs Flaky in { From 89d5b11e0463928f4b159b7e45ae20e41f48c552 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 26 May 2023 16:26:45 +1000 Subject: [PATCH 2/5] Fix "set username" screen (#6824) * Fix cloud-v2/#432 * Delay setting backend to local backend; don't list directory if user is not enabled * Add a way to debug specific dashboard paths * Fix bug * Check resources and status immediately --- .../src/authentication/providers/auth.tsx | 32 +++++++++++++------ .../src/authentication/src/components/app.tsx | 23 ++++++------- .../src/dashboard/components/dashboard.tsx | 21 +++++++++--- .../components/projectActionButton.tsx | 7 +++- 4 files changed, 56 insertions(+), 27 deletions(-) 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 9de5aa889dfb..4c196051f40c 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 @@ -140,7 +140,6 @@ const AuthContext = react.createContext({} as AuthContextType) /** Props for an {@link AuthProvider}. */ export interface AuthProviderProps { authService: authServiceModule.AuthService - platform: platformModule.Platform /** Callback to execute once the user has authenticated successfully. */ onAuthenticated: () => void children: react.ReactNode @@ -148,13 +147,12 @@ export interface AuthProviderProps { /** A React provider for the Cognito API. */ export function AuthProvider(props: AuthProviderProps) { - const { authService, platform, children } = props + const { authService, onAuthenticated, children } = props const { cognito } = authService const { session } = sessionProvider.useSession() const { setBackend } = backendProvider.useSetBackend() 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) @@ -174,7 +172,9 @@ export function AuthProvider(props: AuthProviderProps) { headers.append('Authorization', `Bearer ${accessToken}`) const client = new http.Client(headers) const backend = new remoteBackend.RemoteBackend(client, logger) - if (platform === platformModule.Platform.cloud) { + // The backend MUST be the remote backend before login is finished. + // This is because the "set username" flow requires the remote backend. + if (!initialized || userSession == null) { setBackend(backend) } const organization = await backend.usersMe().catch(() => null) @@ -326,6 +326,7 @@ export function AuthProvider(props: AuthProviderProps) { } const signOut = async () => { + setInitialized(false) await cognito.signOut() toast.success(MESSAGES.signOutSuccess) return true @@ -387,6 +388,16 @@ export function useAuth() { return react.useContext(AuthContext) } +// =============================== +// === shouldPreventNavigation === +// =============================== + +/** True if navigation should be prevented, for debugging purposes. */ +function getShouldPreventNavigation() { + const location = router.useLocation() + return new URLSearchParams(location.search).get('prevent-navigation') === 'true' +} + // ======================= // === ProtectedLayout === // ======================= @@ -394,10 +405,11 @@ export function useAuth() { /** A React Router layout route containing routes only accessible by users that are logged in. */ export function ProtectedLayout() { const { session } = useAuth() + const shouldPreventNavigation = getShouldPreventNavigation() - if (!session) { + if (!shouldPreventNavigation && !session) { return - } else if (session.type === UserSessionType.partial) { + } else if (!shouldPreventNavigation && session?.type === UserSessionType.partial) { return } else { return @@ -412,8 +424,9 @@ export function ProtectedLayout() { * in the process of registering. */ export function SemiProtectedLayout() { const { session } = useAuth() + const shouldPreventNavigation = getShouldPreventNavigation() - if (session?.type === UserSessionType.full) { + if (!shouldPreventNavigation && session?.type === UserSessionType.full) { return } else { return @@ -428,10 +441,11 @@ export function SemiProtectedLayout() { * not logged in. */ export function GuestLayout() { const { session } = useAuth() + const shouldPreventNavigation = getShouldPreventNavigation() - if (session?.type === UserSessionType.partial) { + if (!shouldPreventNavigation && session?.type === UserSessionType.partial) { return - } else if (session?.type === UserSessionType.full) { + } else if (!shouldPreventNavigation && session?.type === UserSessionType.full) { return } else { return 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 34888dc6007a..a7bb605bec00 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 @@ -39,7 +39,6 @@ import * as router from 'react-router-dom' import * as toast from 'react-hot-toast' import * as authService from '../authentication/service' -import * as localBackend from '../dashboard/localBackend' import * as platformModule from '../platform' import * as authProvider from '../authentication/providers/auth' @@ -122,8 +121,14 @@ 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, platform, showDashboard, onAuthenticated } = props + const { logger, showDashboard, onAuthenticated } = props const navigate = router.useNavigate() + // FIXME[sb]: After platform detection for Electron is merged in, `IS_DEV_MODE` should be + // set to true on `ide watch`. + if (IS_DEV_MODE) { + // @ts-expect-error This is used exclusively for debugging. + window.navigate = navigate + } const mainPageUrl = new URL(window.location.href) const memoizedAuthService = react.useMemo(() => { const authConfig = { navigate, ...props } @@ -164,20 +169,12 @@ function AppRouter(props: AppProps) { userSession={userSession} registerAuthEventListener={registerAuthEventListener} > - + {/* This is safe, because the backend is always set by the authentication flow. */} + {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + {routes} 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 68467555ba9d..8f11acad8e55 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 @@ -278,9 +278,17 @@ function Dashboard(props: DashboardProps) { backendModule.Asset[] >([]) + const canListDirectory = + backend.platform !== platformModule.Platform.cloud || organization.isEnabled const directory = directoryStack[directoryStack.length - 1] const parentDirectory = directoryStack[directoryStack.length - 2] + react.useEffect(() => { + if (platform === platformModule.Platform.desktop) { + setBackend(new localBackend.LocalBackend()) + } + }, []) + react.useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ( @@ -398,6 +406,7 @@ function Dashboard(props: DashboardProps) { { setProject(null) }} @@ -603,10 +612,14 @@ function Dashboard(props: DashboardProps) { hooks.useAsyncEffect( null, async signal => { - const assets = await backend.listDirectory({ parentId: directoryId }) - if (!signal.aborted) { + if (canListDirectory) { + const assets = await backend.listDirectory({ parentId: directoryId }) + if (!signal.aborted) { + setIsLoadingAssets(false) + setAssets(assets) + } + } else { setIsLoadingAssets(false) - setAssets(assets) } }, [accessToken, directoryId, refresh, backend] @@ -728,7 +741,7 @@ function Dashboard(props: DashboardProps) { query={query} setQuery={setQuery} /> - {backend.platform === platformModule.Platform.cloud && !organization.isEnabled ? ( + {!canListDirectory ? (
We will review your user details and enable the cloud experience for you diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx index 587c694c692d..4c410d7d78df 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx @@ -43,11 +43,12 @@ export interface ProjectActionButtonProps { appRunner: AppRunner | null onClose: () => void openIde: () => void + doRefresh: () => void } /** An interactive button displaying the status of a project. */ function ProjectActionButton(props: ProjectActionButtonProps) { - const { project, onClose, appRunner, openIde } = props + const { project, onClose, appRunner, openIde, doRefresh } = props const { backend } = backendProvider.useBackend() const [state, setState] = react.useState(backendModule.ProjectState.created) @@ -101,6 +102,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) { () => void checkProjectStatus(), CHECK_STATUS_INTERVAL_MS ) + void checkProjectStatus() return () => { clearInterval(handle) } @@ -132,6 +134,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) { () => void checkProjectResources(), CHECK_RESOURCES_INTERVAL_MS ) + void checkProjectResources() return () => { clearInterval(handle) } @@ -159,10 +162,12 @@ function ProjectActionButton(props: ProjectActionButtonProps) { switch (backend.platform) { case platform.Platform.cloud: await backend.openProject(project.id) + doRefresh() setIsCheckingStatus(true) break case platform.Platform.desktop: await backend.openProject(project.id) + doRefresh() setState(backendModule.ProjectState.opened) setSpinnerState(SpinnerState.done) break From 245ff8d32ec62bf616af74c454281857b897c453 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 26 May 2023 19:30:02 +1000 Subject: [PATCH 3/5] Fix JWT leak (#6815) Should fix [cloud-v2#464](https://github.com/enso-org/cloud-v2/issues/464). # Important Notes I'm not 100% clear on how to repro the issue so i'm partly just guessing the root cause. I have eliminated various other things from being potential causes though - e.g. `localStorage` indicates that the AWS libraries are clearing their entries as expected. --- .../src/authentication/providers/auth.tsx | 13 ++++++++++--- .../src/authentication/providers/session.tsx | 12 +++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) 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 4c196051f40c..0a6ad05c218f 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 @@ -31,7 +31,9 @@ const MESSAGES = { forgotPasswordSuccess: 'We have sent you an email with further instructions!', changePasswordSuccess: 'Successfully changed password!', resetPasswordSuccess: 'Successfully reset password!', + signOutLoading: 'Logging out...', signOutSuccess: 'Successfully logged out!', + signOutError: 'Error logging out, please try again.', pleaseWait: 'Please wait...', } as const @@ -149,7 +151,7 @@ export interface AuthProviderProps { export function AuthProvider(props: AuthProviderProps) { const { authService, onAuthenticated, children } = props const { cognito } = authService - const { session } = sessionProvider.useSession() + const { session, deinitializeSession } = sessionProvider.useSession() const { setBackend } = backendProvider.useSetBackend() const logger = loggerProvider.useLogger() const navigate = router.useNavigate() @@ -326,9 +328,14 @@ export function AuthProvider(props: AuthProviderProps) { } const signOut = async () => { + deinitializeSession() setInitialized(false) - await cognito.signOut() - toast.success(MESSAGES.signOutSuccess) + setUserSession(null) + await toast.promise(cognito.signOut(), { + success: MESSAGES.signOutSuccess, + error: MESSAGES.signOutError, + loading: MESSAGES.signOutLoading, + }) return true } 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 4fc45e826080..5639e3f1110b 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 @@ -16,6 +16,8 @@ import * as listen from '../listen' /** State contained in a {@link SessionContext}. */ interface SessionContextType { session: results.Option + /** Set `initialized` to false. Must be called when logging out. */ + deinitializeSession: () => void } /** See `AuthContext` for safety details. */ @@ -58,7 +60,7 @@ export function SessionProvider(props: SessionProviderProps) { const [initialized, setInitialized] = react.useState(false) /** 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 + * set. 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, @@ -112,10 +114,14 @@ export function SessionProvider(props: SessionProviderProps) { return cancel }, [registerAuthEventListener]) - const value = { session } + const deinitializeSession = () => { + setInitialized(false) + } return ( - {initialized && children} + + {initialized && children} + ) } From 079b1eed9d63f5eb44d38b5c73bb28af7e9b7d00 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 26 May 2023 20:17:03 +1000 Subject: [PATCH 4/5] Fix some dashboard issues (#6668) Fixes some of #6662 Issues addressed: - `ide watch` and `gui watch` should now use the desktop platform - error screen should now be shown when passing invalid options - password (both creating password when registering, and resetting password) should now warn on invalid input # Important Notes Instead of checking whether `location.hostname === 'localhost'`, I've opted to use a constant defined by the build tool instead. This is to make it easier to merge the cloud IDE and desktop IDE entrypoints in the future, since it would be able to simply set `platform: Platform.cloud` in the build config. --- app/ide-desktop/lib/client/start.ts | 2 +- app/ide-desktop/lib/client/watch.ts | 8 +- .../lib/content-config/src/config.json | 4 +- app/ide-desktop/lib/content/bundle.ts | 8 +- app/ide-desktop/lib/content/esbuild-config.ts | 40 ++++-- app/ide-desktop/lib/content/globals.d.ts | 15 ++ app/ide-desktop/lib/content/src/index.ts | 135 ++++++++++-------- app/ide-desktop/lib/content/start.ts | 6 +- app/ide-desktop/lib/content/watch.ts | 11 +- .../src/authentication/cognito.ts | 18 +-- .../components/registration.tsx | 4 + .../components/resetPassword.tsx | 4 + .../src/authentication/providers/auth.tsx | 23 +-- .../src/authentication/providers/session.tsx | 2 +- .../src/authentication/service.tsx | 90 ++++++------ .../src/authentication/src/components/app.tsx | 14 +- .../authentication/src/dashboard/backend.ts | 9 +- .../components/changePasswordModal.tsx | 3 + .../src/dashboard/components/dashboard.tsx | 103 ++++++++----- .../components/directoryCreateForm.tsx | 3 +- .../dashboard/components/fileCreateForm.tsx | 3 +- .../src/dashboard/components/ide.tsx | 21 ++- .../components/projectActionButton.tsx | 9 +- .../components/projectCreateForm.tsx | 3 +- .../dashboard/components/secretCreateForm.tsx | 3 +- .../src/dashboard/components/topBar.tsx | 19 +-- .../dashboard/components/uploadFileModal.tsx | 3 +- .../src/dashboard/localBackend.ts | 3 +- .../src/dashboard/projectManager.ts | 30 ++-- .../src/dashboard/remoteBackend.ts | 3 +- .../src/dashboard/validation.ts | 10 ++ .../src/authentication/src/detect.ts | 9 ++ .../src/authentication/src/index.tsx | 14 +- .../src/authentication/src/platform.ts | 21 --- app/ide-desktop/lib/dashboard/src/index.tsx | 21 ++- app/ide-desktop/lib/types/globals.d.ts | 6 +- lib/rust/ensogl/pack/js/src/runner/config.ts | 74 +++++++++- lib/rust/ensogl/pack/js/src/runner/index.ts | 87 +++-------- 38 files changed, 502 insertions(+), 339 deletions(-) create mode 100644 app/ide-desktop/lib/content/globals.d.ts create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/validation.ts create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/detect.ts delete mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/platform.ts diff --git a/app/ide-desktop/lib/client/start.ts b/app/ide-desktop/lib/client/start.ts index d4e8585ec934..1bca40140327 100644 --- a/app/ide-desktop/lib/client/start.ts +++ b/app/ide-desktop/lib/client/start.ts @@ -36,7 +36,7 @@ await esbuild.build(BUNDLER_OPTIONS) console.log('Linking GUI files.') await fs.symlink(path.join(GUI_PATH, 'assets'), path.join(IDE_PATH, 'assets'), 'dir') -console.log('LinkingProject Manager files.') +console.log('Linking Project Manager files.') await fs.symlink(PROJECT_MANAGER_BUNDLE, path.join(IDE_PATH, paths.PROJECT_MANAGER_BUNDLE), 'dir') console.log('Spawning Electron process.') diff --git a/app/ide-desktop/lib/client/watch.ts b/app/ide-desktop/lib/client/watch.ts index 21b72734afe7..6fd6d19643bb 100644 --- a/app/ide-desktop/lib/client/watch.ts +++ b/app/ide-desktop/lib/client/watch.ts @@ -93,7 +93,12 @@ const ALL_BUNDLES_READY = new Promise((resolve, reject) => { void dashboardBuilder.watch() console.log('Bundling content.') - const contentOpts = contentBundler.bundlerOptionsFromEnv() + const contentOpts = contentBundler.bundlerOptionsFromEnv({ + // This is in watch mode, however it runs its own server rather than an esbuild server. + devMode: false, + supportsLocalBackend: true, + supportsDeepLinks: false, + }) contentOpts.plugins.push({ name: 'enso-on-rebuild', setup: build => { @@ -103,6 +108,7 @@ const ALL_BUNDLES_READY = new Promise((resolve, reject) => { }, }) contentOpts.outdir = path.resolve(IDE_DIR_PATH, 'assets') + contentOpts.define.REDIRECT_OVERRIDE = JSON.stringify('http://localhost:8080') const contentBuilder = await esbuild.context(contentOpts) const content = await contentBuilder.rebuild() console.log('Result of content bundling: ', content) diff --git a/app/ide-desktop/lib/content-config/src/config.json b/app/ide-desktop/lib/content-config/src/config.json index c513b5e80a99..eff732d50106 100644 --- a/app/ide-desktop/lib/content-config/src/config.json +++ b/app/ide-desktop/lib/content-config/src/config.json @@ -1,7 +1,7 @@ { "options": { "authentication": { - "value": false, + "value": true, "description": "Determines whether user authentication is enabled. This option is always true when executed in the cloud." }, "dataCollection": { @@ -116,7 +116,7 @@ "primary": false }, "newDashboard": { - "value": false, + "value": true, "description": "Determines whether the new dashboard with cloud integration is enabled." }, "profiling": { diff --git a/app/ide-desktop/lib/content/bundle.ts b/app/ide-desktop/lib/content/bundle.ts index af9273ffacd3..9e7cb71fdb85 100644 --- a/app/ide-desktop/lib/content/bundle.ts +++ b/app/ide-desktop/lib/content/bundle.ts @@ -8,7 +8,13 @@ import * as bundler from './esbuild-config' // ======================= try { - void esbuild.build(bundler.bundleOptions()) + void esbuild.build( + bundler.bundleOptions({ + devMode: false, + supportsLocalBackend: true, + supportsDeepLinks: true, + }) + ) } catch (error) { console.error(error) throw error diff --git a/app/ide-desktop/lib/content/esbuild-config.ts b/app/ide-desktop/lib/content/esbuild-config.ts index e3f9555a7da4..222194bee60b 100644 --- a/app/ide-desktop/lib/content/esbuild-config.ts +++ b/app/ide-desktop/lib/content/esbuild-config.ts @@ -34,8 +34,20 @@ const THIS_PATH = pathModule.resolve(pathModule.dirname(url.fileURLToPath(import // === Environment variables === // ============================= +/** Arguments that must always be supplied, because they are not defined as + * environment variables. */ +export interface PassthroughArguments { + /** `true` if in development mode (live-reload), `false` if in production mode. */ + devMode: boolean + /** Whether the application may have the local backend running. */ + supportsLocalBackend: boolean + /** Whether the application supports deep links. This is only true when using + * the installed app on macOS and Windows. */ + supportsDeepLinks: boolean +} + /** Mandatory build options. */ -export interface Arguments { +export interface Arguments extends PassthroughArguments { /** List of files to be copied from WASM artifacts. */ wasmArtifacts: string /** Directory with assets. Its contents are to be copied. */ @@ -44,17 +56,15 @@ export interface Arguments { outputPath: string /** The main JS bundle to load WASM and JS wasm-pack bundles. */ ensoglAppPath: string - /** `true` if in development mode (live-reload), `false` if in production mode. */ - devMode: boolean } /** Get arguments from the environment. */ -export function argumentsFromEnv(): Arguments { +export function argumentsFromEnv(passthroughArguments: PassthroughArguments): Arguments { const wasmArtifacts = utils.requireEnv('ENSO_BUILD_GUI_WASM_ARTIFACTS') const assetsPath = utils.requireEnv('ENSO_BUILD_GUI_ASSETS') const outputPath = pathModule.resolve(utils.requireEnv('ENSO_BUILD_GUI'), 'assets') const ensoglAppPath = utils.requireEnv('ENSO_BUILD_GUI_ENSOGL_APP') - return { wasmArtifacts, assetsPath, outputPath, ensoglAppPath, devMode: false } + return { ...passthroughArguments, wasmArtifacts, assetsPath, outputPath, ensoglAppPath } } // =================== @@ -77,7 +87,15 @@ function git(command: string): string { /** Generate the builder options. */ export function bundlerOptions(args: Arguments) { - const { outputPath, ensoglAppPath, wasmArtifacts, assetsPath, devMode } = args + const { + outputPath, + ensoglAppPath, + wasmArtifacts, + assetsPath, + devMode, + supportsLocalBackend, + supportsDeepLinks, + } = args const buildOptions = { // Disabling naming convention because these are third-party options. /* eslint-disable @typescript-eslint/naming-convention */ @@ -138,6 +156,8 @@ export function bundlerOptions(args: Arguments) { /** Overrides the redirect URL for OAuth logins in the production environment. * This is needed for logins to work correctly under `./run gui watch`. */ REDIRECT_OVERRIDE: 'undefined', + SUPPORTS_LOCAL_BACKEND: JSON.stringify(supportsLocalBackend), + SUPPORTS_DEEP_LINKS: JSON.stringify(supportsDeepLinks), }, sourcemap: true, minify: true, @@ -165,13 +185,13 @@ export function bundlerOptions(args: Arguments) { * * Note that they should be further customized as per the needs of the specific workflow * (e.g. watch vs. build). */ -export function bundlerOptionsFromEnv() { - return bundlerOptions(argumentsFromEnv()) +export function bundlerOptionsFromEnv(passthroughArguments: PassthroughArguments) { + return bundlerOptions(argumentsFromEnv(passthroughArguments)) } /** esbuild options for bundling the package for a one-off build. * * Relies on the environment variables to be set. */ -export function bundleOptions() { - return bundlerOptionsFromEnv() +export function bundleOptions(passthroughArguments: PassthroughArguments) { + return bundlerOptionsFromEnv(passthroughArguments) } diff --git a/app/ide-desktop/lib/content/globals.d.ts b/app/ide-desktop/lib/content/globals.d.ts new file mode 100644 index 000000000000..3d4552b22e94 --- /dev/null +++ b/app/ide-desktop/lib/content/globals.d.ts @@ -0,0 +1,15 @@ +/** @file Globals defined only in this module. */ + +declare global { + // These are top-level constants, and therefore should be `CONSTANT_CASE`. + /* eslint-disable @typescript-eslint/naming-convention */ + /** Whether the */ + /** Whether the application may have the local backend running. */ + const SUPPORTS_LOCAL_BACKEND: boolean + /** Whether the application supports deep links. This is only true when using + * the installed app on macOS and Windows. */ + const SUPPORTS_DEEP_LINKS: boolean + /* eslint-enable @typescript-eslint/naming-convention */ +} + +export {} diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index 602e07146490..746e3ce4dae8 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -5,6 +5,7 @@ import * as semver from 'semver' import * as authentication from 'enso-authentication' +import * as common from 'enso-common' import * as contentConfig from 'enso-content-config' import * as app from '../../../../../target/ensogl-pack/linked-dist' @@ -16,6 +17,8 @@ const logger = app.log.logger // === Constants === // ================= +/** The name of the `localStorage` key storing the initial URL of the app. */ +const INITIAL_URL_KEY = `${common.PRODUCT_NAME.toLowerCase()}-initial-url` /** Path to the SSE endpoint over which esbuild sends events. */ const ESBUILD_PATH = '/esbuild' /** SSE event indicating a build has finished. */ @@ -39,6 +42,10 @@ if (IS_DEV_MODE) { location.href = location.href.toString() }) void navigator.serviceWorker.register(SERVICE_WORKER_PATH) +} else { + void navigator.serviceWorker + .getRegistration() + .then(serviceWorker => serviceWorker?.unregister()) } // ============= @@ -189,67 +196,83 @@ class Main implements AppRunner { /** The entrypoint into the IDE. */ main(inputConfig?: StringConfig) { - contentConfig.OPTIONS.loadAll([app.urlParams()]) - const isUsingAuthentication = contentConfig.OPTIONS.options.authentication.value - const isUsingNewDashboard = - contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value - const isOpeningMainEntryPoint = - contentConfig.OPTIONS.groups.startup.options.entry.value === - contentConfig.OPTIONS.groups.startup.options.entry.default - const isNotOpeningProject = - contentConfig.OPTIONS.groups.startup.options.project.value === '' - if ( - (isUsingAuthentication || isUsingNewDashboard) && - isOpeningMainEntryPoint && - isNotOpeningProject - ) { - const hideAuth = () => { - const auth = document.getElementById('dashboard') - const ide = document.getElementById('root') - if (auth) { - auth.style.display = 'none' - } - if (ide) { - ide.hidden = false - } - } - /** This package is an Electron desktop app (i.e., not in the Cloud), so - * we're running on the desktop. */ - /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 - * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE - * should only have one entry point. Right now, we have two. One for the cloud - * and one for the desktop. */ - const currentPlatform = contentConfig.OPTIONS.groups.startup.options.platform.value - let platform = authentication.Platform.desktop - if (currentPlatform === 'web') { - platform = authentication.Platform.cloud + /** Note: Signing out always redirects to `/`. It is impossible to make this work, + * as it is not possible to distinguish between having just logged out, and explicitly + * opening a page with no URL parameters set. + * + * Client-side routing endpoints are explicitly not supported for live-reload, as they are + * transitional pages that should not need live-reload when running `gui watch`. */ + const url = new URL(location.href) + const isInAuthenticationFlow = url.searchParams.has('code') && url.searchParams.has('state') + const authenticationUrl = location.href + if (isInAuthenticationFlow) { + history.replaceState(null, '', localStorage.getItem(INITIAL_URL_KEY)) + } + const parseOk = contentConfig.OPTIONS.loadAllAndDisplayHelpIfUnsuccessful([app.urlParams()]) + if (isInAuthenticationFlow) { + history.replaceState(null, '', authenticationUrl) + } else { + localStorage.setItem(INITIAL_URL_KEY, location.href) + } + if (parseOk) { + const isUsingAuthentication = contentConfig.OPTIONS.options.authentication.value + const isUsingNewDashboard = + contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value + const isOpeningMainEntryPoint = + contentConfig.OPTIONS.groups.startup.options.entry.value === + contentConfig.OPTIONS.groups.startup.options.entry.default + const isNotOpeningProject = + contentConfig.OPTIONS.groups.startup.options.project.value === '' + if ( + (isUsingAuthentication || isUsingNewDashboard) && + isOpeningMainEntryPoint && + isNotOpeningProject + ) { + this.runAuthentication(isInAuthenticationFlow, inputConfig) + } else { + void this.runApp(inputConfig) } - /** 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 = () => { + } + } + + /** Begins the authentication UI flow. */ + runAuthentication(isInAuthenticationFlow: boolean, inputConfig?: StringConfig) { + /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 + * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE + * should only have one entry point. Right now, we have two. One for the cloud + * and one for the 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. */ + authentication.run({ + appRunner: this, + logger, + supportsLocalBackend: SUPPORTS_LOCAL_BACKEND, + supportsDeepLinks: SUPPORTS_DEEP_LINKS, + showDashboard: contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, + onAuthenticated: () => { + if (isInAuthenticationFlow) { + const initialUrl = localStorage.getItem(INITIAL_URL_KEY) + if (initialUrl != null) { + // This is not used past this point, however it is set to the initial URL + // to make refreshing work as expected. + history.replaceState(null, '', initialUrl) + } + } if (!contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value) { - hideAuth() - if (!appInstanceRan) { - appInstanceRan = true + document.getElementById('enso-dashboard')?.remove() + const ide = document.getElementById('root') + if (ide) { + ide.hidden = false + } + if (this.app == null) { void this.runApp(inputConfig) } } - } - authentication.run({ - appRunner: this, - logger, - platform, - showDashboard: - contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, - onAuthenticated, - }) - } else { - void this.runApp(inputConfig) - } + }, + }) } } diff --git a/app/ide-desktop/lib/content/start.ts b/app/ide-desktop/lib/content/start.ts index 28441e899ca0..01cc95adb41f 100644 --- a/app/ide-desktop/lib/content/start.ts +++ b/app/ide-desktop/lib/content/start.ts @@ -17,7 +17,11 @@ const HTTP_STATUS_OK = 200 /** Start the esbuild watcher. */ async function watch() { - const opts = bundler.bundleOptions() + const opts = bundler.bundleOptions({ + devMode: true, + supportsLocalBackend: true, + supportsDeepLinks: false, + }) const builder = await esbuild.context(opts) await builder.watch() await builder.serve({ diff --git a/app/ide-desktop/lib/content/watch.ts b/app/ide-desktop/lib/content/watch.ts index 98b270f79686..091b48a87d70 100644 --- a/app/ide-desktop/lib/content/watch.ts +++ b/app/ide-desktop/lib/content/watch.ts @@ -31,10 +31,13 @@ async function watch() { // This MUST be called before `builder.watch()` as `tailwind.css` must be generated // before the copy plugin runs. await dashboardBuilder.watch() - const opts = bundler.bundlerOptions({ - ...bundler.argumentsFromEnv(), - devMode: true, - }) + const opts = bundler.bundlerOptions( + bundler.argumentsFromEnv({ + devMode: true, + supportsLocalBackend: true, + supportsDeepLinks: false, + }) + ) opts.define.REDIRECT_OVERRIDE = JSON.stringify('http://localhost:8080') opts.entryPoints.push({ in: path.resolve(THIS_PATH, 'src', 'serviceWorker.ts'), diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts index f7c9156bb0bf..44c179170636 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/cognito.ts @@ -34,8 +34,8 @@ import * as cognito from 'amazon-cognito-identity-js' import * as results from 'ts-results' import * as config from './config' +import * as detect from '../detect' import * as loggerProvider from '../providers/logger' -import * as platformModule from '../platform' // ================= // === Constants === @@ -144,7 +144,7 @@ export class Cognito { /** Create a new Cognito wrapper. */ constructor( private readonly logger: loggerProvider.Logger, - private readonly platform: platformModule.Platform, + private readonly supportsDeepLinks: boolean, private readonly amplifyConfig: config.AmplifyConfig ) { /** Amplify expects `Auth.configure` to be called before any other `Auth` methods are @@ -173,7 +173,7 @@ export class Cognito { * * Does not rely on federated identity providers (e.g., Google or GitHub). */ signUp(username: string, password: string) { - return signUp(username, password, this.platform) + return signUp(this.supportsDeepLinks, username, password) } /** Send the email address verification code. @@ -263,7 +263,7 @@ export class Cognito { * * See: https://github.com/aws-amplify/amplify-js/issues/3391#issuecomment-756473970 */ private customState() { - return this.platform === platformModule.Platform.desktop ? window.location.pathname : null + return detect.isRunningInElectron() ? window.location.pathname : null } } @@ -339,9 +339,9 @@ function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind { /** A wrapper around the Amplify "sign up" endpoint that converts known errors * to {@link SignUpError}s. */ -async function signUp(username: string, password: string, platform: platformModule.Platform) { +async function signUp(supportsDeepLinks: boolean, username: string, password: string) { const result = await results.Result.wrapAsync(async () => { - const params = intoSignUpParams(username, password, platform) + const params = intoSignUpParams(supportsDeepLinks, username, password) await amplify.Auth.signUp(params) }) return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignUpErrorOrThrow) @@ -349,9 +349,9 @@ async function signUp(username: string, password: string, platform: platformModu /** Format a username and password as an {@link amplify.SignUpParams}. */ function intoSignUpParams( + supportsDeepLinks: boolean, username: string, - password: string, - platform: platformModule.Platform + password: string ): amplify.SignUpParams { return { username, @@ -368,7 +368,7 @@ function intoSignUpParams( * It is necessary to disable the naming convention rule here, because the key is * expected to appear exactly as-is in Cognito, so we must match it. */ // eslint-disable-next-line @typescript-eslint/naming-convention - 'custom:fromDesktop': platform === platformModule.Platform.desktop ? 'true' : 'false', + ...(supportsDeepLinks ? { 'custom:fromDesktop': JSON.stringify(true) } : {}), }, } } 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 efdd6e3a12b4..527074fd132b 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 @@ -6,6 +6,8 @@ import toast from 'react-hot-toast' import * as app from '../../components/app' import * as auth from '../providers/auth' import * as svg from '../../components/svg' +import * as validation from '../../dashboard/validation' + import Input from './input' import SvgIcon from './svgIcon' @@ -83,6 +85,8 @@ function Registration() { type="password" name="password" placeholder="Password" + pattern={validation.PASSWORD_PATTERN} + title={validation.PASSWORD_TITLE} value={password} setValue={setPassword} /> 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 576e0f0b7d9c..ed0712eadbb6 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 @@ -7,6 +7,8 @@ import toast from 'react-hot-toast' import * as app from '../../components/app' import * as auth from '../providers/auth' import * as svg from '../../components/svg' +import * as validation from '../../dashboard/validation' + import Input from './input' import SvgIcon from './svgIcon' @@ -117,6 +119,8 @@ function ResetPassword() { type="password" name="new_password" placeholder="New Password" + pattern={validation.PASSWORD_PATTERN} + title={validation.PASSWORD_TITLE} value={newPassword} setValue={setNewPassword} /> 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 0a6ad05c218f..5c1f0cfe5515 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 @@ -15,7 +15,6 @@ import * as errorModule from '../../error' import * as http from '../../http' import * as loggerProvider from '../../providers/logger' import * as newtype from '../../newtype' -import * as platformModule from '../../platform' import * as remoteBackend from '../../dashboard/remoteBackend' import * as sessionProvider from './session' @@ -26,7 +25,9 @@ import * as sessionProvider from './session' const MESSAGES = { signUpSuccess: 'We have sent you an email with further instructions!', confirmSignUpSuccess: 'Your account has been confirmed! Please log in.', + setUsernameLoading: 'Setting username...', setUsernameSuccess: 'Your username has been set!', + setUsernameFailure: 'Could not set your username.', signInWithPasswordSuccess: 'Successfully logged in!', forgotPasswordSuccess: 'We have sent you an email with further instructions!', changePasswordSuccess: 'Successfully changed password!', @@ -44,7 +45,6 @@ const MESSAGES = { /** Possible types of {@link BaseUserSession}. */ export enum UserSessionType { partial = 'partial', - awaitingAcceptance = 'awaitingAcceptance', full = 'full', } @@ -276,20 +276,25 @@ export function AuthProvider(props: AuthProviderProps) { username: string, email: string ) => { - if (backend.platform === platformModule.Platform.desktop) { + if (backend.type === backendModule.BackendType.local) { toast.error('You cannot set your username on the local backend.') return false } else { try { - await backend.createUser({ - userName: username, - userEmail: newtype.asNewtype(email), - }) + await toast.promise( + backend.createUser({ + userName: username, + userEmail: newtype.asNewtype(email), + }), + { + success: MESSAGES.setUsernameSuccess, + error: MESSAGES.setUsernameFailure, + loading: MESSAGES.setUsernameLoading, + } + ) navigate(app.DASHBOARD_PATH) - toast.success(MESSAGES.setUsernameSuccess) return true } catch (e) { - toast.error('Could not set your username.') return false } } 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 5639e3f1110b..a045f18387d3 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 @@ -97,7 +97,7 @@ export function SessionProvider(props: SessionProviderProps) { * * See: * https://github.com/aws-amplify/amplify-js/issues/3391#issuecomment-756473970 */ - window.history.replaceState({}, '', mainPageUrl) + history.replaceState({}, '', mainPageUrl) doRefresh() break } 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 6323c806c38f..764369622fd2 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 @@ -12,7 +12,13 @@ import * as config from '../config' import * as listen from './listen' import * as loggerProvider from '../providers/logger' import * as newtype from '../newtype' -import * as platformModule from '../platform' + +// ============= +// === Types === +// ============= + +/** The subset of Amplify configuration related to sign in and sign out redirects. */ +interface AmplifyRedirects extends Pick {} // ================= // === Constants === @@ -31,21 +37,19 @@ const CONFIRM_REGISTRATION_PATHNAME = '//auth/confirmation' * password email. */ const LOGIN_PATHNAME = '//auth/login' -/** URL used as the OAuth redirect when running in the desktop app. */ -const DESKTOP_REDIRECT = newtype.asNewtype(`${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.desktop]: { - redirectSignIn: DESKTOP_REDIRECT, - redirectSignOut: DESKTOP_REDIRECT, - }, - [platformModule.Platform.cloud]: { - redirectSignIn: config.ACTIVE_CONFIG.cloudRedirect, - redirectSignOut: config.ACTIVE_CONFIG.cloudRedirect, - }, +/** URI used as the OAuth redirect when deep links are supported. */ +const DEEP_LINK_REDIRECT = newtype.asNewtype( + `${common.DEEP_LINK_SCHEME}://auth` +) +/** OAuth redirect URLs for the electron app. */ +const DEEP_LINK_REDIRECTS: AmplifyRedirects = { + redirectSignIn: DEEP_LINK_REDIRECT, + redirectSignOut: DEEP_LINK_REDIRECT, +} +/** OAuth redirect URLs for the browser. */ +const CLOUD_REDIRECTS: AmplifyRedirects = { + redirectSignIn: config.ACTIVE_CONFIG.cloudRedirect, + redirectSignOut: config.ACTIVE_CONFIG.cloudRedirect, } const BASE_AMPLIFY_CONFIG = { @@ -88,8 +92,9 @@ const AMPLIFY_CONFIGS = { 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 + /** Whether the application supports deep links. This is only true when using + * the installed app on macOS and Windows. */ + supportsDeepLinks: boolean /** 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 @@ -116,9 +121,9 @@ 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) + const { logger, supportsDeepLinks, navigate } = authConfig + const amplifyConfig = loadAmplifyConfig(logger, supportsDeepLinks, navigate) + const cognitoClient = new cognito.Cognito(logger, supportsDeepLinks, amplifyConfig) return { cognito: cognitoClient, registerAuthEventListener: listen.registerAuthEventListener, @@ -128,34 +133,35 @@ export function initAuthService(authConfig: AuthConfig): AuthService { /** Return the appropriate Amplify configuration for the current platform. */ function loadAmplifyConfig( logger: loggerProvider.Logger, - platform: platformModule.Platform, + supportsDeepLinks: boolean, navigate: (url: string) => void ): auth.AmplifyConfig { /** Load the environment-specific Amplify configuration. */ const baseConfig = AMPLIFY_CONFIGS[config.ENVIRONMENT] - let urlOpener = null - let accessTokenSaver = 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: + let urlOpener: ((url: string) => void) | null = null + let accessTokenSaver: ((accessToken: string) => void) | null = null + if ('authenticationApi' in window) { + /** When running on destop we want to have option to save access token to a file, + * so it can be later reuse when issuing requests to Cloud API. */ + accessTokenSaver = saveAccessToken + } + if (supportsDeepLinks) { + /** If we support redirecting back here via deep links, 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 - /** When running on destop we want to have option to save access token to a file, - * so it can be later reused when issuing requests to the Cloud API. */ - accessTokenSaver = saveAccessToken - /** 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] + const platformConfig = supportsDeepLinks ? DEEP_LINK_REDIRECTS : CLOUD_REDIRECTS return { ...baseConfig, ...platformConfig, @@ -243,19 +249,19 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin * 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 + /** Temporarily override the `history` object so that Amplify doesn't try to call + * `history.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. */ + * the original `history.replaceState` function, which is not bound to the + * `history` object. */ // eslint-disable-next-line @typescript-eslint/unbound-method - const replaceState = window.history.replaceState - window.history.replaceState = () => false + const replaceState = history.replaceState + history.replaceState = () => false try { /** # Safety * @@ -267,8 +273,8 @@ function handleAuthResponse(url: string) { // 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 + /** Restore the original `history.replaceState` function. */ + 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 a7bb605bec00..6dc553eca089 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 @@ -39,7 +39,7 @@ import * as router from 'react-router-dom' import * as toast from 'react-hot-toast' import * as authService from '../authentication/service' -import * as platformModule from '../platform' +import * as detect from '../detect' import * as authProvider from '../authentication/providers/auth' import * as backendProvider from '../providers/backend' @@ -81,11 +81,15 @@ export const SET_USERNAME_PATH = '/set-username' /** Global configuration for the `App` component. */ export interface AppProps { logger: loggerProvider.Logger - platform: platformModule.Platform + /** Whether the application may have the local backend running. */ + supportsLocalBackend: boolean + /** Whether the application supports deep links. This is only true when using + * the installed app on macOS and Windows. */ + supportsDeepLinks: boolean /** Whether the dashboard should be rendered. */ showDashboard: boolean onAuthenticated: () => void - appRunner: AppRunner | null + appRunner: AppRunner } /** Component called by the parent module, returning the root React component for this @@ -94,11 +98,9 @@ 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 + const Router = detect.isRunningInElectron() ? 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 ( diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts index bab6add54186..296366f63593 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts @@ -1,12 +1,17 @@ /** @file Type definitions common between all backends. */ import * as dateTime from './dateTime' import * as newtype from '../newtype' -import * as platform from '../platform' // ============= // === Types === // ============= +/** The {@link Backend} variant. If a new variant is created, it should be added to this enum. */ +export enum BackendType { + local = 'local', + remote = 'remote', +} + /** Unique identifier for a user/organization. */ export type UserOrOrganizationId = newtype.Newtype @@ -366,7 +371,7 @@ export function assetIsType(type: Type) { /** Interface for sending requests to a backend that manages assets and runs projects. */ export interface Backend { - readonly platform: platform.Platform + readonly type: BackendType /** Set the username of the current user. */ createUser: (body: CreateUserRequestBody) => Promise diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx index 038c780402a3..9d4cff420024 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx @@ -6,6 +6,7 @@ import toast from 'react-hot-toast' import * as auth from '../../authentication/providers/auth' import * as modalProvider from '../../providers/modal' import * as svg from '../../components/svg' +import * as validation from '../validation' import Modal from './modal' @@ -96,6 +97,8 @@ function ChangePasswordModal() { type="password" name="new_password" placeholder="New Password" + pattern={validation.PASSWORD_PATTERN} + title={validation.PASSWORD_TITLE} value={newPassword} onChange={event => { setNewPassword(event.target.value) 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 8f11acad8e55..cc483bcb637a 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 @@ -2,6 +2,8 @@ * interactive components. */ import * as react from 'react' +import * as common from 'enso-common' + import * as backendModule from '../backend' import * as dateTime from '../dateTime' import * as fileInfo from '../../fileInfo' @@ -9,7 +11,7 @@ import * as hooks from '../../hooks' import * as http from '../../http' import * as localBackend from '../localBackend' import * as newtype from '../../newtype' -import * as platformModule from '../../platform' +import * as projectManager from '../projectManager' import * as remoteBackendModule from '../remoteBackend' import * as svg from '../../components/svg' import * as uploadMultipleFiles from '../../uploadMultipleFiles' @@ -129,7 +131,7 @@ const COLUMN_NAME: Record, string> = { /** CSS classes for every column. Currently only used to set the widths. */ const COLUMN_CSS_CLASS: Record = { [Column.name]: 'w-60', - [Column.lastModified]: 'w-32', + [Column.lastModified]: 'w-40', [Column.sharedWith]: 'w-36', [Column.docs]: 'w-96', [Column.labels]: 'w-80', @@ -207,9 +209,9 @@ function rootDirectoryId(userOrOrganizationId: backendModule.UserOrOrganizationI } /** Returns the list of columns to be displayed. */ -function columnsFor(displayMode: ColumnDisplayMode, backendPlatform: platformModule.Platform) { +function columnsFor(displayMode: ColumnDisplayMode, backendType: backendModule.BackendType) { const columns = COLUMNS_FOR[displayMode] - return backendPlatform === platformModule.Platform.desktop + return backendType === backendModule.BackendType.local ? columns.filter(column => column !== Column.sharedWith) : columns } @@ -220,8 +222,8 @@ function columnsFor(displayMode: ColumnDisplayMode, backendPlatform: platformMod /** Props for {@link Dashboard}s that are common to all platforms. */ export interface DashboardProps { - platform: platformModule.Platform - appRunner: AppRunner | null + supportsLocalBackend: boolean + appRunner: AppRunner } // TODO[sb]: Implement rename when clicking name of a selected row. @@ -229,7 +231,7 @@ export interface DashboardProps { /** The component that contains the entire UI. */ function Dashboard(props: DashboardProps) { - const { platform, appRunner } = props + const { supportsLocalBackend, appRunner } = props const logger = loggerProvider.useLogger() const { accessToken, organization } = auth.useFullUserSession() @@ -241,6 +243,7 @@ function Dashboard(props: DashboardProps) { const [refresh, doRefresh] = hooks.useRefresh() const [query, setQuery] = react.useState('') + const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = react.useState(false) const [directoryId, setDirectoryId] = react.useState(rootDirectoryId(organization.id)) const [directoryStack, setDirectoryStack] = react.useState< backendModule.Asset[] @@ -278,14 +281,38 @@ function Dashboard(props: DashboardProps) { backendModule.Asset[] >([]) - const canListDirectory = - backend.platform !== platformModule.Platform.cloud || organization.isEnabled + const listingLocalDirectoryAndWillFail = + backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail + const listingRemoteDirectoryAndWillFail = + backend.type === backendModule.BackendType.remote && !organization.isEnabled const directory = directoryStack[directoryStack.length - 1] const parentDirectory = directoryStack[directoryStack.length - 2] react.useEffect(() => { - if (platform === platformModule.Platform.desktop) { - setBackend(new localBackend.LocalBackend()) + const onProjectManagerLoadingFailed = () => { + setLoadingProjectManagerDidFail(true) + } + document.addEventListener( + projectManager.ProjectManagerEvents.loadingFailed, + onProjectManagerLoadingFailed + ) + return () => { + document.removeEventListener( + projectManager.ProjectManagerEvents.loadingFailed, + onProjectManagerLoadingFailed + ) + } + }, []) + + react.useEffect(() => { + if (backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail) { + setIsLoadingAssets(false) + } + }, [isLoadingAssets, loadingProjectManagerDidFail, backend.type]) + + react.useEffect(() => { + if (supportsLocalBackend) { + new localBackend.LocalBackend() } }, []) @@ -387,7 +414,7 @@ function Dashboard(props: DashboardProps) { { - if (canListDirectory) { + if (listingLocalDirectoryAndWillFail) { + // Do not `setIsLoadingAssets(false)` + } else if (!listingRemoteDirectoryAndWillFail) { const assets = await backend.listDirectory({ parentId: directoryId }) if (!signal.aborted) { setIsLoadingAssets(false) @@ -697,7 +726,7 @@ function Dashboard(props: DashboardProps) { onDragEnter={openDropZone} > { @@ -717,18 +746,18 @@ function Dashboard(props: DashboardProps) { } } }} - setBackendPlatform={newBackendPlatform => { - if (newBackendPlatform !== backend.platform) { + setBackendType={newBackendType => { + if (newBackendType !== backend.type) { setIsLoadingAssets(true) setProjectAssets([]) setDirectoryAssets([]) setSecretAssets([]) setFileAssets([]) - switch (newBackendPlatform) { - case platformModule.Platform.desktop: + switch (newBackendType) { + case backendModule.BackendType.local: setBackend(new localBackend.LocalBackend()) break - case platformModule.Platform.cloud: { + case backendModule.BackendType.remote: { const headers = new Headers() headers.append('Authorization', `Bearer ${accessToken}`) const client = new http.Client(headers) @@ -741,7 +770,14 @@ function Dashboard(props: DashboardProps) { query={query} setQuery={setQuery} /> - {!canListDirectory ? ( + {listingLocalDirectoryAndWillFail ? ( +
+
+ Could not connect to the Project Manager. Please try restarting{' '} + {common.PRODUCT_NAME}, or manually launching the Project Manager. +
+
+ ) : listingRemoteDirectoryAndWillFail ? (
We will review your user details and enable the cloud experience for you @@ -754,7 +790,7 @@ function Dashboard(props: DashboardProps) {

Drive

- {backend.platform === platformModule.Platform.cloud && ( + {backend.type === backendModule.BackendType.remote && ( <>
{directory && ( @@ -776,11 +812,11 @@ function Dashboard(props: DashboardProps) {