Revert "Cognito auth 8/7 - fix prettier config; run prettier (#6003)" (#6127)

This reverts commit a9dbebf3f3.
This commit is contained in:
Paweł Buchowski 2023-03-29 12:20:46 +01:00 committed by GitHub
parent 99a6f8f2f9
commit 1a569223aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 29061 additions and 4902 deletions

View File

@ -1,9 +1,8 @@
overrides: overrides:
- files: - files:
- "*.[j|t]s" - "*.[j|t]s"
- "*.[j|t]sx" - "*.mjs"
- "*.m[j|t]s" - "*.cjs"
- "*.c[j|t]s"
options: options:
printWidth: 100 printWidth: 100
tabWidth: 4 tabWidth: 4

View File

@ -0,0 +1,25 @@
/** @file A helper module for running esbuild in watch mode. */
import * as esbuild from 'esbuild'
/** Transform a given esbuild bundle option configuration into a watch configuration.
* @param config - Configuration for the esbuild command.
* @param onRebuild - Callback to be called after each rebuild.
* @param inject - See [esbuild docs](https://esbuild.github.io/api/#inject).
*/
export function toWatchOptions<T extends esbuild.BuildOptions>(
config: T,
onRebuild?: () => void,
inject?: esbuild.BuildOptions['inject']
) {
return {
...config,
inject: [...(config.inject ?? []), ...(inject ?? [])],
watch: {
onRebuild(error) {
if (error) console.error('watch build failed:', error)
else onRebuild?.()
},
},
} satisfies esbuild.BuildOptions
}

View File

@ -24,8 +24,8 @@ const NAME = 'enso'
* `yargs` and `react-hot-toast` are modules we explicitly want the default imports of. * `yargs` and `react-hot-toast` are modules we explicitly want the default imports of.
* `node:process` is here because `process.on` does not exist on the namespace import. */ * `node:process` is here because `process.on` does not exist on the namespace import. */
const DEFAULT_IMPORT_ONLY_MODULES = const DEFAULT_IMPORT_ONLY_MODULES =
'node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*' 'node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener'
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|react-hot-toast` const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|react-hot-toast`
const OUR_MODULES = 'enso-content-config|enso-common' const OUR_MODULES = 'enso-content-config|enso-common'
const RELATIVE_MODULES = const RELATIVE_MODULES =
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|index|ipc|naming|paths|preload|security' 'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|index|ipc|naming|paths|preload|security'

View File

@ -5,8 +5,7 @@ import * as url from 'node:url'
import * as esbuild from 'esbuild' import * as esbuild from 'esbuild'
import * as bundler from './esbuild-config' import * as esbuildConfig from './esbuild-config.js'
import * as dashboardBundler from '../dashboard/esbuild-config'
// ================= // =================
// === Constants === // === Constants ===
@ -18,8 +17,5 @@ export const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta
// === Bundling === // === Bundling ===
// ================ // ================
// The dashboard bundler bundles `tailwind.css`. const BUNDLER_OPTIONS: esbuild.BuildOptions = esbuildConfig.bundlerOptionsFromEnv()
const DASHBOARD_BUNDLER_OPTIONS = dashboardBundler.bundleOptions()
await esbuild.build(DASHBOARD_BUNDLER_OPTIONS)
const BUNDLER_OPTIONS = bundler.bundlerOptionsFromEnv()
await esbuild.build(BUNDLER_OPTIONS) await esbuild.build(BUNDLER_OPTIONS)

View File

@ -4,6 +4,6 @@
* @see Arguments * @see Arguments
*/ */
import * as electronBuilderConfig from './electron-builder-config' import * as electronBuilderConfig from './electron-builder-config.js'
await electronBuilderConfig.buildPackage(electronBuilderConfig.args) await electronBuilderConfig.buildPackage(electronBuilderConfig.args)

View File

@ -238,6 +238,8 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
} }
}, },
// Third-party API specifies `null`, not `undefined`.
// eslint-disable-next-line no-restricted-syntax
publish: null, publish: null,
} }
} }
@ -259,8 +261,4 @@ export async function buildPackage(passedArgs: Arguments) {
console.log('Building with configuration:', cliOpts) console.log('Building with configuration:', cliOpts)
const result = await electronBuilder.build(cliOpts) const result = await electronBuilder.build(cliOpts)
console.log('Electron Builder is done. Result:', result) console.log('Electron Builder is done. Result:', result)
// FIXME: https://github.com/enso-org/enso/issues/6082
// This is workaround which fixes esbuild freezing after successfully finishing the electronBuilder.build.
// It's safe to exit(0) since all processes are finished.
process.exit(0)
} }

View File

@ -3,7 +3,23 @@ import * as path from 'node:path'
import * as esbuild from 'esbuild' import * as esbuild from 'esbuild'
import * as paths from './paths' import * as paths from './paths.js'
import * as utils from '../../utils.js'
// ===================================================
// === Constants provided through the environment. ===
// ===================================================
/** Output directory for bundled client files. */
export const OUT_DIR_PATH = path.join(utils.requireEnvResolvedPath('ENSO_BUILD_IDE'), 'client')
/** Path to the project manager executable relative to the PM bundle root. */
export const PROJECT_MANAGER_IN_BUNDLE_PATH = utils.requireEnv(
'ENSO_BUILD_PROJECT_MANAGER_IN_BUNDLE_PATH'
)
/** Version of the Engine (backend) that is bundled along with this client build. */
export const BUNDLED_ENGINE_VERSION = utils.requireEnv('ENSO_BUILD_IDE_BUNDLED_ENGINE_VERSION')
// ================ // ================
// === Bundling === // === Bundling ===

View File

@ -22,6 +22,7 @@
"chalk": "^5.2.0", "chalk": "^5.2.0",
"create-servers": "^3.2.0", "create-servers": "^3.2.0",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"enso-gui-server": "^1.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"opener": "^1.5.2", "opener": "^1.5.2",
"string-length": "^5.0.1", "string-length": "^5.0.1",
@ -35,25 +36,24 @@
"electron": "23.0.0", "electron": "23.0.0",
"electron-builder": "^22.14.13", "electron-builder": "^22.14.13",
"electron-notarize": "1.2.2", "electron-notarize": "1.2.2",
"enso-common": "^1.0.0",
"enso-copy-plugin": "^1.0.0", "enso-copy-plugin": "^1.0.0",
"esbuild": "^0.17.0", "enso-common": "^1.0.0",
"esbuild": "^0.15.14",
"fast-glob": "^3.2.12", "fast-glob": "^3.2.12",
"portfinder": "^1.0.32", "ts-node": "^10.9.1"
"tsx": "^3.12.6"
}, },
"optionalDependencies": { "optionalDependencies": {
"dmg-license": "^1.0.11", "dmg-license": "^1.0.11",
"@esbuild/darwin-x64": "^0.17.0", "esbuild-linux-64": "^0.15.18",
"@esbuild/linux-x64": "^0.17.0", "esbuild-windows-64": "^0.15.18",
"@esbuild/windows-x64": "^0.17.0" "esbuild-darwin-64": "^0.15.18"
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "npx tsc --noEmit",
"lint": "npx --yes eslint src", "lint": "npx --yes eslint src",
"start": "tsx start.ts", "start": "ts-node start.ts",
"build": "tsx bundle.ts", "build": "ts-node bundle.ts",
"dist": "tsx dist.ts", "dist": "ts-node dist.ts",
"watch": "tsx watch.ts" "watch": "ts-node watch.ts"
} }
} }

View File

@ -1,6 +1,6 @@
/** @file This module defines paths within the client distribution's resources. */ /** @file This module defines paths within the client distribution's resources. */
import * as utils from '../../utils' import * as utils from '../../utils.js'
/** Path to the Project Manager bundle within the electron distribution (relative to the electron's resources directory). */ /** Path to the Project Manager bundle within the electron distribution (relative to the electron's resources directory). */
export const PROJECT_MANAGER_BUNDLE = 'enso' export const PROJECT_MANAGER_BUNDLE = 'enso'

View File

@ -6,8 +6,8 @@ import * as path from 'node:path'
import * as esbuild from 'esbuild' import * as esbuild from 'esbuild'
import * as esbuildConfig from './esbuild-config' import * as esbuildConfig from './esbuild-config.js'
import * as paths from './paths' import * as paths from './paths.js'
const GUI_PATH = path.resolve(paths.getGuiDirectory()) const GUI_PATH = path.resolve(paths.getGuiDirectory())
const IDE_PATH = paths.getIdeDirectory() const IDE_PATH = paths.getIdeDirectory()

View File

@ -16,15 +16,13 @@ import process from 'node:process'
import * as esbuild from 'esbuild' import * as esbuild from 'esbuild'
import * as clientBundler from './esbuild-config' import * as clientBundler from './esbuild-config.js'
import * as contentBundler from '../content/esbuild-config' import * as contentBundler from '../content/esbuild-config.js'
import * as dashboardBundler from '../dashboard/esbuild-config' import * as paths from './paths.js'
import * as paths from './paths'
/** Set of esbuild watches for the client and content. */ /** Set of esbuild watches for the client and content. */
interface Watches { interface Watches {
client: esbuild.BuildResult client: esbuild.BuildResult
dashboard: esbuild.BuildResult
content: esbuild.BuildResult content: esbuild.BuildResult
} }
@ -35,74 +33,40 @@ console.log('Cleaning IDE dist directory.')
await fs.rm(IDE_DIR_PATH, { recursive: true, force: true }) await fs.rm(IDE_DIR_PATH, { recursive: true, force: true })
await fs.mkdir(IDE_DIR_PATH, { recursive: true }) await fs.mkdir(IDE_DIR_PATH, { recursive: true })
const ALL_BUNDLES_READY = new Promise<Watches>((resolve, reject) => { const BOTH_BUNDLES_READY = new Promise<Watches>((resolve, reject) => {
void (async () => { void (async () => {
console.log('Bundling client.') console.log('Bundling client.')
const clientBundlerOpts = clientBundler.bundlerOptionsFromEnv() const clientBundlerOpts: esbuild.BuildOptions = {
clientBundlerOpts.outdir = path.resolve(IDE_DIR_PATH) ...clientBundler.bundlerOptionsFromEnv(),
// Eslint is wrong here; this is actually `undefined`. outdir: path.resolve(IDE_DIR_PATH),
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition watch: {
;(clientBundlerOpts.plugins ??= []).push({ onRebuild(error) {
name: 'enso-on-rebuild', if (error) {
setup: build => {
build.onEnd(result => {
if (result.errors.length) {
// We cannot carry on if the client failed to build, because electron executable // We cannot carry on if the client failed to build, because electron executable
// would immediately exit with an error. // would immediately exit with an error.
console.error('Client watch bundle failed:', result.errors[0]) console.error('Client watch bundle failed:', error)
reject(result.errors[0]) reject(error)
} else { } else {
console.log('Client bundle updated.') console.log('Client bundle updated.')
} }
}) },
}, },
}) }
const clientBuilder = await esbuild.context(clientBundlerOpts) const client = await esbuild.build(clientBundlerOpts)
const client = await clientBuilder.rebuild()
console.log('Result of client bundling: ', client) console.log('Result of client bundling: ', client)
void clientBuilder.watch()
console.log('Bundling dashboard.')
const dashboardOpts = dashboardBundler.bundleOptions()
dashboardOpts.plugins.push({
name: 'enso-on-rebuild',
setup: build => {
build.onEnd(() => {
console.log('Dashboard bundle updated.')
})
},
})
dashboardOpts.outdir = path.resolve(IDE_DIR_PATH, 'assets')
const dashboardBuilder = await esbuild.context(dashboardOpts)
const dashboard = await dashboardBuilder.rebuild()
console.log('Result of dashboard bundling: ', dashboard)
// We do not need to serve the dashboard as it outputs to the same directory.
// It will not rebuild on request, but it is not intended to rebuild on request anyway.
// This MUST be called before `builder.watch()` as `tailwind.css` must be generated
// before the copy plugin runs.
void dashboardBuilder.watch()
console.log('Bundling content.') console.log('Bundling content.')
const contentOpts = contentBundler.bundlerOptionsFromEnv() const contentOpts = contentBundler.watchOptions(() => {
contentOpts.plugins.push({ console.log('Content bundle updated.')
name: 'enso-on-rebuild',
setup: build => {
build.onEnd(() => {
console.log('Content bundle updated.')
})
},
}) })
contentOpts.outdir = path.resolve(IDE_DIR_PATH, 'assets') contentOpts.outdir = path.resolve(IDE_DIR_PATH, 'assets')
const contentBuilder = await esbuild.context(contentOpts) const content = await esbuild.build(contentOpts)
const content = await contentBuilder.rebuild()
console.log('Result of content bundling: ', content) console.log('Result of content bundling: ', content)
void contentBuilder.watch() resolve({ client, content })
resolve({ client, dashboard, content })
})() })()
}) })
await ALL_BUNDLES_READY await BOTH_BUNDLES_READY
console.log('Exposing Project Manager bundle.') console.log('Exposing Project Manager bundle.')
await fs.symlink( await fs.symlink(
PROJECT_MANAGER_BUNDLE_PATH, PROJECT_MANAGER_BUNDLE_PATH,

View File

@ -1,11 +1,3 @@
/** @file Entry point for the bundler. */ /** @file Entry point for the bundler. */
import * as esbuild from 'esbuild' import bundler from './esbuild-config.js'
await bundler.bundle()
import * as bundler from './esbuild-config'
try {
void esbuild.build(bundler.bundleOptions())
} catch (error) {
console.error(error)
throw error
}

View File

@ -22,11 +22,19 @@ import esbuildPluginAlias from 'esbuild-plugin-alias'
import esbuildPluginTime from 'esbuild-plugin-time' import esbuildPluginTime from 'esbuild-plugin-time'
import esbuildPluginYaml from 'esbuild-plugin-yaml' import esbuildPluginYaml from 'esbuild-plugin-yaml'
import * as esbuildWatch from '../../esbuild-watch.js'
import * as utils from '../../utils.js' import * as utils from '../../utils.js'
import BUILD_INFO from '../../build.json' assert { type: 'json' } import BUILD_INFO from '../../build.json' assert { type: 'json' }
export const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url))) export const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
// =================
// === Constants ===
// =================
const TAILWIND_BINARY_PATH = '../../node_modules/.bin/tailwindcss'
const TAILWIND_CSS_PATH = path.resolve(THIS_PATH, 'src', 'tailwind.css')
// ============================= // =============================
// === Environment variables === // === Environment variables ===
// ============================= // =============================
@ -81,6 +89,7 @@ export function alwaysCopiedFiles(wasmArtifacts: string) {
path.resolve(THIS_PATH, 'src', 'run.js'), path.resolve(THIS_PATH, 'src', 'run.js'),
path.resolve(THIS_PATH, 'src', 'style.css'), path.resolve(THIS_PATH, 'src', 'style.css'),
path.resolve(THIS_PATH, 'src', 'docsStyle.css'), path.resolve(THIS_PATH, 'src', 'docsStyle.css'),
path.resolve(THIS_PATH, 'src', 'tailwind.css'),
...wasmArtifacts.split(path.delimiter), ...wasmArtifacts.split(path.delimiter),
] ]
} }
@ -98,6 +107,45 @@ export async function* filesToCopyProvider(wasmArtifacts: string, assetsPath: st
console.log('Generator for files to copy finished.') console.log('Generator for files to copy finished.')
} }
// ======================
// === Inline plugins ===
// ======================
function esbuildPluginGenerateTailwind(args: Pick<Arguments, 'assetsPath'>): esbuild.Plugin {
return {
name: 'enso-generate-tailwind',
setup: build => {
// Required since `onStart` is called on every rebuild.
let firstRun = true
build.onStart(() => {
if (firstRun) {
const dest = path.join(args.assetsPath, 'tailwind.css')
const config = path.resolve(THIS_PATH, 'tailwind.config.ts')
console.log(`Generating tailwind css from '${TAILWIND_CSS_PATH}' to '${dest}'.`)
const child = childProcess.spawn(`node`, [
TAILWIND_BINARY_PATH,
'-i',
TAILWIND_CSS_PATH,
'o',
dest,
'-c',
config,
'--minify',
])
firstRun = false
return new Promise(resolve =>
child.on('close', () => {
resolve({})
})
)
} else {
return {}
}
})
},
}
}
// ================ // ================
// === Bundling === // === Bundling ===
// ================ // ================
@ -107,11 +155,14 @@ export async function* filesToCopyProvider(wasmArtifacts: string, assetsPath: st
*/ */
export function bundlerOptions(args: Arguments) { export function bundlerOptions(args: Arguments) {
const { outputPath, ensoglAppPath, wasmArtifacts, assetsPath } = args const { outputPath, ensoglAppPath, wasmArtifacts, assetsPath } = args
// This is required to make the `true` properties be typed as `boolean`.
// eslint-disable-next-line no-restricted-syntax
let trueBoolean = true as boolean
const buildOptions = { const buildOptions = {
// Disabling naming convention because these are third-party options. // Disabling naming convention because these are third-party options.
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
absWorkingDir: THIS_PATH, absWorkingDir: THIS_PATH,
bundle: true, bundle: trueBoolean,
entryPoints: [path.resolve(THIS_PATH, 'src', 'index.ts')], entryPoints: [path.resolve(THIS_PATH, 'src', 'index.ts')],
outdir: outputPath, outdir: outputPath,
outbase: 'src', outbase: 'src',
@ -121,6 +172,8 @@ export function bundlerOptions(args: Arguments) {
esbuildPluginNodeGlobals.NodeGlobalsPolyfillPlugin({ buffer: true, process: true }), esbuildPluginNodeGlobals.NodeGlobalsPolyfillPlugin({ buffer: true, process: true }),
esbuildPluginAlias({ ensogl_app: ensoglAppPath }), esbuildPluginAlias({ ensogl_app: ensoglAppPath }),
esbuildPluginTime(), esbuildPluginTime(),
// This must run before the copy plugin so that the generated `tailwind.css` is used.
esbuildPluginGenerateTailwind({ assetsPath }),
esbuildPluginCopy.create(() => filesToCopyProvider(wasmArtifacts, assetsPath)), esbuildPluginCopy.create(() => filesToCopyProvider(wasmArtifacts, assetsPath)),
], ],
define: { define: {
@ -128,12 +181,14 @@ export function bundlerOptions(args: Arguments) {
GIT_STATUS: JSON.stringify(git('status --short --porcelain')), GIT_STATUS: JSON.stringify(git('status --short --porcelain')),
BUILD_INFO: JSON.stringify(BUILD_INFO), BUILD_INFO: JSON.stringify(BUILD_INFO),
}, },
sourcemap: true, sourcemap: trueBoolean,
minify: true, minify: trueBoolean,
metafile: true, metafile: trueBoolean,
format: 'esm', format: 'esm',
publicPath: '/assets',
platform: 'browser', platform: 'browser',
color: true, incremental: trueBoolean,
color: trueBoolean,
logOverride: { logOverride: {
// Happens in Emscripten-generated MSDF (msdfgen_wasm.js): // Happens in Emscripten-generated MSDF (msdfgen_wasm.js):
// 1 │ ...typeof module!=="undefined"){module["exports"]=Module}process["o... // 1 │ ...typeof module!=="undefined"){module["exports"]=Module}process["o...
@ -158,10 +213,32 @@ export function bundlerOptionsFromEnv() {
return bundlerOptions(argumentsFromEnv()) return bundlerOptions(argumentsFromEnv())
} }
/** ESBuild options for spawning a watcher, that will continuously rebuild the package. */
export function watchOptions(onRebuild?: () => void, inject?: esbuild.BuildOptions['inject']) {
return esbuildWatch.toWatchOptions(bundlerOptionsFromEnv(), onRebuild, inject)
}
/** ESBuild options for bundling (one-off build) the package. /** ESBuild options for bundling (one-off build) the package.
* *
* Relies on the environment variables to be set. * Relies on the environment variables to be set.
*/ */
export function bundleOptions() { export function bundleOptions() {
return bundlerOptionsFromEnv() const ret = bundlerOptionsFromEnv()
ret.watch = false
ret.incremental = false
return ret
} }
/**
* Bundles the package.
*/
export async function bundle() {
try {
return esbuild.build(bundleOptions())
} catch (error) {
console.error(error)
throw error
}
}
export default { watchOptions, bundle, bundleOptions }

View File

@ -15,19 +15,23 @@
"url": "https://github.com/enso-org/ide/issues" "url": "https://github.com/enso-org/ide/issues"
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "npx tsc --noEmit",
"lint": "npx --yes eslint src", "lint": "npx --yes eslint src",
"build": "tsx bundle.ts", "build": "ts-node bundle.ts",
"watch": "tsx watch.ts", "watch": "ts-node watch.ts",
"start": "tsx start.ts" "start": "ts-node start.ts"
}, },
"dependencies": { "dependencies": {
"@types/mixpanel-browser": "^2.38.0",
"@types/semver": "^7.3.9", "@types/semver": "^7.3.9",
"enso-content-config": "^1.0.0" "enso-content-config": "^1.0.0",
"enso-gui-server": "^1.0.0",
"html-loader": "^4.2.0",
"mixpanel-browser": "2.45.0"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2", "@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@eslint/js": "^8.36.0", "@eslint/js": "^8.36.0",
"@types/connect": "^3.4.35", "@types/connect": "^3.4.35",
"@types/morgan": "^1.9.4", "@types/morgan": "^1.9.4",
@ -39,20 +43,27 @@
"@typescript-eslint/parser": "^5.55.0", "@typescript-eslint/parser": "^5.55.0",
"enso-authentication": "^1.0.0", "enso-authentication": "^1.0.0",
"enso-copy-plugin": "^1.0.0", "enso-copy-plugin": "^1.0.0",
"esbuild": "^0.17.0", "enso-gui-server": "^1.0.0",
"esbuild": "^0.15.14",
"esbuild-copy-static-files": "^0.1.0",
"esbuild-dev-server": "^0.3.0",
"esbuild-plugin-alias": "^0.2.1", "esbuild-plugin-alias": "^0.2.1",
"esbuild-plugin-time": "^1.0.0", "esbuild-plugin-time": "^1.0.0",
"esbuild-plugin-yaml": "^0.0.1", "esbuild-plugin-yaml": "^0.0.1",
"eslint": "^8.36.0", "eslint": "^8.36.0",
"eslint-plugin-jsdoc": "^40.0.2", "eslint-plugin-jsdoc": "^40.0.2",
"glob": "^8.0.3",
"globals": "^13.20.0", "globals": "^13.20.0",
"portfinder": "^1.0.32", "source-map-loader": "^4.0.1",
"tsx": "^3.12.6", "tailwindcss": "^3.2.7",
"typescript": "^4.9.3" "ts-loader": "^9.3.0",
"ts-node": "^10.9.1",
"typescript": "^4.9.3",
"yaml-loader": "^0.8.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@esbuild/darwin-x64": "^0.17.0", "esbuild-darwin-64": "^0.15.18",
"@esbuild/linux-x64": "^0.17.0", "esbuild-linux-64": "^0.15.18",
"@esbuild/windows-x64": "^0.17.0" "esbuild-windows-64": "^0.15.18"
} }
} }

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,25 +1,12 @@
/** @file Start the file watch service. */ /** @file Start the file watch service. */
import * as esbuild from 'esbuild' import * as esbuild from 'esbuild'
import * as portfinder from 'portfinder'
import * as bundler from './esbuild-config.js' import * as guiServer from 'enso-gui-server'
const PORT = 8080 import bundler from './esbuild-config.js'
const HTTP_STATUS_OK = 200
async function watch() { const OPTS = bundler.bundleOptions()
const opts = bundler.bundleOptions() const ROOT = OPTS.outdir
const builder = await esbuild.context(opts) const ASSETS = ROOT
await builder.watch() await esbuild.build(OPTS)
await builder.serve({ await guiServer.start({ root: ROOT, assets: ASSETS ?? null })
port: await portfinder.getPortPromise({ port: PORT }),
servedir: opts.outdir,
onRequest(args) {
if (args.status !== HTTP_STATUS_OK) {
console.error(`HTTP error ${args.status} when serving path '${args.path}'.`)
}
},
})
}
void watch()

View File

@ -0,0 +1,13 @@
/** @file Configuration for Tailwind. */
/** @type {import('tailwindcss').Config} */
module.exports = {
// FIXME[sb]: Tailwind building should be in `dashboard/`.
content: ['../dashboard/src/authentication/src/**/*.tsx'],
theme: {
extend: {},
},
corePlugins: {
preflight: false,
},
plugins: [],
}

View File

@ -1,4 +1,4 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"include": ["../types", "."] "include": ["../types", ".", "tailwind.config.ts"]
} }

