mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 02:21:54 +03:00
This reverts commit a9dbebf3f3
.
This commit is contained in:
parent
99a6f8f2f9
commit
1a569223aa
@ -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
|
||||||
|
25
app/ide-desktop/esbuild-watch.ts
Normal file
25
app/ide-desktop/esbuild-watch.ts
Normal 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
|
||||||
|
}
|
@ -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'
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
@ -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 ===
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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'
|
||||||
|
@ -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()
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
|
@ -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 }
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
app/ide-desktop/lib/content/src/tailwind.css
Normal file
3
app/ide-desktop/lib/content/src/tailwind.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
@ -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()
|
|
||||||
|
13
app/ide-desktop/lib/content/tailwind.config.ts
Normal file
13
app/ide-desktop/lib/content/tailwind.config.ts
Normal 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: [],
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"include": ["../types", "."]
|
"include": ["../types", ".", "tailwind.config.ts"]
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
|
@ -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())
|
|
||||||
}
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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'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'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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>();
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
})()
|
})();
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"include": ["../../../types", ".", "../../tailwind.config.ts"]
|
"include": ["../../../types", "."]
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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 {}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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']
|
|
@ -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()
|
|
25
app/ide-desktop/lib/server/package.json
Normal file
25
app/ide-desktop/lib/server/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
53
app/ide-desktop/lib/server/src/index.mjs
Normal file
53
app/ide-desktop/lib/server/src/index.mjs
Normal 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')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
13
app/ide-desktop/lib/server/src/live-reload.js
Normal file
13
app/ide-desktop/lib/server/src/live-reload.js
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
app/ide-desktop/lib/types/enso-gui-server.d.ts
vendored
Normal file
17
app/ide-desktop/lib/types/enso-gui-server.d.ts
vendored
Normal 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>
|
||||||
|
}
|
4
app/ide-desktop/lib/types/globals.d.ts
vendored
4
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -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 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
7
app/ide-desktop/lib/types/modules.d.ts
vendored
7
app/ide-desktop/lib/types/modules.d.ts
vendored
@ -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'
|
||||||
|
|
||||||
|
30041
app/ide-desktop/package-lock.json
generated
30041
app/ide-desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,5 +21,9 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"target": "ES2019",
|
"target": "ES2019",
|
||||||
"jsx": "react-jsx"
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"esm": true,
|
||||||
|
"files": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user