View File

@ -1,41 +1,15 @@
/** @file File watch and compile service. */ /** @file File watch and compile service. */
import * as esbuild from 'esbuild' import * as esbuild from 'esbuild'
import * as portfinder from 'portfinder'
import * as bundler from './esbuild-config' import * as guiServer from 'enso-gui-server'
import * as dashboardBundler from '../dashboard/esbuild-config'
// ================= import bundler from './esbuild-config.js'
// === Constants ===
// =================
const PORT = 8080 const OPTS = bundler.watchOptions(() => {
const HTTP_STATUS_OK = 200 LIVE_SERVER.reload()
}, [guiServer.LIVE_RELOAD_LISTENER_PATH])
// =============== await esbuild.build(OPTS)
// === Watcher === const LIVE_SERVER = await guiServer.start({
// =============== root: OPTS.outdir,
assets: OPTS.outdir ?? null,
async function watch() { })
const dashboardOpts = dashboardBundler.bundleOptions()
const dashboardBuilder = await esbuild.context(dashboardOpts)
// We do not need to serve the dashboard as it outputs to the same directory.
// It will not rebuild on request, but it is not intended to rebuild on request anyway.
// This MUST be called before `builder.watch()` as `tailwind.css` must be generated
// before the copy plugin runs.
await dashboardBuilder.watch()
const opts = bundler.bundleOptions()
const builder = await esbuild.context(opts)
await builder.watch()
await builder.serve({
port: await portfinder.getPortPromise({ port: PORT }),
servedir: opts.outdir,
onRequest(args) {
if (args.status !== HTTP_STATUS_OK) {
console.error(`HTTP error ${args.status} when serving path '${args.path}'.`)
}
},
})
}
void watch()

View File

@ -57,7 +57,6 @@ export function create(filesProvider) {
let files let files
if (Array.isArray(build.initialOptions.entryPoints)) { if (Array.isArray(build.initialOptions.entryPoints)) {
// @ts-expect-error We do not support `{ in: string; out: string; }` entry points.
build.initialOptions.entryPoints.push(magic) build.initialOptions.entryPoints.push(magic)
} else if (typeof build.initialOptions.entryPoints === 'object') { } else if (typeof build.initialOptions.entryPoints === 'object') {
build.initialOptions.entryPoints[magic] = magic build.initialOptions.entryPoints[magic] = magic

View File

@ -1,34 +0,0 @@
/** @file Entry point for the bundler. */
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as url from 'node:url'
import * as esbuild from 'esbuild'
import * as bundler from './esbuild-config'
export const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
async function bundle() {
try {
try {
await fs.rm('./build', { recursive: true })
} catch {
// Ignored.
}
const opts = bundler.bundlerOptions({
outputPath: './build',
devMode: false,
})
opts.entryPoints.push(
path.resolve(THIS_PATH, 'src', 'index.html'),
path.resolve(THIS_PATH, 'src', 'index.tsx')
)
await esbuild.build(opts)
} catch (error) {
console.error(error)
throw error
}
}
void bundle()

View File

@ -1,144 +0,0 @@
/**
* @file Configuration for the esbuild bundler and build/watch commands.
*
* The bundler processes each entry point into a single file, each with no external dependencies and
* minified. This primarily involves resolving all imports, along with some other transformations
* (like TypeScript compilation).
*
* See the bundlers documentation for more information:
* https://esbuild.github.io/getting-started/#bundling-for-node.
*/
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as url from 'node:url'
import * as esbuild from 'esbuild'
import * as esbuildPluginNodeModules from '@esbuild-plugins/node-modules-polyfill'
import esbuildPluginTime from 'esbuild-plugin-time'
import postcss from 'postcss'
import tailwindcss from 'tailwindcss'
import tailwindcssNesting from 'tailwindcss/nesting/index.js'
import * as utils from '../../utils'
// =================
// === Constants ===
// =================
const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
const TAILWIND_CONFIG_PATH = path.resolve(THIS_PATH, 'tailwind.config.ts')
// =============================
// === Environment variables ===
// =============================
export interface Arguments {
/** Path where bundled files are output. */
outputPath: string
/** `true` if in development mode (live-reload), `false` if in production mode. */
devMode: boolean
}
/**
* Get arguments from the environment.
*/
export function argumentsFromEnv(): Arguments {
const outputPath = path.resolve(utils.requireEnv('ENSO_BUILD_GUI'), 'assets')
return { outputPath, devMode: false }
}
// ======================
// === Inline plugins ===
// ======================
function esbuildPluginGenerateTailwind(): esbuild.Plugin {
return {
name: 'enso-generate-tailwind',
setup: build => {
interface CacheEntry {
contents: string
lastModified: number
}
let cachedOutput: Record<string, CacheEntry> = {}
let tailwindConfigLastModified = 0
let tailwindConfigWasModified = true
const cssProcessor = postcss([
tailwindcss({
config: TAILWIND_CONFIG_PATH,
}),
tailwindcssNesting(),
])
build.onStart(async () => {
const tailwindConfigNewLastModified = (await fs.stat(TAILWIND_CONFIG_PATH)).mtimeMs
tailwindConfigWasModified =
tailwindConfigLastModified !== tailwindConfigNewLastModified
tailwindConfigLastModified = tailwindConfigNewLastModified
})
build.onLoad({ filter: /\.css$/ }, async loadArgs => {
const lastModified = (await fs.stat(loadArgs.path)).mtimeMs
let output = cachedOutput[loadArgs.path]
if (!output || output.lastModified !== lastModified || tailwindConfigWasModified) {
console.log(`Processing CSS file '${loadArgs.path}'.`)
const result = await cssProcessor.process(
await fs.readFile(loadArgs.path, 'utf8'),
{ from: loadArgs.path }
)
console.log(`Processed CSS file '${loadArgs.path}'.`)
output = { contents: result.css, lastModified }
cachedOutput[loadArgs.path] = output
}
return {
contents: output.contents,
loader: 'css',
watchFiles: [loadArgs.path, TAILWIND_CONFIG_PATH],
}
})
},
}
}
// ================
// === Bundling ===
// ================
/** Generate the bundler options. */
export function bundlerOptions(args: Arguments) {
const { outputPath } = args
const buildOptions = {
absWorkingDir: THIS_PATH,
bundle: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
loader: { '.html': 'copy' },
entryPoints: [path.resolve(THIS_PATH, 'src', 'tailwind.css')],
outdir: outputPath,
outbase: 'src',
plugins: [
esbuildPluginNodeModules.NodeModulesPolyfillPlugin(),
esbuildPluginTime(),
esbuildPluginGenerateTailwind(),
],
define: {
// We are defining a constant, so it should be `CONSTANT_CASE`.
// eslint-disable-next-line @typescript-eslint/naming-convention
IS_DEV_MODE: JSON.stringify(args.devMode),
},
sourcemap: true,
minify: true,
metafile: true,
format: 'esm',
platform: 'browser',
color: true,
} satisfies esbuild.BuildOptions
// The narrower type is required to avoid non-null assertions elsewhere.
// The intersection with `esbuild.BuildOptions` is required to allow mutation.
const correctlyTypedBuildOptions: esbuild.BuildOptions & typeof buildOptions = buildOptions
return correctlyTypedBuildOptions
}
/** ESBuild options for bundling (one-off build) the package.
*
* Relies on the environment variables to be set. */
export function bundleOptions() {
return bundlerOptions(argumentsFromEnv())
}

View File

@ -1,28 +1,23 @@
{ {
"name": "enso-dashboard", "name": "enso-dashboard",
"version": "0.1.0", "version": "0.1.0",
"type": "module",
"private": true, "private": true,
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "npx --yes eslint src",
"build": "tsx bundle.ts",
"watch": "tsx watch.ts",
"start": "tsx start.ts"
},
"dependencies": { "dependencies": {
"@heroicons/react": "^2.0.15", "@heroicons/react": "^2.0.15",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.11", "@types/node": "^16.18.11",
"@types/react": "^18.0.27", "@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"esbuild": "^0.17.0",
"esbuild-plugin-time": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.7.0" "react-router-dom": "^6.7.0",
"react-scripts": "^5.0.1",
"web-vitals": "^2.1.4"
}, },
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0", "@typescript-eslint/parser": "^5.49.0",
"enso-authentication": "^1.0.0", "enso-authentication": "^1.0.0",
@ -30,12 +25,34 @@
"eslint": "^8.32.0", "eslint": "^8.32.0",
"eslint-plugin-jsdoc": "^39.6.8", "eslint-plugin-jsdoc": "^39.6.8",
"eslint-plugin-react": "^7.32.1", "eslint-plugin-react": "^7.32.1",
"tailwindcss": "^3.2.7", "jsdoc": "^4.0.0",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },
"optionalDependencies": { "scripts": {
"@esbuild/darwin-x64": "^0.17.0", "start": "react-scripts start",
"@esbuild/linux-x64": "^0.17.0", "build": "react-scripts build",
"@esbuild/windows-x64": "^0.17.0" "test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"comments": {
"browserslist": "The `browserslist` key is used by `react-scripts` to determine which browsers to support."
} }
} }

View File

@ -3,10 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"main": "./src/index.tsx", "main": "./src/index.tsx",
"exports": { "exports": "./src/index.tsx",
".": "./src/index.tsx",
"./src/platform": "./src/platform.ts"
},
"devDependencies": { "devDependencies": {
"typescript": "^4.9.3" "typescript": "^4.9.3"
}, },

View File

@ -3,25 +3,25 @@
* For example, this file contains the {@link SvgIcon} component, which is used by the * For example, this file contains the {@link SvgIcon} component, which is used by the
* `Registration` and `Login` components. */ * `Registration` and `Login` components. */
import * as fontawesome from '@fortawesome/react-fontawesome' import * as fontawesome from "@fortawesome/react-fontawesome";
import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons' import * as fontawesomeIcons from "@fortawesome/free-brands-svg-icons";
import * as icons from '../../components/svg' import * as icons from "../../components/svg";
// ============= // =============
// === Input === // === Input ===
// ============= // =============
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) { export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
return ( return (
<input <input
{...props} {...props}
className={ className={
'text-sm sm:text-base placeholder-gray-500 pl-10 pr-4 rounded-lg border border-gray-400 ' + "text-sm sm:text-base placeholder-gray-500 pl-10 pr-4 rounded-lg border border-gray-400 " +
'w-full py-2 focus:outline-none focus:border-blue-400' "w-full py-2 focus:outline-none focus:border-blue-400"
} }
/> />
) );
} }
// =============== // ===============
@ -29,22 +29,22 @@ export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
// =============== // ===============
interface SvgIconProps { interface SvgIconProps {
data: string data: string;
} }
export function SvgIcon(props: SvgIconProps) { export function SvgIcon(props: SvgIconProps) {
return ( return (
<div <div
className={ className={
'inline-flex items-center justify-center absolute left-0 top-0 h-full w-10 ' + "inline-flex items-center justify-center absolute left-0 top-0 h-full w-10 " +
'text-gray-400' "text-gray-400"
} }
> >
<span> <span>
<icons.Svg {...props} /> <icons.Svg {...props} />
</span> </span>
</div> </div>
) );
} }
// ======================= // =======================
@ -52,18 +52,18 @@ export function SvgIcon(props: SvgIconProps) {
// ======================= // =======================
interface FontAwesomeIconProps { interface FontAwesomeIconProps {
icon: fontawesomeIcons.IconDefinition icon: fontawesomeIcons.IconDefinition;
} }
export function FontAwesomeIcon(props: FontAwesomeIconProps) { export function FontAwesomeIcon(props: FontAwesomeIconProps) {
return ( return (
<span <span
className={ className={
'absolute left-0 top-0 flex items-center justify-center h-full w-10 ' + "absolute left-0 top-0 flex items-center justify-center h-full w-10 " +
'text-blue-500' "text-blue-500"
} }
> >
<fontawesome.FontAwesomeIcon icon={props.icon} /> <fontawesome.FontAwesomeIcon icon={props.icon} />
</span> </span>
) );
} }

View File

@ -1,60 +1,62 @@
/** @file Registration confirmation page for when a user clicks the confirmation link set to their /** @file Registration confirmation page for when a user clicks the confirmation link set to their
* email address. */ * email address. */
import * as react from 'react' import * as react from "react";
import * as router from 'react-router-dom' import * as router from "react-router-dom";
import toast from 'react-hot-toast' import toast from "react-hot-toast";
import * as app from '../../components/app' import * as app from "../../components/app";
import * as auth from '../providers/auth' import * as auth from "../providers/auth";
import * as loggerProvider from '../../providers/logger' import * as loggerProvider from "../../providers/logger";
// ================= // =================
// === Constants === // === Constants ===
// ================= // =================
const REGISTRATION_QUERY_PARAMS = { const REGISTRATION_QUERY_PARAMS = {
verificationCode: 'verification_code', verificationCode: "verification_code",
email: 'email', email: "email",
} as const } as const;
// ============================ // ============================
// === Confirm Registration === // === Confirm Registration ===
// ============================ // ============================
function ConfirmRegistration() { function ConfirmRegistration() {
const logger = loggerProvider.useLogger() const logger = loggerProvider.useLogger();
const { confirmSignUp } = auth.useAuth() const { confirmSignUp } = auth.useAuth();
const { search } = router.useLocation() const { search } = router.useLocation();
const navigate = router.useNavigate() const navigate = router.useNavigate();
const { verificationCode, email } = parseUrlSearchParams(search) const { verificationCode, email } = parseUrlSearchParams(search);
react.useEffect(() => { react.useEffect(() => {
if (!email || !verificationCode) { if (!email || !verificationCode) {
navigate(app.LOGIN_PATH) navigate(app.LOGIN_PATH);
} else { } else {
confirmSignUp(email, verificationCode) confirmSignUp(email, verificationCode)
.then(() => { .then(() => {
navigate(app.LOGIN_PATH + search.toString()) navigate(app.LOGIN_PATH + search.toString());
}) })
.catch(error => { .catch((error) => {
logger.error('Error while confirming sign-up', error) logger.error("Error while confirming sign-up", error);
toast.error( toast.error(
'Something went wrong! Please try again or contact the administrators.' "Something went wrong! Please try again or contact the administrators."
) );
navigate(app.LOGIN_PATH) navigate(app.LOGIN_PATH);
}) });
} }
}, []) }, []);
return <></> return <></>;
} }
function parseUrlSearchParams(search: string) { function parseUrlSearchParams(search: string) {
const query = new URLSearchParams(search) const query = new URLSearchParams(search);
const verificationCode = query.get(REGISTRATION_QUERY_PARAMS.verificationCode) const verificationCode = query.get(
const email = query.get(REGISTRATION_QUERY_PARAMS.email) REGISTRATION_QUERY_PARAMS.verificationCode
return { verificationCode, email } );
const email = query.get(REGISTRATION_QUERY_PARAMS.email);
return { verificationCode, email };
} }
export default ConfirmRegistration export default ConfirmRegistration;

View File

@ -1,93 +1,93 @@
/** @file Container responsible for rendering and interactions in first half of forgot password /** @file Container responsible for rendering and interactions in first half of forgot password
* flow. */ * flow. */
import * as router from 'react-router-dom' import * as router from "react-router-dom";
import * as app from '../../components/app' import * as app from "../../components/app";
import * as auth from '../providers/auth' import * as auth from "../providers/auth";
import * as common from './common' import * as common from "./common";
import * as hooks from '../../hooks' import * as hooks from "../../hooks";
import * as icons from '../../components/svg' import * as icons from "../../components/svg";
import * as utils from '../../utils' import * as utils from "../../utils";
// ====================== // ======================
// === ForgotPassword === // === ForgotPassword ===
// ====================== // ======================
function ForgotPassword() { function ForgotPassword() {
const { forgotPassword } = auth.useAuth() const { forgotPassword } = auth.useAuth();
const [email, bindEmail] = hooks.useInput('') const [email, bindEmail] = hooks.useInput("");
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-300"> <div className="min-h-screen flex flex-col items-center justify-center bg-gray-300">
<div <div
className={ className={
'flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md w-full ' + "flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md w-full " +
'max-w-md' "max-w-md"
} }
> >
<div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800"> <div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800">
Forgot Your Password? Forgot Your Password?
</div>
<div className="mt-10">
<form
onSubmit={utils.handleEvent(async () => {
await forgotPassword(email)
})}
>
<div className="flex flex-col mb-6">
<label
htmlFor="email"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
E-Mail Address:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindEmail}
id="email"
type="email"
name="email"
placeholder="E-Mail Address"
/>
</div>
</div>
<div className="flex w-full">
<button
type="submit"
className={
'flex items-center justify-center focus:outline-none text-white text-sm ' +
'sm:text-base bg-blue-600 hover:bg-blue-700 rounded py-2 w-full transition ' +
'duration-150 ease-in'
}
>
<span className="mr-2 uppercase">Send link</span>
<span>
<icons.Svg data={icons.PATHS.rightArrow} />
</span>
</button>
</div>
</form>
</div>
<div className="flex justify-center items-center mt-6">
<router.Link
to={app.LOGIN_PATH}
className={
'inline-flex items-center font-bold text-blue-500 hover:text-blue-700 text-xs ' +
'text-center'
}
>
<span>
<icons.Svg data={icons.PATHS.goBack} />
</span>
<span className="ml-2">Go back to login</span>
</router.Link>
</div>
</div>
</div> </div>
) <div className="mt-10">
<form
onSubmit={utils.handleEvent(async () => {
await forgotPassword(email);
})}
>
<div className="flex flex-col mb-6">
<label
htmlFor="email"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
E-Mail Address:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindEmail}
id="email"
type="email"
name="email"
placeholder="E-Mail Address"
/>
</div>
</div>
<div className="flex w-full">
<button
type="submit"
className={
"flex items-center justify-center focus:outline-none text-white text-sm " +
"sm:text-base bg-blue-600 hover:bg-blue-700 rounded py-2 w-full transition " +
"duration-150 ease-in"
}
>
<span className="mr-2 uppercase">Send link</span>
<span>
<icons.Svg data={icons.PATHS.rightArrow} />
</span>
</button>
</div>
</form>
</div>
<div className="flex justify-center items-center mt-6">
<router.Link
to={app.LOGIN_PATH}
className={
"inline-flex items-center font-bold text-blue-500 hover:text-blue-700 text-xs " +
"text-center"
}
>
<span>
<icons.Svg data={icons.PATHS.goBack} />
</span>
<span className="ml-2">Go back to login</span>
</router.Link>
</div>
</div>
</div>
);
} }
export default ForgotPassword export default ForgotPassword;

View File

@ -1,162 +1,170 @@
/** @file Login component responsible for rendering and interactions in sign in flow. */ /** @file Login component responsible for rendering and interactions in sign in flow. */
import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons' import * as fontawesomeIcons from "@fortawesome/free-brands-svg-icons";
import * as router from 'react-router-dom' import * as router from "react-router-dom";
import * as app from '../../components/app' import * as app from "../../components/app";
import * as auth from '../providers/auth' import * as auth from "../providers/auth";
import * as common from './common' import * as common from "./common";
import * as hooks from '../../hooks' import * as hooks from "../../hooks";
import * as icons from '../../components/svg' import * as icons from "../../components/svg";
import * as utils from '../../utils' import * as utils from "../../utils";
// ================= // =================
// === Constants === // === Constants ===
// ================= // =================
const BUTTON_CLASS_NAME = 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 = { const LOGIN_QUERY_PARAMS = {
email: 'email', email: "email",
} as const } as const;
// ============= // =============
// === Login === // === Login ===
// ============= // =============
function Login() { function Login() {
const { search } = router.useLocation() const { search } = router.useLocation();
const { signInWithGoogle, signInWithGitHub, signInWithPassword } = auth.useAuth() const { signInWithGoogle, signInWithGitHub, signInWithPassword } =
auth.useAuth();
const initialEmail = parseUrlSearchParams(search) const initialEmail = parseUrlSearchParams(search);
const [email, bindEmail] = hooks.useInput(initialEmail ?? '') const [email, bindEmail] = hooks.useInput(initialEmail ?? "");
const [password, bindPassword] = hooks.useInput('') const [password, bindPassword] = hooks.useInput("");
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-300"> <div className="min-h-screen flex flex-col items-center justify-center bg-gray-300">
<div <div
className={ className={
'flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md ' + "flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md " +
'w-full max-w-md' "w-full max-w-md"
} }
> >
<div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800"> <div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800">
Login To Your Account Login To Your Account
</div>
<button onClick={utils.handleEvent(signInWithGoogle)} className={BUTTON_CLASS_NAME}>
<common.FontAwesomeIcon icon={fontawesomeIcons.faGithub} />
<span>Login with Google</span>
</button>
<button onClick={utils.handleEvent(signInWithGitHub)} className={BUTTON_CLASS_NAME}>
<common.FontAwesomeIcon icon={fontawesomeIcons.faGithub} />
<span>Login with Github</span>
</button>
<div className="relative mt-10 h-px bg-gray-300">
<div className="absolute left-0 top-0 flex justify-center w-full -mt-2">
<span className="bg-white px-4 text-xs text-gray-500 uppercase">
Or Login With Email
</span>
</div>
</div>
<div className="mt-10">
<form
onSubmit={utils.handleEvent(async () =>
signInWithPassword(email, password)
)}
>
<div className="flex flex-col mb-6">
<label
htmlFor="email"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
E-Mail Address:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindEmail}
required={true}
id="email"
type="email"
name="email"
placeholder="E-Mail Address"
/>
</div>
</div>
<div className="flex flex-col mb-6">
<label
htmlFor="password"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindPassword}
required={true}
id="password"
type="password"
name="password"
placeholder="Password"
/>
</div>
</div>
<div className="flex items-center mb-6 -mt-4">
<div className="flex ml-auto">
<router.Link
to={app.FORGOT_PASSWORD_PATH}
className="inline-flex text-xs sm:text-sm text-blue-500 hover:text-blue-700"
>
Forgot Your Password?
</router.Link>
</div>
</div>
<div className="flex w-full">
<button
type="submit"
className={
'flex items-center justify-center focus:outline-none text-white ' +
'text-sm sm:text-base bg-blue-600 hover:bg-blue-700 rounded py-2 w-full ' +
'transition duration-150 ease-in'
}
>
<span className="mr-2 uppercase">Login</span>
<span>
<icons.Svg data={icons.PATHS.rightArrow} />
</span>
</button>
</div>
</form>
</div>
<div className="flex justify-center items-center mt-6">
<router.Link
to={app.REGISTRATION_PATH}
className={
'inline-flex items-center font-bold text-blue-500 hover:text-blue-700 ' +
'text-xs text-center'
}
>
<span>
<icons.Svg data={icons.PATHS.createAccount} />
</span>
<span className="ml-2">You don&apos;t have an account?</span>
</router.Link>
</div>
</div>
</div> </div>
) <button
onClick={utils.handleEvent(signInWithGoogle)}
className={BUTTON_CLASS_NAME}
>
<common.FontAwesomeIcon icon={fontawesomeIcons.faGithub} />
<span>Login with Google</span>
</button>
<button
onClick={utils.handleEvent(signInWithGitHub)}
className={BUTTON_CLASS_NAME}
>
<common.FontAwesomeIcon icon={fontawesomeIcons.faGithub} />
<span>Login with Github</span>
</button>
<div className="relative mt-10 h-px bg-gray-300">
<div className="absolute left-0 top-0 flex justify-center w-full -mt-2">
<span className="bg-white px-4 text-xs text-gray-500 uppercase">
Or Login With Email
</span>
</div>
</div>
<div className="mt-10">
<form
onSubmit={utils.handleEvent(async () =>
signInWithPassword(email, password)
)}
>
<div className="flex flex-col mb-6">
<label
htmlFor="email"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
E-Mail Address:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindEmail}
required={true}
id="email"
type="email"
name="email"
placeholder="E-Mail Address"
/>
</div>
</div>
<div className="flex flex-col mb-6">
<label
htmlFor="password"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindPassword}
required={true}
id="password"
type="password"
name="password"
placeholder="Password"
/>
</div>
</div>
<div className="flex items-center mb-6 -mt-4">
<div className="flex ml-auto">
<router.Link
to={app.FORGOT_PASSWORD_PATH}
className="inline-flex text-xs sm:text-sm text-blue-500 hover:text-blue-700"
>
Forgot Your Password?
</router.Link>
</div>
</div>
<div className="flex w-full">
<button
type="submit"
className={
"flex items-center justify-center focus:outline-none text-white " +
"text-sm sm:text-base bg-blue-600 hover:bg-blue-700 rounded py-2 w-full " +
"transition duration-150 ease-in"
}
>
<span className="mr-2 uppercase">Login</span>
<span>
<icons.Svg data={icons.PATHS.rightArrow} />
</span>
</button>
</div>
</form>
</div>
<div className="flex justify-center items-center mt-6">
<router.Link
to={app.REGISTRATION_PATH}
className={
"inline-flex items-center font-bold text-blue-500 hover:text-blue-700 " +
"text-xs text-center"
}
>
<span>
<icons.Svg data={icons.PATHS.createAccount} />
</span>
<span className="ml-2">You don&apos;t have an account?</span>
</router.Link>
</div>
</div>
</div>
);
} }
function parseUrlSearchParams(search: string) { function parseUrlSearchParams(search: string) {
const query = new URLSearchParams(search) const query = new URLSearchParams(search);
const email = query.get(LOGIN_QUERY_PARAMS.email) const email = query.get(LOGIN_QUERY_PARAMS.email);
return email return email;
} }
export default Login export default Login;

View File

@ -1,138 +1,138 @@
/** @file Registration container responsible for rendering and interactions in sign up flow. */ /** @file Registration container responsible for rendering and interactions in sign up flow. */
import * as router from 'react-router-dom' import * as router from "react-router-dom";
import toast from 'react-hot-toast' import toast from "react-hot-toast";
import * as app from '../../components/app' import * as app from "../../components/app";
import * as auth from '../providers/auth' import * as auth from "../providers/auth";
import * as common from './common' import * as common from "./common";
import * as hooks from '../../hooks' import * as hooks from "../../hooks";
import * as icons from '../../components/svg' import * as icons from "../../components/svg";
import * as utils from '../../utils' import * as utils from "../../utils";
// ==================== // ====================
// === Registration === // === Registration ===
// ==================== // ====================
function Registration() { function Registration() {
const { signUp } = auth.useAuth() const { signUp } = auth.useAuth();
const [email, bindEmail] = hooks.useInput('') const [email, bindEmail] = hooks.useInput("");
const [password, bindPassword] = hooks.useInput('') const [password, bindPassword] = hooks.useInput("");
const [confirmPassword, bindConfirmPassword] = hooks.useInput('') const [confirmPassword, bindConfirmPassword] = hooks.useInput("");
const handleSubmit = () => { const handleSubmit = () => {
/** The password & confirm password fields must match. */ /** The password & confirm password fields must match. */
if (password !== confirmPassword) { if (password !== confirmPassword) {
toast.error('Passwords do not match.') toast.error("Passwords do not match.");
return Promise.resolve() return Promise.resolve();
} else { } else {
return signUp(email, password) return signUp(email, password);
}
} }
};
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-300 px-4 py-8"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-300 px-4 py-8">
<div <div
className={ className={
'rounded-md bg-white w-full max-w-sm sm:max-w-md border border-gray-200 ' + "rounded-md bg-white w-full max-w-sm sm:max-w-md border border-gray-200 " +
'shadow-md px-4 py-6 sm:p-8' "shadow-md px-4 py-6 sm:p-8"
} }
> >
<div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800"> <div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800">
Create new account Create new account
</div>
<form onSubmit={utils.handleEvent(handleSubmit)}>
<div className="flex flex-col mb-4">
<label
htmlFor="email"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
E-Mail Address:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindEmail}
id="email"
type="email"
name="email"
placeholder="E-Mail Address"
/>
</div>
</div>
<div className="flex flex-col mb-4">
<label
htmlFor="password"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindPassword}
id="password"
type="password"
name="password"
placeholder="Password"
/>
</div>
</div>
<div className="flex flex-col mb-4">
<label
htmlFor="password_confirmation"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Confirm Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindConfirmPassword}
id="password_confirmation"
type="password"
name="password_confirmation"
placeholder="Confirm Password"
/>
</div>
</div>
<div className="flex w-full mt-6">
<button
type="submit"
className={
'flex items-center justify-center focus:outline-none text-white text-sm ' +
'sm:text-base bg-indigo-600 hover:bg-indigo-700 rounded py-2 w-full transition ' +
'duration-150 ease-in'
}
>
<span className="mr-2 uppercase">Register</span>
<span>
<icons.Svg data={icons.PATHS.createAccount} />
</span>
</button>
</div>
</form>
</div>
<div className="flex justify-center items-center mt-6">
<router.Link
to={app.LOGIN_PATH}
className={
'inline-flex items-center font-bold text-indigo-500 hover:text-indigo-700 ' +
'text-sm text-center'
}
>
<span>
<icons.Svg data={icons.PATHS.goBack} />
</span>
<span className="ml-2">Already have an account?</span>
</router.Link>
</div>
</div> </div>
)
<form onSubmit={utils.handleEvent(handleSubmit)}>
<div className="flex flex-col mb-4">
<label
htmlFor="email"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
E-Mail Address:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindEmail}
id="email"
type="email"
name="email"
placeholder="E-Mail Address"
/>
</div>
</div>
<div className="flex flex-col mb-4">
<label
htmlFor="password"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindPassword}
id="password"
type="password"
name="password"
placeholder="Password"
/>
</div>
</div>
<div className="flex flex-col mb-4">
<label
htmlFor="password_confirmation"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Confirm Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindConfirmPassword}
id="password_confirmation"
type="password"
name="password_confirmation"
placeholder="Confirm Password"
/>
</div>
</div>
<div className="flex w-full mt-6">
<button
type="submit"
className={
"flex items-center justify-center focus:outline-none text-white text-sm " +
"sm:text-base bg-indigo-600 hover:bg-indigo-700 rounded py-2 w-full transition " +
"duration-150 ease-in"
}
>
<span className="mr-2 uppercase">Register</span>
<span>
<icons.Svg data={icons.PATHS.createAccount} />
</span>
</button>
</div>
</form>
</div>
<div className="flex justify-center items-center mt-6">
<router.Link
to={app.LOGIN_PATH}
className={
"inline-flex items-center font-bold text-indigo-500 hover:text-indigo-700 " +
"text-sm text-center"
}
>
<span>
<icons.Svg data={icons.PATHS.goBack} />
</span>
<span className="ml-2">Already have an account?</span>
</router.Link>
</div>
</div>
);
} }
export default Registration export default Registration;

View File

@ -1,178 +1,181 @@
/** @file Container responsible for rendering and interactions in second half of forgot password /** @file Container responsible for rendering and interactions in second half of forgot password
* flow. */ * flow. */
import * as router from 'react-router-dom' import * as router from "react-router-dom";
import toast from 'react-hot-toast' import toast from "react-hot-toast";
import * as app from '../../components/app' import * as app from "../../components/app";
import * as auth from '../providers/auth' import * as auth from "../providers/auth";
import * as common from './common' import * as common from "./common";
import * as hooks from '../../hooks' import * as hooks from "../../hooks";
import * as icons from '../../components/svg' import * as icons from "../../components/svg";
import * as utils from '../../utils' import * as utils from "../../utils";
// ================= // =================
// === Constants === // === Constants ===
// ================= // =================
const RESET_PASSWORD_QUERY_PARAMS = { const RESET_PASSWORD_QUERY_PARAMS = {
email: 'email', email: "email",
verificationCode: 'verification_code', verificationCode: "verification_code",
} as const } as const;
// ===================== // =====================
// === ResetPassword === // === ResetPassword ===
// ===================== // =====================
function ResetPassword() { function ResetPassword() {
const { resetPassword } = auth.useAuth() const { resetPassword } = auth.useAuth();
const { search } = router.useLocation() 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 [email, bindEmail] = hooks.useInput(initialEmail ?? "");
const [code, bindCode] = hooks.useInput(initialCode ?? '') const [code, bindCode] = hooks.useInput(initialCode ?? "");
const [newPassword, bindNewPassword] = hooks.useInput('') const [newPassword, bindNewPassword] = hooks.useInput("");
const [newPasswordConfirm, bindNewPasswordConfirm] = hooks.useInput('') const [newPasswordConfirm, bindNewPasswordConfirm] = hooks.useInput("");
const handleSubmit = () => { const handleSubmit = () => {
if (newPassword !== newPasswordConfirm) { if (newPassword !== newPasswordConfirm) {
toast.error('Passwords do not match') toast.error("Passwords do not match");
return Promise.resolve() return Promise.resolve();
} else { } else {
return resetPassword(email, code, newPassword) return resetPassword(email, code, newPassword);
}
} }
};
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-300"> <div className="min-h-screen flex flex-col items-center justify-center bg-gray-300">
<div <div
className={ className={
'flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md w-full ' + "flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md w-full " +
'max-w-md' "max-w-md"
} }
> >
<div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800"> <div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800">
Reset Your Password Reset Your Password
</div>
<div className="mt-10">
<form onSubmit={utils.handleEvent(handleSubmit)}>
<div className="flex flex-col mb-6">
<label
htmlFor="email"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
E-Mail Address:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindEmail}
id="email"
type="email"
name="email"
placeholder="E-Mail Address"
/>
</div>
</div>
<div className="flex flex-col mb-6">
<label
htmlFor="code"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Confirmation Code:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindCode}
id="code"
type="text"
name="code"
placeholder="Confirmation Code"
/>
</div>
</div>
<div className="flex flex-col mb-6">
<label
htmlFor="new_password"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
New Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindNewPassword}
id="new_password"
type="password"
name="new_password"
placeholder="New Password"
/>
</div>
</div>
<div className="flex flex-col mb-6">
<label
htmlFor="new_password_confirm"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Confirm New Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindNewPasswordConfirm}
id="new_password_confirm"
type="password"
name="new_password_confirm"
placeholder="Confirm New Password"
/>
</div>
</div>
<div className="flex w-full">
<button
type="submit"
className={
'flex items-center justify-center focus:outline-none text-white text-sm ' +
'sm:text-base bg-blue-600 hover:bg-blue-700 rounded py-2 w-full transition ' +
'duration-150 ease-in'
}
>
<span className="mr-2 uppercase">Reset</span>
<span>
<icons.Svg data={icons.PATHS.rightArrow} />
</span>
</button>
</div>
</form>
</div>
<div className="flex justify-center items-center mt-6">
<router.Link
to={app.LOGIN_PATH}
className={
'inline-flex items-center font-bold text-blue-500 hover:text-blue-700 text-xs ' +
'text-center'
}
>
<span>
<icons.Svg data={icons.PATHS.goBack} />
</span>
<span className="ml-2">Go back to login</span>
</router.Link>
</div>
</div>
</div> </div>
) <div className="mt-10">
<form onSubmit={utils.handleEvent(handleSubmit)}>
<div className="flex flex-col mb-6">
<label
htmlFor="email"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
E-Mail Address:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindEmail}
id="email"
type="email"
name="email"
placeholder="E-Mail Address"
/>
</div>
</div>
<div className="flex flex-col mb-6">
<label
htmlFor="code"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Confirmation Code:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindCode}
id="code"
type="text"
name="code"
placeholder="Confirmation Code"
/>
</div>
</div>
<div className="flex flex-col mb-6">
<label
htmlFor="new_password"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
New Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindNewPassword}
id="new_password"
type="password"
name="new_password"
placeholder="New Password"
/>
</div>
</div>
<div className="flex flex-col mb-6">
<label
htmlFor="new_password_confirm"
className="mb-1 text-xs sm:text-sm tracking-wide text-gray-600"
>
Confirm New Password:
</label>
<div className="relative">
<common.SvgIcon data={icons.PATHS.lock} />
<common.Input
{...bindNewPasswordConfirm}
id="new_password_confirm"
type="password"
name="new_password_confirm"
placeholder="Confirm New Password"
/>
</div>
</div>
<div className="flex w-full">
<button
type="submit"
className={
"flex items-center justify-center focus:outline-none text-white text-sm " +
"sm:text-base bg-blue-600 hover:bg-blue-700 rounded py-2 w-full transition " +
"duration-150 ease-in"
}
>
<span className="mr-2 uppercase">Reset</span>
<span>
<icons.Svg data={icons.PATHS.rightArrow} />
</span>
</button>
</div>
</form>
</div>
<div className="flex justify-center items-center mt-6">
<router.Link
to={app.LOGIN_PATH}
className={
"inline-flex items-center font-bold text-blue-500 hover:text-blue-700 text-xs " +
"text-center"
}
>
<span>
<icons.Svg data={icons.PATHS.goBack} />
</span>
<span className="ml-2">Go back to login</span>
</router.Link>
</div>
</div>
</div>
);
} }
function parseUrlSearchParams(search: string) { function parseUrlSearchParams(search: string) {
const query = new URLSearchParams(search) const query = new URLSearchParams(search);
const verificationCode = query.get(RESET_PASSWORD_QUERY_PARAMS.verificationCode) const verificationCode = query.get(
const email = query.get(RESET_PASSWORD_QUERY_PARAMS.email) RESET_PASSWORD_QUERY_PARAMS.verificationCode
return { verificationCode, email } );
const email = query.get(RESET_PASSWORD_QUERY_PARAMS.email);
return { verificationCode, email };
} }
export default ResetPassword export default ResetPassword;

View File

@ -1,72 +1,72 @@
/** @file Container responsible for rendering and interactions in setting username flow, after /** @file Container responsible for rendering and interactions in setting username flow, after
* registration. */ * registration. */
import * as auth from '../providers/auth' import * as auth from "../providers/auth";
import * as common from './common' import * as common from "./common";
import * as hooks from '../../hooks' import * as hooks from "../../hooks";
import * as icons from '../../components/svg' import * as icons from "../../components/svg";
import * as utils from '../../utils' import * as utils from "../../utils";
// =================== // ===================
// === SetUsername === // === SetUsername ===
// =================== // ===================
function SetUsername() { function SetUsername() {
const { setUsername } = auth.useAuth() const { setUsername } = auth.useAuth();
const { accessToken, email } = auth.usePartialUserSession() const { accessToken, email } = auth.usePartialUserSession();
const [username, bindUsername] = hooks.useInput('') const [username, bindUsername] = hooks.useInput("");
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-300"> <div className="min-h-screen flex flex-col items-center justify-center bg-gray-300">
<div <div
className={ className={
'flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md w-full ' + "flex flex-col bg-white shadow-md px-4 sm:px-6 md:px-8 lg:px-10 py-8 rounded-md w-full " +
'max-w-md' "max-w-md"
} }
> >
<div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800"> <div className="font-medium self-center text-xl sm:text-2xl uppercase text-gray-800">
Set your username Set your username
</div>
<div className="mt-10">
<form
onSubmit={utils.handleEvent(() =>
setUsername(accessToken, username, email)
)}
>
<div className="flex flex-col mb-6">
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindUsername}
id="username"
type="text"
name="username"
placeholder="Username"
/>
</div>
</div>
<div className="flex w-full">
<button
type="submit"
className={
'flex items-center justify-center focus:outline-none text-white text-sm ' +
'sm:text-base bg-blue-600 hover:bg-blue-700 rounded py-2 w-full transition ' +
'duration-150 ease-in'
}
>
<span className="mr-2 uppercase">Set username</span>
<span>
<icons.Svg data={icons.PATHS.rightArrow} />
</span>
</button>
</div>
</form>
</div>
</div>
</div> </div>
) <div className="mt-10">
<form
onSubmit={utils.handleEvent(() =>
setUsername(accessToken, username, email)
)}
>
<div className="flex flex-col mb-6">
<div className="relative">
<common.SvgIcon data={icons.PATHS.at} />
<common.Input
{...bindUsername}
id="username"
type="text"
name="username"
placeholder="Username"
/>
</div>
</div>
<div className="flex w-full">
<button
type="submit"
className={
"flex items-center justify-center focus:outline-none text-white text-sm " +
"sm:text-base bg-blue-600 hover:bg-blue-700 rounded py-2 w-full transition " +
"duration-150 ease-in"
}
>
<span className="mr-2 uppercase">Set username</span>
<span>
<icons.Svg data={icons.PATHS.rightArrow} />
</span>
</button>
</div>
</form>
</div>
</div>
</div>
);
} }
export default SetUsername export default SetUsername;

View File

@ -3,14 +3,14 @@
* Listening to authentication events is necessary to update the authentication state of the * 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 * application. For example, if the user signs out, we want to clear the authentication state so
* that the login screen is rendered. */ * that the login screen is rendered. */
import * as amplify from '@aws-amplify/core' import * as amplify from "@aws-amplify/core";
// ================= // =================
// === Constants === // === Constants ===
// ================= // =================
/** Name of the string identifying the "hub" that AWS Amplify issues authentication events on. */ /** Name of the string identifying the "hub" that AWS Amplify issues authentication events on. */
const AUTHENTICATION_HUB = 'auth' const AUTHENTICATION_HUB = "auth";
// ================= // =================
// === AuthEvent === // === 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, * 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=...`. */ * when the user signs in or signs out by accessing a page like `enso://auth?code=...&state=...`. */
export enum AuthEvent { export enum AuthEvent {
/** Issued when the user has passed custom OAuth state parameters to some other auth event. */ /** Issued when the user has passed custom OAuth state parameters to some other auth event. */
customOAuthState = 'customOAuthState', customOAuthState = "customOAuthState",
/** Issued when the user completes the sign-in process (via federated identity provider). */ /** Issued when the user completes the sign-in process (via federated identity provider). */
cognitoHostedUi = 'cognitoHostedUI', cognitoHostedUi = "cognitoHostedUI",
/** Issued when the user completes the sign-in process (via email/password). */ /** Issued when the user completes the sign-in process (via email/password). */
signIn = 'signIn', signIn = "signIn",
/** Issued when the user signs out. */ /** Issued when the user signs out. */
signOut = 'signOut', signOut = "signOut",
} }
/** Returns `true` if the given `string` is an {@link AuthEvent}. */ /** Returns `true` if the given `string` is an {@link AuthEvent}. */
function isAuthEvent(value: string): value is AuthEvent { function isAuthEvent(value: string): value is AuthEvent {
return Object.values<string>(AuthEvent).includes(value) return Object.values<string>(AuthEvent).includes(value);
} }
// ================================= // =================================
@ -43,24 +43,26 @@ function isAuthEvent(value: string): value is AuthEvent {
/** Callback called in response to authentication state changes. /** Callback called in response to authentication state changes.
* *
* @see {@link Api["listen"]} */ * @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. /** Unsubscribes the {@link ListenerCallback} from authentication state changes.
* *
* @see {@link Api["listen"]} */ * @see {@link Api["listen"]} */
type UnsubscribeFunction = () => void type UnsubscribeFunction = () => void;
/** Used to subscribe to {@link AuthEvent}s. /** Used to subscribe to {@link AuthEvent}s.
* *
* Returns a function that MUST be called before re-subscribing, * Returns a function that MUST be called before re-subscribing,
* to avoid memory leaks or duplicate event handlers. */ * 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) { export function registerAuthEventListener(listener: ListenerCallback) {
const callback: amplify.HubCallback = data => { const callback: amplify.HubCallback = (data) => {
if (isAuthEvent(data.payload.event)) { if (isAuthEvent(data.payload.event)) {
listener(data.payload.event, data.payload.data) listener(data.payload.event, data.payload.data);
}
} }
return amplify.Hub.listen(AUTHENTICATION_HUB, callback) };
return amplify.Hub.listen(AUTHENTICATION_HUB, callback);
} }

View File

@ -3,31 +3,31 @@
* Provides an `AuthProvider` component that wraps the entire application, and a `useAuth` hook that * 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 * 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. */ * hook also provides methods for registering a user, logging in, logging out, etc. */
import * as react from 'react' import * as react from "react";
import * as router from 'react-router-dom' import * as router from "react-router-dom";
import toast from 'react-hot-toast' import toast from "react-hot-toast";
import * as app from '../../components/app' import * as app from "../../components/app";
import * as authServiceModule from '../service' import * as authServiceModule from "../service";
import * as backendService from '../../dashboard/service' import * as backendService from "../../dashboard/service";
import * as errorModule from '../../error' import * as errorModule from "../../error";
import * as loggerProvider from '../../providers/logger' import * as loggerProvider from "../../providers/logger";
import * as sessionProvider from './session' import * as sessionProvider from "./session";
// ================= // =================
// === Constants === // === Constants ===
// ================= // =================
const MESSAGES = { const MESSAGES = {
signUpSuccess: 'We have sent you an email with further instructions!', signUpSuccess: "We have sent you an email with further instructions!",
confirmSignUpSuccess: 'Your account has been confirmed! Please log in.', confirmSignUpSuccess: "Your account has been confirmed! Please log in.",
setUsernameSuccess: 'Your username has been set!', setUsernameSuccess: "Your username has been set!",
signInWithPasswordSuccess: 'Successfully logged in!', signInWithPasswordSuccess: "Successfully logged in!",
forgotPasswordSuccess: 'We have sent you an email with further instructions!', forgotPasswordSuccess: "We have sent you an email with further instructions!",
resetPasswordSuccess: 'Successfully reset password!', resetPasswordSuccess: "Successfully reset password!",
signOutSuccess: 'Successfully logged out!', signOutSuccess: "Successfully logged out!",
pleaseWait: 'Please wait...', pleaseWait: "Please wait...",
} as const } as const;
// ============= // =============
// === Types === // === Types ===
@ -35,19 +35,19 @@ const MESSAGES = {
// === UserSession === // === UserSession ===
export type UserSession = FullUserSession | PartialUserSession export type UserSession = FullUserSession | PartialUserSession;
/** Object containing the currently signed-in user's session data. */ /** Object containing the currently signed-in user's session data. */
export interface FullUserSession { export interface FullUserSession {
/** A discriminator for TypeScript to be able to disambiguate between this interface and other /** A discriminator for TypeScript to be able to disambiguate between this interface and other
* `UserSession` variants. */ * `UserSession` variants. */
variant: 'full' variant: "full";
/** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */ /** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */
accessToken: string accessToken: string;
/** User's email address. */ /** User's email address. */
email: string email: string;
/** User's organization information. */ /** User's organization information. */
organization: backendService.Organization organization: backendService.Organization;
} }
/** Object containing the currently signed-in user's session data, if the user has not yet set their /** 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 * their account. Otherwise, this type is identical to the `Session` type. This type should ONLY be
* used by the `SetUsername` component. */ * used by the `SetUsername` component. */
export interface PartialUserSession { export interface PartialUserSession {
/** A discriminator for TypeScript to be able to disambiguate between this interface and other /** A discriminator for TypeScript to be able to disambiguate between this interface and other
* `UserSession` variants. */ * `UserSession` variants. */
variant: 'partial' variant: "partial";
/** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */ /** User's JSON Web Token (JWT), used for authenticating and authorizing requests to the API. */
accessToken: string accessToken: string;
/** User's email address. */ /** User's email address. */
email: string email: string;
} }
// =================== // ===================
@ -78,19 +78,27 @@ export interface PartialUserSession {
* *
* See {@link Cognito} for details on each of the authentication functions. */ * See {@link Cognito} for details on each of the authentication functions. */
interface AuthContextType { interface AuthContextType {
signUp: (email: string, password: string) => Promise<void> signUp: (email: string, password: string) => Promise<void>;
confirmSignUp: (email: string, code: string) => Promise<void> confirmSignUp: (email: string, code: string) => Promise<void>;
setUsername: (accessToken: string, username: string, email: string) => Promise<void> setUsername: (
signInWithGoogle: () => Promise<void> accessToken: string,
signInWithGitHub: () => Promise<void> username: string,
signInWithPassword: (email: string, password: string) => Promise<void> email: string
forgotPassword: (email: string) => Promise<void> ) => Promise<void>;
resetPassword: (email: string, code: string, password: string) => Promise<void> signInWithGoogle: () => Promise<void>;
signOut: () => Promise<void> signInWithGitHub: () => Promise<void>;
/** Session containing the currently authenticated user's authentication information. signInWithPassword: (email: string, password: string) => Promise<void>;
* forgotPassword: (email: string) => Promise<void>;
* If the user has not signed in, the session will be `null`. */ resetPassword: (
session: UserSession | null email: string,
code: string,
password: string
) => Promise<void>;
signOut: () => Promise<void>;
/** 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. // Eslint doesn't like headings.
@ -118,186 +126,192 @@ interface AuthContextType {
* So changing the cast would provide no safety guarantees, and would require us to introduce null * So changing the cast would provide no safety guarantees, and would require us to introduce null
* checks everywhere we use the context. */ * checks everywhere we use the context. */
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const AuthContext = react.createContext<AuthContextType>({} as AuthContextType) const AuthContext = react.createContext<AuthContextType>({} as AuthContextType);
// ==================== // ====================
// === AuthProvider === // === AuthProvider ===
// ==================== // ====================
export interface AuthProviderProps { export interface AuthProviderProps {
authService: authServiceModule.AuthService authService: authServiceModule.AuthService;
/** Callback to execute once the user has authenticated successfully. */ /** Callback to execute once the user has authenticated successfully. */
onAuthenticated: () => void onAuthenticated: () => void;
children: react.ReactNode children: react.ReactNode;
} }
export function AuthProvider(props: AuthProviderProps) { export function AuthProvider(props: AuthProviderProps) {
const { authService, children } = props const { authService, children } = props;
const { cognito } = authService const { cognito } = authService;
const { session } = sessionProvider.useSession() const { session } = sessionProvider.useSession();
const logger = loggerProvider.useLogger() const logger = loggerProvider.useLogger();
const navigate = router.useNavigate() const navigate = router.useNavigate();
const onAuthenticated = react.useCallback(props.onAuthenticated, []) const onAuthenticated = react.useCallback(props.onAuthenticated, []);
const [initialized, setInitialized] = react.useState(false) const [initialized, setInitialized] = react.useState(false);
const [userSession, setUserSession] = react.useState<UserSession | null>(null) const [userSession, setUserSession] = react.useState<UserSession | null>(
null
);
/** Fetch the JWT access token from the session via the AWS Amplify library. /** 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 * 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`. * 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. */ * If the token has expired, automatically refreshes the token and returns the new token. */
react.useEffect(() => { react.useEffect(() => {
const fetchSession = async () => { const fetchSession = async () => {
if (session.none) { if (session.none) {
setInitialized(true) setInitialized(true);
setUserSession(null) setUserSession(null);
} else { } else {
const { accessToken, email } = session.val const { accessToken, email } = session.val;
const backend = backendService.createBackend(accessToken, logger) const backend = backendService.createBackend(accessToken, logger);
const organization = await backend.getUser() const organization = await backend.getUser();
let newUserSession: UserSession let newUserSession: UserSession;
if (!organization) { if (!organization) {
newUserSession = { newUserSession = {
variant: 'partial', variant: "partial",
email, email,
accessToken, accessToken,
} };
} else { } else {
newUserSession = { newUserSession = {
variant: 'full', variant: "full",
email, email,
accessToken, accessToken,
organization, organization,
} };
/** Execute the callback that should inform the Electron app that the user has logged in. /** 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. */ * This is done to transition the app from the authentication/dashboard view to the IDE. */
onAuthenticated() onAuthenticated();
}
setUserSession(newUserSession)
setInitialized(true)
}
} }
fetchSession().catch(error => { setUserSession(newUserSession);
if (isUserFacingError(error)) { setInitialized(true);
toast.error(error.message) }
} else { };
logger.error(error)
}
})
}, [session])
const withLoadingToast = fetchSession().catch((error) => {
<T extends unknown[]>(action: (...args: T) => Promise<void>) => if (isUserFacingError(error)) {
async (...args: T) => { toast.error(error.message);
const loadingToast = toast.loading(MESSAGES.pleaseWait) } else {
try { logger.error(error);
await action(...args) }
} finally { });
toast.dismiss(loadingToast) }, [session]);
}
const withLoadingToast =
<T extends unknown[]>(action: (...args: T) => Promise<void>) =>
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 signUp = (username: string, password: string) => toast.error(result.val.message);
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) => const setUsername = async (
cognito.confirmSignUp(email, code).then(result => { accessToken: string,
if (result.err) { username: string,
switch (result.val.kind) { email: string
case 'UserAlreadyConfirmed': ) => {
break const body: backendService.SetUsernameRequestBody = {
default: userName: username,
throw new errorModule.UnreachableCaseError(result.val.kind) userEmail: email,
} };
}
toast.success(MESSAGES.confirmSignUpSuccess) /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343
navigate(app.LOGIN_PATH) * 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 signInWithPassword = async (email: string, password: string) => await backend.setUsername(body);
cognito.signInWithPassword(email, password).then(result => { navigate(app.DASHBOARD_PATH);
if (result.ok) { toast.success(MESSAGES.setUsernameSuccess);
toast.success(MESSAGES.signInWithPasswordSuccess) };
} else {
if (result.val.kind === 'UserNotFound') {
navigate(app.REGISTRATION_PATH)
}
toast.error(result.val.message) 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 setUsername = async (accessToken: string, username: string, email: string) => { const resetPassword = async (email: string, code: string, password: string) =>
const body: backendService.SetUsernameRequestBody = { cognito.forgotPasswordSubmit(email, code, password).then((result) => {
userName: username, if (result.ok) {
userEmail: email, toast.success(MESSAGES.resetPasswordSuccess);
} navigate(app.LOGIN_PATH);
} else {
toast.error(result.val.message);
}
});
/** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/343 const signOut = () =>
* The API client is reinitialised on every request. That is an inefficient way of usage. cognito.signOut().then(() => {
* Fix it by using React context and implementing it as a singleton. */ toast.success(MESSAGES.signOutSuccess);
const backend = backendService.createBackend(accessToken, logger) });
await backend.setUsername(body) const value = {
navigate(app.DASHBOARD_PATH) signUp: withLoadingToast(signUp),
toast.success(MESSAGES.setUsernameSuccess) 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,
};
const forgotPassword = async (email: string) => return (
cognito.forgotPassword(email).then(result => { <AuthContext.Provider value={value}>
if (result.ok) { {/* Only render the underlying app after we assert for the presence of a current user. */}
toast.success(MESSAGES.forgotPasswordSuccess) {initialized && children}
navigate(app.RESET_PASSWORD_PATH) </AuthContext.Provider>
} 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 (
<AuthContext.Provider value={value}>
{/* Only render the underlying app after we assert for the presence of a current user. */}
{initialized && children}
</AuthContext.Provider>
)
} }
/** Type of an error containing a `string`-typed `message` field. /** Type of an error containing a `string`-typed `message` field.
@ -305,13 +319,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 * Many types of errors fall into this category. We use this type to check if an error can be safely
* displayed to the user. */ * displayed to the user. */
interface UserFacingError { interface UserFacingError {
/** The user-facing error message. */ /** The user-facing error message. */
message: string message: string;
} }
/** Returns `true` if the value is a {@link UserFacingError}. */ /** Returns `true` if the value is a {@link UserFacingError}. */
function isUserFacingError(value: unknown): value is 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;
} }
// =============== // ===============
@ -323,7 +337,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 * Only the hook is exported, and not the context, because we only want to use the hook directly and
* never the context component. */ * never the context component. */
export function useAuth() { export function useAuth() {
return react.useContext(AuthContext) return react.useContext(AuthContext);
} }
// ======================= // =======================
@ -332,13 +346,13 @@ export function useAuth() {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
export function ProtectedLayout() { export function ProtectedLayout() {
const { session } = useAuth() const { session } = useAuth();
if (!session) { if (!session) {
return <router.Navigate to={app.LOGIN_PATH} /> return <router.Navigate to={app.LOGIN_PATH} />;
} else { } else {
return <router.Outlet context={session} /> return <router.Outlet context={session} />;
} }
} }
// =================== // ===================
@ -347,15 +361,15 @@ export function ProtectedLayout() {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
export function GuestLayout() { export function GuestLayout() {
const { session } = useAuth() const { session } = useAuth();
if (session?.variant === 'partial') { if (session?.variant === "partial") {
return <router.Navigate to={app.SET_USERNAME_PATH} /> return <router.Navigate to={app.SET_USERNAME_PATH} />;
} else if (session?.variant === 'full') { } else if (session?.variant === "full") {
return <router.Navigate to={app.DASHBOARD_PATH} /> return <router.Navigate to={app.DASHBOARD_PATH} />;
} else { } else {
return <router.Outlet /> return <router.Outlet />;
} }
} }
// ============================= // =============================
@ -363,7 +377,7 @@ export function GuestLayout() {
// ============================= // =============================
export function usePartialUserSession() { export function usePartialUserSession() {
return router.useOutletContext<PartialUserSession>() return router.useOutletContext<PartialUserSession>();
} }
// ========================== // ==========================
@ -371,5 +385,5 @@ export function usePartialUserSession() {
// ========================== // ==========================
export function useFullUserSession() { export function useFullUserSession() {
return router.useOutletContext<FullUserSession>() return router.useOutletContext<FullUserSession>();
} }

View File

@ -1,136 +1,139 @@
/** @file Provider for the {@link SessionContextType}, which contains information about the /** @file Provider for the {@link SessionContextType}, which contains information about the
* currently authenticated user's session. */ * 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 cognito from "../cognito";
import * as error from '../../error' import * as error from "../../error";
import * as hooks from '../../hooks' import * as hooks from "../../hooks";
import * as listen from '../listen' import * as listen from "../listen";
// ====================== // ======================
// === SessionContext === // === SessionContext ===
// ====================== // ======================
interface SessionContextType { interface SessionContextType {
session: results.Option<cognito.UserSession> session: results.Option<cognito.UserSession>;
} }
/** See {@link AuthContext} for safety details. */ /** See {@link AuthContext} for safety details. */
const SessionContext = react.createContext<SessionContextType>( const SessionContext = react.createContext<SessionContextType>(
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
{} as SessionContextType {} as SessionContextType
) );
// ======================= // =======================
// === SessionProvider === // === SessionProvider ===
// ======================= // =======================
interface SessionProviderProps { interface SessionProviderProps {
/** URL that the content of the app is served at, by Electron. /** 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 * 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 * 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). * will occur (which is the desired behaviour).
* *
* The URL includes a scheme, hostname, and port (e.g., `http://localhost:8080`). The port is not * 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 * 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 * 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 guaranteed to be the correct location, since authentication is instantiated when the content
* is initially served. */ * is initially served. */
mainPageUrl: URL mainPageUrl: URL;
registerAuthEventListener: listen.ListenFunction registerAuthEventListener: listen.ListenFunction;
userSession: () => Promise<results.Option<cognito.UserSession>> userSession: () => Promise<results.Option<cognito.UserSession>>;
children: react.ReactNode children: react.ReactNode;
} }
export function SessionProvider(props: SessionProviderProps) { 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 /** 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. */ * once. Avoids flash of the login screen when the user is already logged in. */
const [initialized, setInitialized] = react.useState(false) const [initialized, setInitialized] = react.useState(false);
/** Produces a new object every time. /** Produces a new object every time.
* This is not equal to any other empty object because objects are compared by reference. * 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. */ * Because it is not equal to the old value, React re-renders the component. */
function newRefresh() { function newRefresh() {
return {} return {};
} }
/** State that, when set, forces a refresh of the user session. This is useful when a /** 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 * user has just logged in (so their cached credentials are out of date). Should be used via the
* `refreshSession` function. */ * `refreshSession` function. */
const [refresh, setRefresh] = react.useState(newRefresh()) const [refresh, setRefresh] = react.useState(newRefresh());
/** Forces a refresh of the user session. /** Forces a refresh of the user session.
* *
* Should be called after any operation that **will** (not **might**) change the user's 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 * 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. */ * of the whole page, which is why it should only be done when necessary. */
const refreshSession = () => { const refreshSession = () => {
setRefresh(newRefresh()) setRefresh(newRefresh());
} };
/** Register an async effect that will fetch the user's session whenever the `refresh` state is /** 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 * incremented. This is useful when a user has just logged in (as their cached credentials are
* out of date, so this will update them). */ * out of date, so this will update them). */
const session = hooks.useAsyncEffect( const session = hooks.useAsyncEffect(
results.None, results.None,
async () => { async () => {
const innerSession = await userSession() const innerSession = await userSession();
setInitialized(true) setInitialized(true);
return innerSession return innerSession;
}, },
[refresh, userSession] [refresh, userSession]
) );
/** Register an effect that will listen for authentication events. When the event occurs, we /** 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 * will refresh or clear the user's session, forcing a re-render of the page with the new
* session. * session.
* *
* For example, if a user clicks the signout button, this will clear the user's session, which * 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). */ * means we want the login screen to render (which is a child of this provider). */
react.useEffect(() => { react.useEffect(() => {
const listener: listen.ListenerCallback = event => { const listener: listen.ListenerCallback = (event) => {
switch (event) { switch (event) {
case listen.AuthEvent.signIn: case listen.AuthEvent.signIn:
case listen.AuthEvent.signOut: { case listen.AuthEvent.signOut: {
refreshSession() refreshSession();
break 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) const cancel = registerAuthEventListener(listener);
/** Return the `cancel` function from the `useEffect`, which ensures that the listener is /** 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 * cleaned up between renders. This must be done because the `useEffect` will be called
* multiple times during the lifetime of the component. */ * multiple times during the lifetime of the component. */
return cancel return cancel;
}, [registerAuthEventListener]) }, [registerAuthEventListener]);
const value = { session } const value = { session };
return ( return (
<SessionContext.Provider value={value}>{initialized && children}</SessionContext.Provider> <SessionContext.Provider value={value}>
) {initialized && children}
</SessionContext.Provider>
);
} }
// ================== // ==================
@ -138,5 +141,5 @@ export function SessionProvider(props: SessionProviderProps) {
// ================== // ==================
export function useSession() { export function useSession() {
return react.useContext(SessionContext) return react.useContext(SessionContext);
} }

View File

@ -1,18 +1,18 @@
/** @file Provides an {@link AuthService} which consists of an underyling {@link Cognito} API /** @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 * wrapper, along with some convenience callbacks to make URL redirects for the authentication flows
* work with Electron. */ * 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 app from "../components/app";
import * as auth from './config' import * as auth from "./config";
import * as cognito from './cognito' import * as cognito from "./cognito";
import * as config from '../config' import * as config from "../config";
import * as listen from './listen' import * as listen from "./listen";
import * as loggerProvider from '../providers/logger' import * as loggerProvider from "../providers/logger";
import * as platformModule from '../platform' import * as platformModule from "../platform";
import * as utils from '../utils' import * as utils from "../utils";
// ================= // =================
// === Constants === // === Constants ===
@ -20,59 +20,67 @@ import * as utils from '../utils'
/** Pathname of the {@link URL} for deep links to the sign in page, after a redirect from a /** Pathname of the {@link URL} for deep links to the sign in page, after a redirect from a
* federated identity provider. */ * 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 /** Pathname of the {@link URL} for deep links to the sign out page, after a redirect from a
* federated identity provider. */ * 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 /** Pathname of the {@link URL} for deep links to the registration confirmation page, after a
* redirect from an account verification email. */ * 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 /** Pathname of the {@link URL} for deep links to the login page, after a redirect from a reset
* password email. */ * password email. */
const LOGIN_PATHNAME = '//auth/login' const LOGIN_PATHNAME = "//auth/login";
/** URL used as the OAuth redirect when running in the desktop app. */ /** URL used as the OAuth redirect when running in the desktop app. */
const DESKTOP_REDIRECT = utils.brand<auth.OAuthRedirect>(`${common.DEEP_LINK_SCHEME}://auth`) const DESKTOP_REDIRECT = utils.brand<auth.OAuthRedirect>(
`${common.DEEP_LINK_SCHEME}://auth`
);
/** Map from platform to the OAuth redirect URL that should be used for that platform. */ /** Map from platform to the OAuth redirect URL that should be used for that platform. */
const PLATFORM_TO_CONFIG: Record< const PLATFORM_TO_CONFIG: Record<
platformModule.Platform, platformModule.Platform,
Pick<auth.AmplifyConfig, 'redirectSignIn' | 'redirectSignOut'> Pick<auth.AmplifyConfig, "redirectSignIn" | "redirectSignOut">
> = { > = {
[platformModule.Platform.desktop]: { [platformModule.Platform.desktop]: {
redirectSignIn: DESKTOP_REDIRECT, redirectSignIn: DESKTOP_REDIRECT,
redirectSignOut: DESKTOP_REDIRECT, redirectSignOut: DESKTOP_REDIRECT,
}, },
[platformModule.Platform.cloud]: { [platformModule.Platform.cloud]: {
redirectSignIn: config.ACTIVE_CONFIG.cloudRedirect, redirectSignIn: config.ACTIVE_CONFIG.cloudRedirect,
redirectSignOut: config.ACTIVE_CONFIG.cloudRedirect, redirectSignOut: config.ACTIVE_CONFIG.cloudRedirect,
}, },
} };
const BASE_AMPLIFY_CONFIG = { const BASE_AMPLIFY_CONFIG = {
region: auth.AWS_REGION, region: auth.AWS_REGION,
scope: auth.OAUTH_SCOPES, scope: auth.OAUTH_SCOPES,
responseType: auth.OAUTH_RESPONSE_TYPE, responseType: auth.OAUTH_RESPONSE_TYPE,
} satisfies Partial<auth.AmplifyConfig> } satisfies Partial<auth.AmplifyConfig>;
/** Collection of configuration details for Amplify user pools, sorted by deployment environment. */ /** Collection of configuration details for Amplify user pools, sorted by deployment environment. */
const AMPLIFY_CONFIGS = { const AMPLIFY_CONFIGS = {
/** Configuration for @pbuchu's Cognito user pool. */ /** Configuration for @pbuchu's Cognito user pool. */
pbuchu: { pbuchu: {
userPoolId: utils.brand<auth.UserPoolId>('eu-west-1_jSF1RbgPK'), userPoolId: utils.brand<auth.UserPoolId>("eu-west-1_jSF1RbgPK"),
userPoolWebClientId: utils.brand<auth.UserPoolWebClientId>('1bnib0jfon3aqc5g3lkia2infr'), userPoolWebClientId: utils.brand<auth.UserPoolWebClientId>(
domain: utils.brand<auth.OAuthDomain>('pb-enso-domain.auth.eu-west-1.amazoncognito.com'), "1bnib0jfon3aqc5g3lkia2infr"
...BASE_AMPLIFY_CONFIG, ),
} satisfies Partial<auth.AmplifyConfig>, domain: utils.brand<auth.OAuthDomain>(
/** Configuration for the production Cognito user pool. */ "pb-enso-domain.auth.eu-west-1.amazoncognito.com"
production: { ),
userPoolId: utils.brand<auth.UserPoolId>('eu-west-1_9Kycu2SbD'), ...BASE_AMPLIFY_CONFIG,
userPoolWebClientId: utils.brand<auth.UserPoolWebClientId>('4j9bfs8e7415erf82l129v0qhe'), } satisfies Partial<auth.AmplifyConfig>,
domain: utils.brand<auth.OAuthDomain>( /** Configuration for the production Cognito user pool. */
'production-enso-domain.auth.eu-west-1.amazoncognito.com' production: {
), userPoolId: utils.brand<auth.UserPoolId>("eu-west-1_9Kycu2SbD"),
...BASE_AMPLIFY_CONFIG, userPoolWebClientId: utils.brand<auth.UserPoolWebClientId>(
} satisfies Partial<auth.AmplifyConfig>, "4j9bfs8e7415erf82l129v0qhe"
} ),
domain: utils.brand<auth.OAuthDomain>(
"production-enso-domain.auth.eu-west-1.amazoncognito.com"
),
...BASE_AMPLIFY_CONFIG,
} satisfies Partial<auth.AmplifyConfig>,
};
// ================== // ==================
// === AuthConfig === // === AuthConfig ===
@ -80,15 +88,15 @@ const AMPLIFY_CONFIGS = {
/** Configuration for the authentication service. */ /** Configuration for the authentication service. */
export interface AuthConfig { export interface AuthConfig {
/** Logger for the authentication service. */ /** Logger for the authentication service. */
logger: loggerProvider.Logger logger: loggerProvider.Logger;
/** Whether the application is running on a desktop (i.e., versus in the Cloud). */ /** Whether the application is running on a desktop (i.e., versus in the Cloud). */
platform: platformModule.Platform platform: platformModule.Platform;
/** Function to navigate to a given (relative) URL. /** 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 * Used to redirect to pages like the password reset page with the query parameters set in the
* URL (e.g., `?verification_code=...`). */ * URL (e.g., `?verification_code=...`). */
navigate: (url: string) => void navigate: (url: string) => void;
} }
// =================== // ===================
@ -97,10 +105,10 @@ export interface AuthConfig {
/** API for the authentication service. */ /** API for the authentication service. */
export interface AuthService { export interface AuthService {
/** @see {@link cognito.Cognito}. */ /** @see {@link cognito.Cognito}. */
cognito: cognito.Cognito cognito: cognito.Cognito;
/** @see {@link listen.ListenFunction} */ /** @see {@link listen.ListenFunction} */
registerAuthEventListener: listen.ListenFunction registerAuthEventListener: listen.ListenFunction;
} }
/** Creates an instance of the authentication service. /** Creates an instance of the authentication service.
@ -110,49 +118,49 @@ export interface AuthService {
* This function should only be called once, and the returned service should be used throughout the * 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. */ * application. This is because it performs global configuration of the Amplify library. */
export function initAuthService(authConfig: AuthConfig): AuthService { export function initAuthService(authConfig: AuthConfig): AuthService {
const { logger, platform, navigate } = authConfig const { logger, platform, navigate } = authConfig;
const amplifyConfig = loadAmplifyConfig(logger, platform, navigate) const amplifyConfig = loadAmplifyConfig(logger, platform, navigate);
const cognitoClient = new cognito.Cognito(logger, platform, amplifyConfig) const cognitoClient = new cognito.Cognito(logger, platform, amplifyConfig);
return { return {
cognito: cognitoClient, cognito: cognitoClient,
registerAuthEventListener: listen.registerAuthEventListener, registerAuthEventListener: listen.registerAuthEventListener,
} };
} }
function loadAmplifyConfig( function loadAmplifyConfig(
logger: loggerProvider.Logger, logger: loggerProvider.Logger,
platform: platformModule.Platform, platform: platformModule.Platform,
navigate: (url: string) => void navigate: (url: string) => void
): auth.AmplifyConfig { ): auth.AmplifyConfig {
/** Load the environment-specific Amplify configuration. */ /** Load the environment-specific Amplify configuration. */
const baseConfig = AMPLIFY_CONFIGS[config.ENVIRONMENT] const baseConfig = AMPLIFY_CONFIGS[config.ENVIRONMENT];
let urlOpener = null let urlOpener = null;
if (platform === platformModule.Platform.desktop) { if (platform === platformModule.Platform.desktop) {
/** If we're running on the desktop, we want to override the default URL opener for OAuth /** 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, * 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 * but we want the user to be sent to their system browser instead. The user should be sent
* to their system browser because: * to their system browser because:
* *
* - users trust their system browser with their credentials more than they trust our app; * - 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., * - 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. */ * we avoid unnecessary reloads/refreshes caused by redirects. */
urlOpener = openUrlWithExternalBrowser urlOpener = openUrlWithExternalBrowser;
/** To handle redirects back to the application from the system browser, we also need to /** To handle redirects back to the application from the system browser, we also need to
* register a custom URL handler. */ * register a custom URL handler. */
setDeepLinkHandler(logger, navigate) setDeepLinkHandler(logger, navigate);
} }
/** Load the platform-specific Amplify configuration. */ /** Load the platform-specific Amplify configuration. */
const platformConfig = PLATFORM_TO_CONFIG[platform] const platformConfig = PLATFORM_TO_CONFIG[platform];
return { return {
...baseConfig, ...baseConfig,
...platformConfig, ...platformConfig,
urlOpener, urlOpener,
} };
} }
function openUrlWithExternalBrowser(url: string) { 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. /** Set the callback that will be invoked when a deep link to the application is opened.
@ -172,81 +180,84 @@ function openUrlWithExternalBrowser(url: string) {
* *
* All URLs that don't have a pathname that starts with {@link AUTHENTICATION_PATHNAME_BASE} will be * All URLs that don't have a pathname that starts with {@link AUTHENTICATION_PATHNAME_BASE} will be
* ignored by this handler. */ * ignored by this handler. */
function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: string) => void) { function setDeepLinkHandler(
const onDeepLink = (url: string) => { logger: loggerProvider.Logger,
const parsedUrl = new URL(url) navigate: (url: string) => void
) {
const onDeepLink = (url: string) => {
const parsedUrl = new URL(url);
switch (parsedUrl.pathname) { switch (parsedUrl.pathname) {
/** If the user is being redirected after clicking the registration confirmation link in their /** 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. */ * email, then the URL will be for the confirmation page path. */
case CONFIRM_REGISTRATION_PATHNAME: { case CONFIRM_REGISTRATION_PATHNAME: {
const redirectUrl = `${app.CONFIRM_REGISTRATION_PATH}${parsedUrl.search}` const redirectUrl = `${app.CONFIRM_REGISTRATION_PATH}${parsedUrl.search}`;
navigate(redirectUrl) navigate(redirectUrl);
break break;
} }
/** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/339 /** 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 * 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`. */ * have to disambiguate between the two on the `DASHBOARD_PATH`. */
case SIGN_OUT_PATHNAME: case SIGN_OUT_PATHNAME:
case SIGN_IN_PATHNAME: case SIGN_IN_PATHNAME:
/** If the user is being redirected after a sign-out, then no query args will be present. */ /** If the user is being redirected after a sign-out, then no query args will be present. */
if (parsedUrl.search === '') { if (parsedUrl.search === "") {
navigate(app.LOGIN_PATH) navigate(app.LOGIN_PATH);
} else { } else {
handleAuthResponse(url) 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 /** 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. */ * URL to the Amplify library, which will parse the URL and complete the OAuth flow. */
function handleAuthResponse(url: string) { function handleAuthResponse(url: string) {
void (async () => { void (async () => {
/** Temporarily override the `window.location` object so that Amplify doesn't try to call /** 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 * `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 * 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. * with a custom URL protocol in Electron.
* *
* # Safety * # Safety
* *
* It is safe to disable the `unbound-method` lint here because we intentionally want to use * 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 * the original `window.history.replaceState` function, which is not bound to the
* `window.history` object. */ * `window.history` object. */
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
const replaceState = window.history.replaceState const replaceState = window.history.replaceState;
window.history.replaceState = () => false window.history.replaceState = () => false;
try { try {
/** # Safety /** # Safety
* *
* It is safe to disable the `no-unsafe-call` lint here because we know that the `Auth` object * 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` * 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 * 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. */ * are intentionally not part of the public AWS Amplify API. */
// @ts-expect-error `_handleAuthResponse` is a private method without typings. // @ts-expect-error `_handleAuthResponse` is a private method without typings.
// eslint-disable-next-line @typescript-eslint/no-unsafe-call // eslint-disable-next-line @typescript-eslint/no-unsafe-call
await amplify.Auth._handleAuthResponse(url) await amplify.Auth._handleAuthResponse(url);
} finally { } finally {
/** Restore the original `window.location.replaceState` function. */ /** Restore the original `window.location.replaceState` function. */
window.history.replaceState = replaceState window.history.replaceState = replaceState;
} }
})() })();
} }

View File

@ -34,41 +34,41 @@
* {@link router.Route}s require fully authenticated users (c.f. * {@link router.Route}s require fully authenticated users (c.f.
* {@link authProvider.FullUserSession}). */ * {@link authProvider.FullUserSession}). */
import * as react from 'react' import * as react from "react";
import * as router from 'react-router-dom' import * as router from "react-router-dom";
import * as toast from 'react-hot-toast' import * as toast from "react-hot-toast";
import * as authProvider from '../authentication/providers/auth' import * as authProvider from "../authentication/providers/auth";
import * as authService from '../authentication/service' import * as authService from "../authentication/service";
import * as loggerProvider from '../providers/logger' import * as loggerProvider from "../providers/logger";
import * as platformModule from '../platform' import * as platformModule from "../platform";
import * as session from '../authentication/providers/session' import * as session from "../authentication/providers/session";
import ConfirmRegistration from '../authentication/components/confirmRegistration' import ConfirmRegistration from "../authentication/components/confirmRegistration";
import Dashboard from '../dashboard/components/dashboard' import Dashboard from "../dashboard/components/dashboard";
import ForgotPassword from '../authentication/components/forgotPassword' import ForgotPassword from "../authentication/components/forgotPassword";
import Login from '../authentication/components/login' import Login from "../authentication/components/login";
import Registration from '../authentication/components/registration' import Registration from "../authentication/components/registration";
import ResetPassword from '../authentication/components/resetPassword' import ResetPassword from "../authentication/components/resetPassword";
import SetUsername from '../authentication/components/setUsername' import SetUsername from "../authentication/components/setUsername";
// ================= // =================
// === Constants === // === Constants ===
// ================= // =================
/** Path to the root of the app (i.e., the Cloud dashboard). */ /** 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. */ /** Path to the login page. */
export const LOGIN_PATH = '/login' export const LOGIN_PATH = "/login";
/** Path to the registration page. */ /** Path to the registration page. */
export const REGISTRATION_PATH = '/registration' export const REGISTRATION_PATH = "/registration";
/** Path to the confirm registration page. */ /** Path to the confirm registration page. */
export const CONFIRM_REGISTRATION_PATH = '/confirmation' export const CONFIRM_REGISTRATION_PATH = "/confirmation";
/** Path to the forgot password page. */ /** 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. */ /** 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. */ /** Path to the set username page. */
export const SET_USERNAME_PATH = '/set-username' export const SET_USERNAME_PATH = "/set-username";
// =========== // ===========
// === App === // === App ===
@ -76,10 +76,10 @@ export const SET_USERNAME_PATH = '/set-username'
/** Global configuration for the `App` component. */ /** Global configuration for the `App` component. */
export interface AppProps { export interface AppProps {
/** Logger to use for logging. */ /** Logger to use for logging. */
logger: loggerProvider.Logger logger: loggerProvider.Logger;
platform: platformModule.Platform platform: platformModule.Platform;
onAuthenticated: () => void onAuthenticated: () => void;
} }
/** Component called by the parent module, returning the root React component for this /** Component called by the parent module, returning the root React component for this
@ -88,21 +88,23 @@ export interface AppProps {
* This component handles all the initialization and rendering of the app, and manages the app's * 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. */ * routes. It also initializes an `AuthProvider` that will be used by the rest of the app. */
function App(props: AppProps) { function App(props: AppProps) {
const { platform } = props const { platform } = props;
// This is a React component even though it does not contain JSX. // This is a React component even though it does not contain JSX.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const Router = const Router =
platform === platformModule.Platform.desktop ? router.MemoryRouter : router.BrowserRouter platform === platformModule.Platform.desktop
/** Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` ? router.MemoryRouter
* will redirect the user between the login/register pages and the dashboard. */ : router.BrowserRouter;
return ( /** 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. */
<toast.Toaster position="top-center" reverseOrder={false} /> return (
<Router> <>
<AppRouter {...props} /> <toast.Toaster position="top-center" reverseOrder={false} />
</Router> <Router>
</> <AppRouter {...props} />
) </Router>
</>
);
} }
// ================= // =================
@ -115,54 +117,66 @@ function App(props: AppProps) {
* because the {@link AppRouter} relies on React hooks, which can't be used in the same React * 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. */ * component as the component that defines the provider. */
function AppRouter(props: AppProps) { function AppRouter(props: AppProps) {
const { logger, onAuthenticated } = props const { logger, onAuthenticated } = props;
const navigate = router.useNavigate() const navigate = router.useNavigate();
const mainPageUrl = new URL(window.location.href) const mainPageUrl = new URL(window.location.href);
const memoizedAuthService = react.useMemo(() => { const memoizedAuthService = react.useMemo(() => {
const authConfig = { navigate, ...props } const authConfig = { navigate, ...props };
return authService.initAuthService(authConfig) return authService.initAuthService(authConfig);
}, [navigate, props]) }, [navigate, props]);
const userSession = memoizedAuthService.cognito.userSession.bind(memoizedAuthService.cognito) const userSession = memoizedAuthService.cognito.userSession.bind(
const registerAuthEventListener = memoizedAuthService.registerAuthEventListener memoizedAuthService.cognito
return ( );
<loggerProvider.LoggerProvider logger={logger}> const registerAuthEventListener =
<session.SessionProvider memoizedAuthService.registerAuthEventListener;
mainPageUrl={mainPageUrl} return (
userSession={userSession} <loggerProvider.LoggerProvider logger={logger}>
registerAuthEventListener={registerAuthEventListener} <session.SessionProvider
> mainPageUrl={mainPageUrl}
<authProvider.AuthProvider userSession={userSession}
authService={memoizedAuthService} registerAuthEventListener={registerAuthEventListener}
onAuthenticated={onAuthenticated} >
> <authProvider.AuthProvider
<router.Routes> authService={memoizedAuthService}
<react.Fragment> onAuthenticated={onAuthenticated}
{/* Login & registration pages are visible to unauthenticated users. */} >
<router.Route element={<authProvider.GuestLayout />}> <router.Routes>
<router.Route path={REGISTRATION_PATH} element={<Registration />} /> <react.Fragment>
<router.Route path={LOGIN_PATH} element={<Login />} /> {/* Login & registration pages are visible to unauthenticated users. */}
</router.Route> <router.Route element={<authProvider.GuestLayout />}>
{/* Protected pages are visible to authenticated users. */} <router.Route
<router.Route element={<authProvider.ProtectedLayout />}> path={REGISTRATION_PATH}
<router.Route path={DASHBOARD_PATH} element={<Dashboard />} /> element={<Registration />}
<router.Route path={SET_USERNAME_PATH} element={<SetUsername />} /> />
</router.Route> <router.Route path={LOGIN_PATH} element={<Login />} />
{/* Other pages are visible to unauthenticated and authenticated users. */} </router.Route>
<router.Route {/* Protected pages are visible to authenticated users. */}
path={CONFIRM_REGISTRATION_PATH} <router.Route element={<authProvider.ProtectedLayout />}>
element={<ConfirmRegistration />} <router.Route path={DASHBOARD_PATH} element={<Dashboard />} />
/> <router.Route
<router.Route path={SET_USERNAME_PATH}
path={FORGOT_PASSWORD_PATH} element={<SetUsername />}
element={<ForgotPassword />} />
/> </router.Route>
<router.Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} /> {/* Other pages are visible to unauthenticated and authenticated users. */}
</react.Fragment> <router.Route
</router.Routes> path={CONFIRM_REGISTRATION_PATH}
</authProvider.AuthProvider> element={<ConfirmRegistration />}
</session.SessionProvider> />
</loggerProvider.LoggerProvider> <router.Route
) path={FORGOT_PASSWORD_PATH}
element={<ForgotPassword />}
/>
<router.Route
path={RESET_PASSWORD_PATH}
element={<ResetPassword />}
/>
</react.Fragment>
</router.Routes>
</authProvider.AuthProvider>
</session.SessionProvider>
</loggerProvider.LoggerProvider>
);
} }
export default App export default App;

View File

@ -10,22 +10,23 @@
/** Path data for the SVG icons used in app. */ /** Path data for the SVG icons used in app. */
export const PATHS = { export const PATHS = {
/** Path data for the `@` icon SVG. */ /** Path data for the `@` icon SVG. */
at: 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 ' + "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', "8.959 0 01-4.5 1.207",
/** Path data for the lock icon SVG. */ /** Path data for the lock icon SVG. */
lock: 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 ' + "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', "0 00-8 0v4h8z",
/** Path data for the "right arrow" icon SVG. */ /** 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', 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. */ /** Path data for the "create account" icon SVG. */
createAccount: 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', "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. */ /** 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', goBack:
} as const "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 === // === Svg ===
@ -33,7 +34,7 @@ export const PATHS = {
/** Props for the `Svg` component. */ /** Props for the `Svg` component. */
interface Props { interface Props {
data: string data: string;
} }
/** Component for rendering SVG icons. /** Component for rendering SVG icons.
@ -41,17 +42,17 @@ interface Props {
* @param props - Extra props for the SVG path. The `props.data` field in particular contains the * @param props - Extra props for the SVG path. The `props.data` field in particular contains the
* SVG path data. */ * SVG path data. */
export function Svg(props: Props) { export function Svg(props: Props) {
return ( return (
<svg <svg
className="h-6 w-6" className="h-6 w-6"
fill="none" fill="none"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path d={props.data} /> <path d={props.data} />
</svg> </svg>
) );
} }

View File

@ -1,22 +1,22 @@
/** @file Main dashboard component, responsible for listing user's projects as well as other /** @file Main dashboard component, responsible for listing user's projects as well as other
* interactive components. */ * interactive components. */
import * as auth from '../../authentication/providers/auth' import * as auth from "../../authentication/providers/auth";
// ================= // =================
// === Dashboard === // === Dashboard ===
// ================= // =================
function Dashboard() { function Dashboard() {
const { signOut } = auth.useAuth() const { signOut } = auth.useAuth();
const { accessToken } = auth.useFullUserSession() const { accessToken } = auth.useFullUserSession();
return ( return (
<> <>
<h1>This is a placeholder page for the cloud dashboard.</h1> <h1>This is a placeholder page for the cloud dashboard.</h1>
<p>Access token: {accessToken}</p> <p>Access token: {accessToken}</p>
<button onClick={signOut}>Log out</button> <button onClick={signOut}>Log out</button>
</> </>
) );
} }
export default Dashboard export default Dashboard;

View File

@ -2,18 +2,18 @@
* *
* Each exported function in the {@link Backend} in this module corresponds to an API endpoint. The * 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. */ * functions are asynchronous and return a `Promise` that resolves to the response from the API. */
import * as config from '../config' import * as config from "../config";
import * as http from '../http' import * as http from "../http";
import * as loggerProvider from '../providers/logger' import * as loggerProvider from "../providers/logger";
// ================= // =================
// === Constants === // === Constants ===
// ================= // =================
/** Relative HTTP path to the "set username" endpoint of the Cloud backend API. */ /** 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. */ /** 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 === // === 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. */ /** A user/organization in the application. These are the primary owners of a project. */
export interface Organization { export interface Organization {
id: string id: string;
userEmail: string userEmail: string;
name: string name: string;
} }
/** HTTP request body for the "set username" endpoint. */ /** HTTP request body for the "set username" endpoint. */
export interface SetUsernameRequestBody { export interface SetUsernameRequestBody {
userName: string userName: string;
userEmail: string userEmail: string;
} }
// =============== // ===============
@ -38,58 +38,63 @@ export interface SetUsernameRequestBody {
/** Class for sending requests to the Cloud backend API endpoints. */ /** Class for sending requests to the Cloud backend API endpoints. */
export class Backend { export class Backend {
/** Creates a new instance of the {@link Backend} API client. /** Creates a new instance of the {@link Backend} API client.
* *
* @throws An error if the `Authorization` header is not set on the given `client`. */ * @throws An error if the `Authorization` header is not set on the given `client`. */
constructor( constructor(
private readonly client: http.Client, private readonly client: http.Client,
private readonly logger: loggerProvider.Logger private readonly logger: loggerProvider.Logger
) { ) {
/** All of our API endpoints are authenticated, so we expect the `Authorization` header to be /** All of our API endpoints are authenticated, so we expect the `Authorization` header to be
* set. */ * set. */
if (!this.client.defaultHeaders?.has('Authorization')) { if (!this.client.defaultHeaders?.has("Authorization")) {
throw new Error('Authorization header not set.') throw new Error("Authorization header not set.");
}
} }
}
/** Returns a {@link RequestBuilder} for an HTTP GET request to the given path. */ /** Returns a {@link RequestBuilder} for an HTTP GET request to the given path. */
get<T = void>(path: string) { get<T = void>(path: string) {
return this.client.get<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`) return this.client.get<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`);
} }
/** Returns a {@link RequestBuilder} for an HTTP POST request to the given path. */ /** Returns a {@link RequestBuilder} for an HTTP POST request to the given path. */
post<T = void>(path: string, payload: object) { post<T = void>(path: string, payload: object) {
return this.client.post<T>(`${config.ACTIVE_CONFIG.apiUrl}/${path}`, payload) return this.client.post<T>(
} `${config.ACTIVE_CONFIG.apiUrl}/${path}`,
payload
);
}
/** Logs the error that occurred and throws a new one with a more user-friendly message. */ /** Logs the error that occurred and throws a new one with a more user-friendly message. */
errorHandler(message: string) { errorHandler(message: string) {
return (error: Error) => { return (error: Error) => {
this.logger.error(error.message) this.logger.error(error.message);
throw new Error(message) throw new Error(message);
} };
} }
/** Sets the username of the current user, on the Cloud backend API. */ /** Sets the username of the current user, on the Cloud backend API. */
setUsername(body: SetUsernameRequestBody): Promise<Organization> { setUsername(body: SetUsernameRequestBody): Promise<Organization> {
return this.post<Organization>(SET_USER_NAME_PATH, body).then(response => response.json()) return this.post<Organization>(SET_USER_NAME_PATH, body).then((response) =>
} response.json()
);
}
/** Returns organization info for the current user, from the Cloud backend API. /** Returns organization info for the current user, from the Cloud backend API.
* *
* @returns `null` if status code 401 or 404 was received. */ * @returns `null` if status code 401 or 404 was received. */
getUser(): Promise<Organization | null> { getUser(): Promise<Organization | null> {
return this.get<Organization>(GET_USER_PATH).then(response => { return this.get<Organization>(GET_USER_PATH).then((response) => {
if ( if (
response.status === http.HttpStatus.unauthorized || response.status === http.HttpStatus.unauthorized ||
response.status === http.HttpStatus.notFound response.status === http.HttpStatus.notFound
) { ) {
return null return null;
} else { } else {
return response.json() return response.json();
} }
}) });
} }
} }
// ===================== // =====================
@ -102,9 +107,12 @@ export class Backend {
* This is a hack to quickly create the backend in the format we want, until we get the provider * 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 * working. This should be removed entirely in favour of creating the backend once and using it from
* the context. */ * the context. */
export function createBackend(accessToken: string, logger: loggerProvider.Logger): Backend { export function createBackend(
const headers = new Headers() accessToken: string,
headers.append('Authorization', `Bearer ${accessToken}`) logger: loggerProvider.Logger
const client = new http.Client(headers) ): Backend {
return new Backend(client, logger) const headers = new Headers();
headers.append("Authorization", `Bearer ${accessToken}`);
const client = new http.Client(headers);
return new Backend(client, logger);
} }

View File

@ -1,7 +1,7 @@
/** @file Module containing common custom React hooks used throughout out Dashboard. */ /** @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 === // === Bind ===
@ -21,8 +21,8 @@ import * as loggerProvider from './providers/logger'
* <input {...bind} /> * <input {...bind} />
* ``` */ * ``` */
interface Bind { interface Bind {
value: string value: string;
onChange: (value: react.ChangeEvent<HTMLInputElement>) => void onChange: (value: react.ChangeEvent<HTMLInputElement>) => void;
} }
// ================ // ================
@ -37,12 +37,12 @@ interface Bind {
* use the `value` prop and the `onChange` event handler. However, this can be tedious to do for * 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. */ * every input field, so we can use a custom hook to handle this for us. */
export function useInput(initialValue: string): [string, Bind] { export function useInput(initialValue: string): [string, Bind] {
const [value, setValue] = react.useState(initialValue) const [value, setValue] = react.useState(initialValue);
const onChange = (event: react.ChangeEvent<HTMLInputElement>) => { const onChange = (event: react.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value) setValue(event.target.value);
} };
const bind = { value, onChange } const bind = { value, onChange };
return [value, bind] 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. * @param deps - The list of dependencies that, when updated, trigger the asynchronous fetch.
* @returns The current value of the state controlled by this hook. */ * @returns The current value of the state controlled by this hook. */
export function useAsyncEffect<T>( export function useAsyncEffect<T>(
initialValue: T, initialValue: T,
fetch: (signal: AbortSignal) => Promise<T>, fetch: (signal: AbortSignal) => Promise<T>,
deps?: react.DependencyList deps?: react.DependencyList
): T { ): T {
const logger = loggerProvider.useLogger() const logger = loggerProvider.useLogger();
const [value, setValue] = react.useState<T>(initialValue) const [value, setValue] = react.useState<T>(initialValue);
react.useEffect(() => { react.useEffect(() => {
const controller = new AbortController() const controller = new AbortController();
const { signal } = controller const { signal } = controller;
/** Declare the async data fetching function. */ /** Declare the async data fetching function. */
const load = async () => { const load = async () => {
const result = await fetch(signal) const result = await fetch(signal);
/** Set state with the result only if this effect has not been aborted. This prevents race /** 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 * conditions by making it so that only the latest async fetch will update the state on
* completion. */ * completion. */
if (!signal.aborted) { if (!signal.aborted) {
setValue(result) setValue(result);
} }
} };
load().catch(error => { load().catch((error) => {
logger.error('Error while fetching data', error) logger.error("Error while fetching data", error);
}) });
/** Cancel any future `setValue` calls. */ /** Cancel any future `setValue` calls. */
return () => { return () => {
controller.abort() controller.abort();
} };
}, deps) }, deps);
return value return value;
} }

View File

@ -8,10 +8,10 @@
/** HTTP status codes returned in a HTTP response. */ /** HTTP status codes returned in a HTTP response. */
export enum HttpStatus { export enum HttpStatus {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers // eslint-disable-next-line @typescript-eslint/no-magic-numbers
unauthorized = 401, unauthorized = 401,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers // eslint-disable-next-line @typescript-eslint/no-magic-numbers
notFound = 404, notFound = 404,
} }
// ================== // ==================
@ -20,10 +20,10 @@ export enum HttpStatus {
/** HTTP method variants that can be used in an HTTP request. */ /** HTTP method variants that can be used in an HTTP request. */
enum HttpMethod { enum HttpMethod {
get = 'GET', get = "GET",
post = 'POST', post = "POST",
put = 'PUT', put = "PUT",
delete = 'DELETE', delete = "DELETE",
} }
// ============== // ==============
@ -32,82 +32,95 @@ enum HttpMethod {
/** A helper function to convert a `Blob` to a base64-encoded string. */ /** A helper function to convert a `Blob` to a base64-encoded string. */
function blobToBase64(blob: Blob) { function blobToBase64(blob: Blob) {
return new Promise<string>(resolve => { return new Promise<string>((resolve) => {
const reader = new FileReader() const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
resolve( resolve(
// This cast is always safe because we read as data URL (a string). // This cast is always safe because we read as data URL (a string).
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
(reader.result as string).replace(/^data:application\/octet-stream;base64,/, '') (reader.result as string).replace(
) /^data:application\/octet-stream;base64,/,
} ""
reader.readAsDataURL(blob) )
}) );
};
reader.readAsDataURL(blob);
});
} }
/** An HTTP client that can be used to create and send HTTP requests asynchronously. */ /** An HTTP client that can be used to create and send HTTP requests asynchronously. */
export class Client { export class Client {
constructor( constructor(
/** A map of default headers that are included in every HTTP request sent by this client. /** 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 * This is useful for setting headers that are required for every request, like authentication
* tokens. */ * tokens. */
public defaultHeaders?: Headers public defaultHeaders?: Headers
) {} ) {}
/** Sends an HTTP GET request to the specified URL. */ /** Sends an HTTP GET request to the specified URL. */
get<T = void>(url: string) { get<T = void>(url: string) {
return this.request<T>(HttpMethod.get, url) return this.request<T>(HttpMethod.get, url);
} }
/** Sends a JSON HTTP POST request to the specified URL. */ /** Sends a JSON HTTP POST request to the specified URL. */
post<T = void>(url: string, payload: object) { post<T = void>(url: string, payload: object) {
return this.request<T>(HttpMethod.post, url, JSON.stringify(payload), 'application/json') return this.request<T>(
} HttpMethod.post,
url,
JSON.stringify(payload),
"application/json"
);
}
/** Sends a base64-encoded binary HTTP POST request to the specified URL. */ /** Sends a base64-encoded binary HTTP POST request to the specified URL. */
async postBase64<T = void>(url: string, payload: Blob) { async postBase64<T = void>(url: string, payload: Blob) {
return await this.request<T>( return await this.request<T>(
HttpMethod.post, HttpMethod.post,
url, url,
await blobToBase64(payload), await blobToBase64(payload),
'application/octet-stream' "application/octet-stream"
) );
} }
/** Sends a JSON HTTP PUT request to the specified URL. */ /** Sends a JSON HTTP PUT request to the specified URL. */
put<T = void>(url: string, payload: object) { put<T = void>(url: string, payload: object) {
return this.request<T>(HttpMethod.put, url, JSON.stringify(payload), 'application/json') return this.request<T>(
} HttpMethod.put,
url,
JSON.stringify(payload),
"application/json"
);
}
/** Sends an HTTP DELETE request to the specified URL. */ /** Sends an HTTP DELETE request to the specified URL. */
delete<T = void>(url: string) { delete<T = void>(url: string) {
return this.request<T>(HttpMethod.delete, url) return this.request<T>(HttpMethod.delete, url);
} }
/** Executes an HTTP request to the specified URL, with the given HTTP method. */ /** Executes an HTTP request to the specified URL, with the given HTTP method. */
private request<T = void>( private request<T = void>(
method: HttpMethod, method: HttpMethod,
url: string, url: string,
payload?: string, payload?: string,
mimetype?: string mimetype?: string
) { ) {
const defaultHeaders = this.defaultHeaders ?? [] const defaultHeaders = this.defaultHeaders ?? [];
const headers = new Headers(defaultHeaders) const headers = new Headers(defaultHeaders);
if (payload) { if (payload) {
const contentType = mimetype ?? 'application/json' const contentType = mimetype ?? "application/json";
headers.set('Content-Type', contentType) headers.set("Content-Type", contentType);
}
interface ResponseWithTypedJson<U> extends Response {
json: () => Promise<U>
}
// 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<ResponseWithTypedJson<T>>
} }
interface ResponseWithTypedJson<U> extends Response {
json: () => Promise<U>;
}
// 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<ResponseWithTypedJson<T>>;
}
} }

View File

@ -10,21 +10,19 @@
// as per the above comment. // as per the above comment.
// @ts-expect-error See above comment for why this import is needed. // @ts-expect-error See above comment for why this import is needed.
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-restricted-syntax // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-restricted-syntax
import * as React from 'react' import * as React from "react";
import * as reactDOM from 'react-dom/client' import * as reactDOM from "react-dom/client";
import * as loggerProvider from './providers/logger' import * as loggerProvider from "./providers/logger";
import * as platformModule from './platform' import * as platformModule from "./platform";
import App, * as app from './components/app' import App, * as app from "./components/app";
// ================= // =================
// === Constants === // === Constants ===
// ================= // =================
/** The `id` attribute of the root element that the app will be rendered into. */ /** The `id` attribute of the root element that the app will be rendered into. */
const ROOT_ELEMENT_ID = 'dashboard' const ROOT_ELEMENT_ID = "dashboard";
/** The `id` attribute of the element that the IDE will be rendered into. */
const IDE_ELEMENT_ID = 'root'
// =========== // ===========
// === run === // === run ===
@ -38,29 +36,23 @@ const IDE_ELEMENT_ID = 'root'
// This is not a React component even though it contains JSX. // This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export function run( export function run(
/** Logger to use for logging. */ /** Logger to use for logging. */
logger: loggerProvider.Logger, logger: loggerProvider.Logger,
platform: platformModule.Platform, platform: platformModule.Platform,
onAuthenticated: () => void onAuthenticated: () => void
) { ) {
logger.log('Starting authentication/dashboard UI.') logger.log("Starting authentication/dashboard UI.");
/** The root element that the authentication/dashboard app will be rendered into. */ /** The root element that the authentication/dashboard app will be rendered into. */
const root = document.getElementById(ROOT_ELEMENT_ID) const root = document.getElementById(ROOT_ELEMENT_ID);
if (root == null) { if (root == null) {
logger.error(`Could not find root element with ID '${ROOT_ELEMENT_ID}'.`) logger.error(`Could not find root element with ID '${ROOT_ELEMENT_ID}'.`);
} else { } else {
// FIXME[sb]: This is a temporary workaround and will be fixed const props = { logger, platform, onAuthenticated };
// when IDE support is properly integrated into the dashboard. reactDOM.createRoot(root).render(<App {...props} />);
const ide = document.getElementById(IDE_ELEMENT_ID) }
if (ide != null) {
ide.style.display = 'none'
}
const props = { logger, platform, onAuthenticated }
reactDOM.createRoot(root).render(<App {...props} />)
}
} }
export type AppProps = app.AppProps export type AppProps = app.AppProps;
// This export should be `PascalCase` because it is a re-export. // This export should be `PascalCase` because it is a re-export.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export const Platform = platformModule.Platform export const Platform = platformModule.Platform;

View File

@ -1,6 +1,6 @@
/** @file Defines the React provider for the {@link Logger} interface, along with a hook to use the /** @file Defines the React provider for the {@link Logger} interface, along with a hook to use the
* provider via the shared React context. */ * provider via the shared React context. */
import * as react from 'react' import * as react from "react";
// ============== // ==============
// === Logger === // === 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 * In the browser, this is the `Console` interface. In Electron, this is the `Logger` interface
* provided by the EnsoGL packager. */ * provided by the EnsoGL packager. */
export interface Logger { export interface Logger {
/** Logs a message to the console. */ /** Logs a message to the console. */
log: (message: unknown, ...optionalParams: unknown[]) => void log: (message: unknown, ...optionalParams: unknown[]) => void;
/** Logs an error message to the console. */ /** Logs an error message to the console. */
error: (message: unknown, ...optionalParams: unknown[]) => void error: (message: unknown, ...optionalParams: unknown[]) => void;
} }
// ===================== // =====================
@ -23,20 +23,22 @@ export interface Logger {
/** See {@link AuthContext} for safety details. */ /** See {@link AuthContext} for safety details. */
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const LoggerContext = react.createContext<Logger>({} as Logger) const LoggerContext = react.createContext<Logger>({} as Logger);
// ====================== // ======================
// === LoggerProvider === // === LoggerProvider ===
// ====================== // ======================
interface LoggerProviderProps { interface LoggerProviderProps {
children: react.ReactNode children: react.ReactNode;
logger: Logger logger: Logger;
} }
export function LoggerProvider(props: LoggerProviderProps) { export function LoggerProvider(props: LoggerProviderProps) {
const { children, logger } = props const { children, logger } = props;
return <LoggerContext.Provider value={logger}>{children}</LoggerContext.Provider> return (
<LoggerContext.Provider value={logger}>{children}</LoggerContext.Provider>
);
} }
// ================= // =================
@ -44,5 +46,5 @@ export function LoggerProvider(props: LoggerProviderProps) {
// ================= // =================
export function useLogger() { export function useLogger() {
return react.useContext(LoggerContext) return react.useContext(LoggerContext);
} }

View File

@ -1,4 +1,4 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"include": ["../../../types", ".", "../../tailwind.config.ts"] "include": ["../../../types", "."]
} }

View File

@ -1,22 +1,15 @@
/** @file Index file declaring main DOM structure for the app. */ /** @file Index file declaring main DOM structure for the app. */
if (IS_DEV_MODE) { import * as authentication from "enso-authentication";
new EventSource('/esbuild').addEventListener('change', () => {
location.reload()
})
void navigator.serviceWorker.register('/serviceWorker.js')
}
import * as authentication from 'enso-authentication' import * as platform from "./authentication/src/platform";
import * as platform from 'enso-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 /** This package is a standalone React app (i.e., IDE deployed to the Cloud), so we're not
* running on the desktop. */ * 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. // 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 // eslint-disable-next-line @typescript-eslint/no-empty-function
function onAuthenticated() {} function onAuthenticated() {}
authentication.run(logger, PLATFORM, onAuthenticated) authentication.run(logger, PLATFORM, onAuthenticated);

View File

@ -1,22 +0,0 @@
/** @file A service worker that redirects paths without extensions to `/index.html`. */
/// <reference lib="WebWorker" />
// We `declare` a variable here because Service Workers have a different global scope.
// eslint-disable-next-line no-restricted-syntax
declare const self: ServiceWorkerGlobalScope
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
if (
url.hostname === 'localhost' &&
/\/[^.]+$/.test(event.request.url) &&
url.pathname !== '/esbuild'
) {
event.respondWith(fetch('/index.html'))
} else {
event.respondWith(fetch(event.request))
}
})
// Required for TypeScript to consider it a module, instead of in window scope.
export {}

View File

@ -1,21 +0,0 @@
/* These styles MUST still be copied as `#dashboard body` and `#dashboard html` make no sense. */
body {
margin: 0;
}
#dashboard {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-moz-tab-size: 4;
tab-size: 4;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", Segoe UI Symbol, "Noto Color Emoji";
font-feature-settings: normal;
}
#dashboard {
@tailwind base;
@tailwind components;
@tailwind utilities;
}

View File

@ -1,17 +0,0 @@
/** @file Configuration for Tailwind. */
import * as path from 'node:path'
import * as url from 'node:url'
// =================
// === Constants ===
// =================
const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
// =====================
// === Configuration ===
// =====================
// This is a third-party API that we do not control.
/* eslint-disable no-restricted-syntax */
export const content = [THIS_PATH + '/src/**/*.tsx']

View File

@ -1,51 +0,0 @@
/** @file File watch and compile service. */
import * as path from 'node:path'
import * as url from 'node:url'
import * as esbuild from 'esbuild'
import chalk from 'chalk'
import * as bundler from './esbuild-config'
export const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
// =================
// === Constants ===
// =================
/** This must be port `8081` because it is defined as such in AWS. */
const PORT = 8081
const HTTP_STATUS_OK = 200
// `assetsPath` and `outputPath` do not have to be real directories because `write` is `false`,
// meaning that files will not be written to the filesystem.
// However, they should still have non-empty paths in order for `esbuild.serve` to work properly.
const ARGS: bundler.Arguments = { outputPath: '/', devMode: true }
const OPTS = bundler.bundlerOptions(ARGS)
OPTS.entryPoints.push(
path.resolve(THIS_PATH, 'src', 'index.html'),
path.resolve(THIS_PATH, 'src', 'index.tsx'),
path.resolve(THIS_PATH, 'src', 'serviceWorker.ts')
)
OPTS.write = false
// ===============
// === Watcher ===
// ===============
async function watch() {
const builder = await esbuild.context(OPTS)
await builder.watch()
await builder.serve({
port: PORT,
servedir: OPTS.outdir,
onRequest(args) {
if (args.status !== HTTP_STATUS_OK) {
console.error(
chalk.red(`HTTP error ${args.status} when serving path '${args.path}'.`)
)
}
},
})
}
void watch()

View File

@ -0,0 +1,25 @@
{
"name": "enso-gui-server",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.mjs",
"author": {
"name": "Enso Team",
"email": "contact@enso.org"
},
"homepage": "https://github.com/enso-org/enso",
"repository": {
"type": "git",
"url": "git@github.com:enso-org/enso.git"
},
"bugs": {
"url": "https://github.com/enso-org/enso/issues"
},
"dependencies": {
"connect": "^3.7.0",
"morgan": "^1.10.0",
"portfinder": "^1.0.28",
"serve-static": "^1.15.0",
"ws": "^8.11.0"
}
}

View File

@ -0,0 +1,53 @@
/** @file Live-reload server implementation. */
import * as path from 'node:path'
import * as url from 'node:url'
import * as portfinder from 'portfinder'
import * as ws from 'ws'
import connect from 'connect'
import logger from 'morgan'
import serveStatic from 'serve-static'
// =================
// === Constants ===
// =================
export const DEFAULT_PORT = 8080
export const HTTP_STATUS_BAD_REQUEST = 400
const DIR_NAME = path.dirname(url.fileURLToPath(import.meta.url))
/** Path of a file that needs to be injected into the bundle for live-reload to work. */
export const LIVE_RELOAD_LISTENER_PATH = path.join(DIR_NAME, 'live-reload.js')
/** Start the server.
*
* @param {{ root: string; assets?: string | null; port?: number; }} options - Configuration options for this server.
*/
export async function start({ root, assets, port }) {
assets = assets ?? path.join(root, 'assets')
const freePort = await portfinder.getPortPromise({ port: port ?? DEFAULT_PORT })
// FIXME: There is an issue probably related with improper caches of served files. Read more
// here: https://github.com/expressjs/serve-static/issues/155
const app = connect()
.use(logger('dev', { skip: (_req, res) => res.statusCode < HTTP_STATUS_BAD_REQUEST }))
.use(serveStatic(root))
.use('/assets', serveStatic(assets))
const server = app.listen(freePort)
const wsServer = new ws.WebSocketServer({ server, clientTracking: true, path: '/live-reload' })
var serverUrl = `http://localhost:${freePort}`
console.log('Serving %s', serverUrl)
return {
port: freePort,
reload() {
wsServer.clients.forEach(sock => {
sock.send('reload')
})
},
}
}

View File

@ -0,0 +1,13 @@
/** @file This file is injected into every entry point, but it needs to run only once. A global variable is used to
* ensure that. */
if (!window.liveReloadListening) {
window.liveReloadListening = true
const protocol = window.location.protocol === 'http:' ? 'ws://' : 'wss://'
const address = protocol + window.location.host + '/live-reload'
const socket = new WebSocket(address)
socket.onmessage = msg => {
if (msg.data === 'reload') {
window.location.reload()
}
}
}

View File

@ -0,0 +1,17 @@
/** @file Type definitions for the `enso-gui-server` module. */
declare module 'enso-gui-server' {
export const DEFAULT_PORT: string
export const LIVE_RELOAD_LISTENER_PATH: string
interface StartParams {
// These are not values we explicitly supply
root: string
assets?: string | null
port?: number
}
interface ExectionInfo {
port: number
reload: () => void
}
export function start(params: StartParams): Promise<ExectionInfo>
}

View File

@ -42,6 +42,7 @@ interface AuthenticationApi {
declare global { declare global {
interface Window { interface Window {
liveReloadListening?: boolean
enso: Enso enso: Enso
authenticationApi: AuthenticationApi authenticationApi: AuthenticationApi
} }
@ -58,10 +59,9 @@ declare global {
// These are used in other files (because they're globals) // These are used in other files (because they're globals)
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
const BUNDLED_ENGINE_VERSION: string const BUNDLED_ENGINE_VERSION: string
const PROJECT_MANAGER_IN_BUNDLE_PATH: string
const BUILD_INFO: BuildInfo const BUILD_INFO: BuildInfo
// eslint-disable-next-line no-restricted-syntax
const PROJECT_MANAGER_IN_BUNDLE_PATH: string | undefined const PROJECT_MANAGER_IN_BUNDLE_PATH: string | undefined
const IS_DEV_MODE: boolean
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
} }

View File

@ -36,13 +36,6 @@ declare module 'esbuild-plugin-time' {
export default function (name?: string): esbuild.Plugin export default function (name?: string): esbuild.Plugin
} }
declare module 'tailwindcss/nesting/index.js' {
import * as nested from 'postcss-nested'
const DEFAULT: nested.Nested
export default DEFAULT
}
declare module 'create-servers' { declare module 'create-servers' {
import * as http from 'node:http' import * as http from 'node:http'

File diff suppressed because it is too large Load Diff

View File

@ -23,18 +23,20 @@
"lib/dashboard/src/authentication", "lib/dashboard/src/authentication",
"lib/content-config", "lib/content-config",
"lib/copy-plugin", "lib/copy-plugin",
"lib/icons" "lib/icons",
"lib/server"
], ],
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0", "@typescript-eslint/parser": "^5.55.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.36.0", "eslint": "^8.36.0",
"eslint-plugin-jsdoc": "^40.0.2" "eslint-plugin-jsdoc": "^40.0.2",
"patch-package": "^6.4.7"
}, },
"scripts": { "scripts": {
"dev": "PORT=8081 npm exec --workspace enso-dashboard -- react-scripts start",
"watch": "npm run watch --workspace enso-content", "watch": "npm run watch --workspace enso-content",
"watch-dashboard": "npm run watch --workspace enso-dashboard", "postinstall": "patch-package"
"build-dashboard": "npm run build --workspace enso-dashboard"
} }
} }

View File

@ -21,5 +21,9 @@
"skipLibCheck": true, "skipLibCheck": true,
"target": "ES2019", "target": "ES2019",
"jsx": "react-jsx" "jsx": "react-jsx"
},
"ts-node": {
"esm": true,
"files": true
} }
} }