Merge branch 'dev'

This commit is contained in:
Proddy
2023-08-13 14:32:41 +02:00
589 changed files with 42115 additions and 37308 deletions

View File

@@ -1,8 +0,0 @@
# This enables lint extensions
EXTEND_ESLINT=true
# This is the name of your project. It appears on the sign-in page and in the menu bar.
REACT_APP_PROJECT_NAME=EMS-ESP
# This is the url path your project will be exposed under.
REACT_APP_PROJECT_PATH=ems-esp

View File

@@ -0,0 +1,2 @@
VITE_ALOVA_TIPS=0
REACT_APP_ALOVA_TIPS=0

View File

@@ -1,3 +0,0 @@
GENERATE_SOURCEMAP=false
REACT_APP_HOSTED=true

View File

@@ -1 +0,0 @@
GENERATE_SOURCEMAP=false

12
interface/.eslintignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules/
build/
dist/
.yarn/
.prettierrc
.eslintrc*
env.d.ts
progmem-generator.js
unpack.ts
vite.config.ts
package.json

108
interface/.eslintrc.json Normal file
View File

@@ -0,0 +1,108 @@
{
"env": {
"browser": true,
"es6": true
},
"extends": [
"eslint:recommended",
"airbnb/hooks",
"airbnb-typescript",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:prettier/recommended",
"plugin:import/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module",
"tsconfigRootDir": ".",
"project": ["tsconfig.json"]
},
"plugins": ["react", "@typescript-eslint", "autofix", "react-hooks"],
"settings": {
"import/resolver": {
"typescript": {
"project": "./tsconfig.json"
}
},
"react": {
"version": "18.x"
}
},
"rules": {
"object-shorthand": "error",
"no-console": "warn",
"@typescript-eslint/consistent-type-definitions": ["off", "type"],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-enum-comparison": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-implied-eval": "off",
"@typescript-eslint/no-misused-promises": "off",
"arrow-body-style": ["error", "as-needed"],
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports"
}
],
"import/order": [
"warn",
{
"groups": ["builtin", "external", "parent", "sibling", "index", "object", "type"],
"pathGroups": [
{
"pattern": "@/**/**",
"group": "parent",
"position": "before"
}
],
"alphabetize": { "order": "asc" }
}
],
// "autofix/no-unused-vars": [
// "error",
// {
// "argsIgnorePattern": "^_",
// "ignoreRestSiblings": true,
// "destructuredArrayIgnorePattern": "^_"
// }
// ],
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"@typescript-eslint/ban-types": [
"error",
{
"extendDefaults": true,
"types": {
"{}": false
}
}
],
"prettier/prettier": [
"error",
{
"endOfLine": "auto"
}
]
}
}

7
interface/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@@ -0,0 +1,6 @@
node_modules/
build/
dist/
.prettierrc
.yarn/
.typesafe-i18n.json

View File

@@ -1,6 +0,0 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "none",
"printWidth": 120
}

View File

@@ -1,5 +1,5 @@
{
"adapter": "react",
"baseLocale": "pl",
"$schema": "https://unpkg.com/typesafe-i18n@5.24.2/schema/typesafe-i18n.json"
"$schema": "https://unpkg.com/typesafe-i18n@5.26.0/schema/typesafe-i18n.json"
}

File diff suppressed because one or more lines are too long

873
interface/.yarn/releases/yarn-3.4.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

20
interface/.yarn/sdks/eslint/bin/eslint.js vendored Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/bin/eslint.js
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = absRequire(`eslint/bin/eslint.js`);

20
interface/.yarn/sdks/eslint/lib/api.js vendored Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint your application uses
module.exports = absRequire(`eslint`);

View File

@@ -0,0 +1,6 @@
{
"name": "eslint",
"version": "8.36.0-sdk",
"main": "./lib/api.js",
"type": "commonjs"
}

5
interface/.yarn/sdks/integrations.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!
integrations:
- vscode

20
interface/.yarn/sdks/prettier/index.js vendored Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/index.js
require(absPnpApiPath).setup();
}
}
// Defer to the real prettier/index.js your application uses
module.exports = absRequire(`prettier/index.js`);

View File

@@ -0,0 +1,6 @@
{
"name": "prettier",
"version": "2.8.7-sdk",
"main": "./index.js",
"type": "commonjs"
}

20
interface/.yarn/sdks/typescript/bin/tsc vendored Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsc your application uses
module.exports = absRequire(`typescript/bin/tsc`);

20
interface/.yarn/sdks/typescript/bin/tsserver vendored Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsserver your application uses
module.exports = absRequire(`typescript/bin/tsserver`);

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
const moduleWrapper = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = createRequire(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/typescript.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/typescript.js your application uses
module.exports = absRequire(`typescript/lib/typescript.js`);

View File

@@ -0,0 +1,6 @@
{
"name": "typescript",
"version": "5.0.2-sdk",
"main": "./lib/typescript.js",
"type": "commonjs"
}

14
interface/.yarnrc.yml Normal file
View File

@@ -0,0 +1,14 @@
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
spec: '@yarnpkg/plugin-typescript'
yarnPath: .yarn/releases/yarn-3.4.1.cjs
# uing pnp
# nodeLinker: pnp
# use these if not using PnP and have node_modules
nodeLinker: node-modules
compressionLevel: 0
nmMode: hardlinks-local
enableGlobalCache: true

View File

@@ -1,30 +0,0 @@
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ProgmemGenerator = require('./progmem-generator.js');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = function override(config, env) {
const hosted = process.env.REACT_APP_HOSTED;
if (env === 'production' && !hosted) {
// rename the ouput file, we need it's path to be short, for embedded FS
config.output.filename = 'js/[id].[chunkhash:4].js';
config.output.chunkFilename = 'js/[id].[chunkhash:4].js';
// take out the manifest plugin
config.plugins = config.plugins.filter((plugin) => !(plugin instanceof WebpackManifestPlugin));
// shorten css filenames
const miniCssExtractPlugin = config.plugins.find((plugin) => plugin instanceof MiniCssExtractPlugin);
miniCssExtractPlugin.options.filename = 'css/[id].[contenthash:4].css';
miniCssExtractPlugin.options.chunkFilename = 'css/[id].[contenthash:4].c.css';
// don't emit license file
const terserPlugin = config.optimization.minimizer.find((plugin) => plugin instanceof TerserPlugin);
terserPlugin.options.extractComments = false;
// build progmem data files
config.plugins.push(new ProgmemGenerator({ outputPath: '../lib/framework/WWWData.h', bytesPerLine: 20 }));
}
return config;
};

14
interface/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="stylesheet" href="/css/roboto.css" />
<link rel="manifest" href="/app/manifest.json" />
<title>EMS-ESP</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

18354
interface/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,104 +1,79 @@
{
"name": "EMS-ESP",
"version": "3.5.0",
"version": "3.6.0",
"description": "build EMS-ESP WebUI",
"homepage": "https://emsesp.github.io/docs",
"author": "proddy",
"license": "MIT",
"private": true,
"proxy": "http://localhost:3080",
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@msgpack/msgpack": "^2.8.0",
"@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.7",
"@table-library/react-table-library": "4.0.24",
"@types/lodash": "^4.14.191",
"@types/node": "^18.11.19",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@types/react-router-dom": "^5.3.3",
"async-validator": "^4.2.5",
"axios": "^1.3.2",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"notistack": "^2.0.8",
"react": "^18.2.0",
"react-app-rewired": "^2.2.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-icons": "^4.7.1",
"react-router-dom": "^6.8.1",
"react-scripts": "5.0.1",
"sockette": "^2.0.6",
"typesafe-i18n": "^5.24.0",
"typescript": "^4.9.5"
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject",
"format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'",
"build-hosted": "env-cmd -f .env.hosted npm run build",
"build-localhost": "PUBLIC_URL=/ react-app-rewired build",
"dev": "vite",
"build": "vite build",
"build-hosted": "vite build --mode hosted",
"preview": "vite preview",
"preview-standalone": "npm-run-all -p preview typesafe-i18n mock-api",
"mock-api": "nodemon --watch ../mock-api ../mock-api/server.js",
"standalone": "npm-run-all -p start typesafe-i18n mock-api",
"lint": "eslint . --ext .ts,.tsx",
"typesafe-i18n": "typesafe-i18n"
"standalone": "npm-run-all -p dev typesafe-i18n mock-api",
"typesafe-i18n": "typesafe-i18n",
"format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --cache --fix"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
],
"rules": {
"eol-last": 1,
"react/jsx-closing-bracket-location": 1,
"react/jsx-closing-tag-location": 1,
"react/jsx-wrap-multilines": 1,
"react/jsx-curly-newline": 1,
"no-multiple-empty-lines": [
1,
{
"max": 1
}
],
"no-trailing-spaces": 1,
"semi": 1,
"no-extra-semi": 1,
"react/jsx-max-props-per-line": [
1,
{
"when": "multiline"
}
],
"react/jsx-first-prop-new-line": [
1,
"multiline"
],
"@typescript-eslint/no-shadow": 1,
"max-len": [
1,
{
"code": 220
}
],
"arrow-parens": 1
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"dependencies": {
"@alova/adapter-xhr": "^1.0.1",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.3",
"@mui/material": "^5.14.4",
"@preact/compat": "^17.1.2",
"@prefresh/vite": "^2.4.1",
"@table-library/react-table-library": "4.1.7",
"@types/lodash-es": "^4.17.8",
"@types/node": "^20.4.10",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-router-dom": "^5.3.3",
"alova": "^2.10.0",
"async-validator": "^4.2.5",
"history": "^5.3.0",
"jwt-decode": "^3.1.2",
"lodash-es": "^4.17.21",
"mime-types": "^2.1.35",
"preact": "^10.16.0",
"react": "latest",
"react-dom": "latest",
"react-dropzone": "^14.2.3",
"react-icons": "^4.10.1",
"react-router-dom": "^6.15.0",
"react-toastify": "^9.1.3",
"sockette": "^2.0.6",
"typesafe-i18n": "^5.26.0",
"typescript": "^5.1.6"
},
"devDependencies": {
"nodemon": "^2.0.20",
"@babel/core": "^7.22.10",
"@preact/preset-vite": "^2.5.0",
"@types/babel__core": "^7",
"@typescript-eslint/eslint-plugin": "^6.3.0",
"@typescript-eslint/parser": "^6.3.0",
"eslint": "^8.47.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.1.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.0",
"eslint-plugin-autofix": "^1.1.0",
"eslint-plugin-import": "^2.28.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "alpha",
"eslint-plugin-react": "^7.33.1",
"eslint-plugin-react-hooks": "^4.6.0",
"nodemon": "^3.0.1",
"npm-run-all": "^4.1.5",
"http-proxy-middleware": "^2.0.6"
}
"prettier": "^3.0.1",
"rollup-plugin-visualizer": "^5.9.2",
"terser": "^5.19.2",
"vite": "^4.4.9",
"vite-plugin-svgr": "^3.2.0",
"vite-tsconfig-paths": "^4.2.0"
},
"packageManager": "yarn@3.4.1"
}

View File

@@ -1,9 +1,10 @@
const { resolve, relative, sep } = require('path');
const { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } = require('fs');
const { resolve, relative, sep } = require('path');
var zlib = require('zlib');
var mime = require('mime-types');
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
const INDENT = ' ';
function getFilesSync(dir, files = []) {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
@@ -17,9 +18,9 @@ function getFilesSync(dir, files = []) {
return files;
}
function coherseToBuffer(input) {
return Buffer.isBuffer(input) ? input : Buffer.from(input);
}
// function coherseToBuffer(input) {
// return Buffer.isBuffer(input) ? input : Buffer.from(input);
// }
function cleanAndOpen(path) {
if (existsSync(path)) {
@@ -28,17 +29,16 @@ function cleanAndOpen(path) {
return createWriteStream(path, { flags: 'w+' });
}
class ProgmemGenerator {
constructor(options = {}) {
const { outputPath, bytesPerLine = 20, indent = ' ', includes = ARDUINO_INCLUDES } = options;
this.options = { outputPath, bytesPerLine, indent, includes };
}
apply(compiler) {
compiler.hooks.emit.tapAsync({ name: 'ProgmemGenerator' }, (compilation, callback) => {
const { outputPath, bytesPerLine, indent, includes } = this.options;
export default function ProgmemGenerator({ outputPath = './WWWData.h', bytesPerLine = 20 }) {
return {
name: 'ProgmemGenerator',
writeBundle: () => {
console.log('Generating ' + outputPath);
const includes = ARDUINO_INCLUDES;
const indent = INDENT;
const fileInfo = [];
const writeStream = cleanAndOpen(resolve(compilation.options.context, outputPath));
const writeStream = cleanAndOpen(resolve(outputPath));
try {
const writeIncludes = () => {
writeStream.write(includes);
@@ -48,7 +48,8 @@ class ProgmemGenerator {
const variable = 'ESP_REACT_DATA_' + fileInfo.length;
const mimeType = mime.lookup(relativeFilePath);
var size = 0;
writeStream.write('const uint8_t ' + variable + '[] PROGMEM = {');
writeStream.write('const uint8_t ' + variable + '[] = {');
// const zipBuffer = zlib.brotliCompressSync(buffer, { quality: 1 });
const zipBuffer = zlib.gzipSync(buffer);
zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) {
@@ -72,22 +73,22 @@ class ProgmemGenerator {
const writeFiles = () => {
// process static files
const buildPath = compilation.options.output.path;
const buildPath = resolve('build');
for (const filePath of getFilesSync(buildPath)) {
const readStream = readFileSync(filePath);
const relativeFilePath = relative(buildPath, filePath);
writeFile(relativeFilePath, readStream);
}
// process assets
const { assets } = compilation;
Object.keys(assets).forEach((relativeFilePath) => {
writeFile(relativeFilePath, coherseToBuffer(assets[relativeFilePath].source()));
});
// const { assets } = compilation;
// Object.keys(assets).forEach((relativeFilePath) => {
// writeFile(relativeFilePath, coherseToBuffer(assets[relativeFilePath].source()));
// });
};
const generateWWWClass = () => {
// eslint-disable-next-line max-len
return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
const generateWWWClass = () =>
`typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
class WWWData {
${indent}public:
@@ -98,8 +99,6 @@ ${fileInfo
${indent.repeat(2)}}
};
`;
};
const writeWWWClass = () => {
writeStream.write(generateWWWClass());
};
@@ -109,13 +108,11 @@ ${indent.repeat(2)}}
writeWWWClass();
writeStream.on('finish', () => {
callback();
// callback();
});
} finally {
writeStream.end();
}
});
}
}
};
}
module.exports = ProgmemGenerator;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -1,26 +1,18 @@
/*
* Just supporting latin due to size constrains on the esp chip
*
* The framework only makes use of 400 (regular) + 500 (medium) weight fonts.
*
* If using light or strong typography variants you will need to add additional fonts.
/*
* Uses font-size 400 (normal) only and Latin (plus extra unicode chars) to keep flash memory to a minimum
* View fonts on https://fonts.google.com/
* Download woff2 using e.g. https://fonts.googleapis.com/css2?family=Lato or https://fonts.googleapis.com/css2?family=Roboto
*/
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: local('Roboto'), local('Roboto-Regular'), url(../fonts/re.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0104-0107, U+0118-0119, U+0131, U+0141-0144, U+0152-0153, U+015A-015B, U+0179-017C,
U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/md.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0104-0107, U+0118-0119, U+0131, U+0141-0144, U+0152-0153, U+015A-015B, U+0179-017C,
U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF,
U+FFFD;
/* src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2) format('woff2'); */
src:
local('Roboto'),
local('Roboto-Regular'),
url(../fonts/re.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0104-0107, U+0118-0119, U+011E-011F, U+0130-0131, U+0141-0144, U+0152-0153, U+015A-015B,
U+015E-015F, U+0179-017C, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

Binary file not shown.

View File

@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, user-scalable=0, maximum-scale=1, minimum-scale=1"
/>
<link rel="stylesheet" href="%PUBLIC_URL%/css/roboto.css" />
<link rel="manifest" href="%PUBLIC_URL%/app/manifest.json" />
<title>EMS-ESP</title>
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -1,61 +1,51 @@
import { FC, createRef, createContext, useContext, useEffect, useState, RefObject } from 'react';
import { SnackbarProvider } from 'notistack';
import { useEffect, useState } from 'react';
import { ToastContainer, Slide } from 'react-toastify';
import { IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { FeaturesLoader } from './contexts/features';
import CustomTheme from './CustomTheme';
import AppRouting from './AppRouting';
import 'react-toastify/dist/ReactToastify.min.css';
import { localStorageDetector } from 'typesafe-i18n/detectors';
import TypesafeI18n from './i18n/i18n-react';
import { detectLocale } from './i18n/i18n-util';
import { loadLocaleAsync } from './i18n/i18n-util.async';
import { FeaturesLoader } from './contexts/features';
import type { FC } from 'react';
import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme';
import TypesafeI18n from 'i18n/i18n-react';
import { detectLocale } from 'i18n/i18n-util';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
const detectedLocale = detectLocale(localStorageDetector);
const App: FC = () => {
const notistackRef: RefObject<any> = createRef();
const onClickDismiss = (key: string | number | undefined) => () => {
notistackRef.current.closeSnackbar(key);
};
const ColorModeContext = createContext({ toggleColorMode: () => {} });
const colorMode = useContext(ColorModeContext);
const [wasLoaded, setWasLoaded] = useState(false);
useEffect(() => {
loadLocaleAsync(detectedLocale).then(() => setWasLoaded(true));
void loadLocaleAsync(detectedLocale).then(() => setWasLoaded(true));
}, []);
if (!wasLoaded) return null;
return (
<ColorModeContext.Provider value={colorMode}>
<TypesafeI18n locale={detectedLocale}>
<CustomTheme>
<SnackbarProvider
maxSnack={3}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
ref={notistackRef}
action={(key) => (
<IconButton onClick={onClickDismiss(key)} size="small">
<CloseIcon />
</IconButton>
)}
>
<FeaturesLoader>
<AppRouting />
</FeaturesLoader>
</SnackbarProvider>
</CustomTheme>
</TypesafeI18n>
</ColorModeContext.Provider>
<TypesafeI18n locale={detectedLocale}>
<CustomTheme>
<FeaturesLoader>
<AppRouting />
</FeaturesLoader>
<ToastContainer
position="bottom-left"
autoClose={3000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick={true}
rtl={false}
pauseOnFocusLoss={false}
draggable={false}
pauseOnHover={false}
transition={Slide}
closeButton={false}
theme="light"
/>
</CustomTheme>
</TypesafeI18n>
);
};

View File

@@ -1,29 +1,28 @@
import { FC, useContext, useEffect } from 'react';
import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
import { useSnackbar, VariantType } from 'notistack';
import { useContext, useEffect } from 'react';
import { useI18nContext } from './i18n/i18n-react';
import { Route, Routes, Navigate, useLocation } from 'react-router-dom';
import { Authentication, AuthenticationContext } from './contexts/authentication';
import { FeaturesContext } from './contexts/features';
import { RequireAuthenticated, RequireUnauthenticated } from './components';
import { toast } from 'react-toastify';
import type { FC } from 'react';
import SignIn from './SignIn';
import AuthenticatedRouting from './AuthenticatedRouting';
import AuthenticatedRouting from 'AuthenticatedRouting';
import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
interface SecurityRedirectProps {
message: string;
variant?: VariantType;
signOut?: boolean;
}
const RootRedirect: FC<SecurityRedirectProps> = ({ message, variant, signOut }) => {
const RootRedirect: FC<SecurityRedirectProps> = ({ message, signOut }) => {
const authenticationContext = useContext(AuthenticationContext);
const { enqueueSnackbar } = useSnackbar();
useEffect(() => {
signOut && authenticationContext.signOut(false);
enqueueSnackbar(message, { variant });
}, [message, variant, signOut, authenticationContext, enqueueSnackbar]);
toast.success(message);
}, [message, signOut, authenticationContext]);
return <Navigate to="/" />;
};
@@ -42,7 +41,6 @@ export const RemoveTrailingSlashes = () => {
};
const AppRouting: FC = () => {
const { features } = useContext(FeaturesContext);
const { LL } = useI18nContext();
return (
@@ -50,17 +48,15 @@ const AppRouting: FC = () => {
<RemoveTrailingSlashes />
<Routes>
<Route path="/unauthorized" element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />} />
<Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} variant="success" />} />
{features.security && (
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
)}
<Route path="/fileUpdated" element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />} />
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
<Route
path="/*"
element={

View File

@@ -1,66 +1,64 @@
import { FC, useCallback, useContext, useEffect } from 'react';
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { AxiosError } from 'axios';
import { Navigate, Routes, Route } from 'react-router-dom';
import Dashboard from './project/Dashboard';
import Help from './project/Help';
import Settings from './project/Settings';
import type { FC } from 'react';
import { FeaturesContext } from './contexts/features';
import * as AuthenticationApi from './api/authentication';
import { PROJECT_PATH } from './api/env';
import { AXIOS } from './api/endpoints';
import { Layout, RequireAdmin } from './components';
import { Layout, RequireAdmin } from 'components';
import AccessPoint from 'framework/ap/AccessPoint';
import Mqtt from 'framework/mqtt/Mqtt';
import NetworkConnection from 'framework/network/NetworkConnection';
import NetworkTime from 'framework/ntp/NetworkTime';
import Security from 'framework/security/Security';
import System from 'framework/system/System';
import ProjectRouting from './project/ProjectRouting';
const AuthenticatedRouting: FC = () => (
// const location = useLocation();
// const navigate = useNavigate();
// const handleApiResponseError = useCallback(
// (error: AxiosError) => {
// if (error.response && error.response.status === 401) {
// AuthenticationApi.storeLoginRedirect(location);
// navigate('/unauthorized');
// }
// return Promise.reject(error);
// },
// [location, navigate]
// );
// useEffect(() => {
// const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
// return () => AXIOS.interceptors.response.eject(axiosHandlerId);
// }, [handleApiResponseError]);
import NetworkConnection from './framework/network/NetworkConnection';
import AccessPoint from './framework/ap/AccessPoint';
import NetworkTime from './framework/ntp/NetworkTime';
import Mqtt from './framework/mqtt/Mqtt';
import System from './framework/system/System';
import Security from './framework/security/Security';
<Layout>
<Routes>
<Route path="/dashboard/*" element={<Dashboard />} />
<Route
path="/settings/*"
element={
<RequireAdmin>
<Settings />
</RequireAdmin>
}
/>
<Route path="/help/*" element={<Help />} />
const AuthenticatedRouting: FC = () => {
const { features } = useContext(FeaturesContext);
const location = useLocation();
const navigate = useNavigate();
const handleApiResponseError = useCallback(
(error: AxiosError) => {
if (error.response && error.response.status === 401) {
AuthenticationApi.storeLoginRedirect(location);
navigate('/unauthorized');
}
return Promise.reject(error);
},
[location, navigate]
);
useEffect(() => {
const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
return () => AXIOS.interceptors.response.eject(axiosHandlerId);
}, [handleApiResponseError]);
return (
<Layout>
<Routes>
{features.project && <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />}
<Route path="/network/*" element={<NetworkConnection />} />
<Route path="/ap/*" element={<AccessPoint />} />
{features.ntp && <Route path="/ntp/*" element={<NetworkTime />} />}
{features.mqtt && <Route path="/mqtt/*" element={<Mqtt />} />}
{features.security && (
<Route
path="/security/*"
element={
<RequireAdmin>
<Security />
</RequireAdmin>
}
/>
)}
<Route path="/system/*" element={<System />} />
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute(features)} />} />
</Routes>
</Layout>
);
};
<Route path="/network/*" element={<NetworkConnection />} />
<Route path="/ap/*" element={<AccessPoint />} />
<Route path="/ntp/*" element={<NetworkTime />} />
<Route path="/mqtt/*" element={<Mqtt />} />
<Route
path="/security/*"
element={
<RequireAdmin>
<Security />
</RequireAdmin>
}
/>
<Route path="/system/*" element={<System />} />
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
</Layout>
);
export default AuthenticatedRouting;

View File

@@ -1,10 +1,18 @@
import { FC } from 'react';
import { CssBaseline } from '@mui/material';
import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
import { blueGrey, blue } from '@mui/material/colors';
import type { FC } from 'react';
import { RequiredChildrenProps } from './utils';
import type { RequiredChildrenProps } from 'utils';
export const dialogStyle = {
'& .MuiDialog-paper': {
borderRadius: '8px',
borderColor: '#565656',
borderStyle: 'solid',
borderWidth: '1px'
},
backdropFilter: 'blur(1px)'
};
const theme = responsiveFontSizes(
createTheme({
@@ -14,10 +22,10 @@ const theme = responsiveFontSizes(
palette: {
mode: 'dark',
secondary: {
main: blue[500]
main: '#2196f3' // blue[500]
},
info: {
main: blueGrey[500]
main: '#607d8b' // blueGrey[500]
}
}
})

View File

@@ -1,34 +1,40 @@
import { FC, useContext, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { useSnackbar } from 'notistack';
import { Box, Fab, Paper, Typography, Button } from '@mui/material';
import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Paper, Typography, MenuItem, TextField, Button } from '@mui/material';
import { useRequest } from 'alova';
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import { FeaturesContext } from './contexts/features';
import type { ValidateFieldsError } from 'async-validator';
import * as AuthenticationApi from './api/authentication';
import { PROJECT_NAME } from './api/env';
import { AuthenticationContext } from './contexts/authentication';
import type { Locales } from 'i18n/i18n-types';
import type { ChangeEventHandler, FC } from 'react';
import type { SignInRequest } from 'types';
import * as AuthenticationApi from 'api/authentication';
import { PROJECT_NAME } from 'api/env';
import { extractErrorMessage, onEnterCallback, updateValue } from './utils';
import { SignInRequest } from './types';
import { ValidatedTextField } from './components';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from './validators';
import { ValidatedPasswordField, ValidatedTextField } from 'components';
import { AuthenticationContext } from 'contexts/authentication';
import { I18nContext } from './i18n/i18n-react';
import type { Locales } from './i18n/i18n-types';
import { loadLocaleAsync } from './i18n/i18n-util.async';
import { ReactComponent as NLflag } from './i18n/NL.svg';
import { ReactComponent as DEflag } from './i18n/DE.svg';
import { ReactComponent as GBflag } from './i18n/GB.svg';
import { ReactComponent as SVflag } from './i18n/SV.svg';
import { ReactComponent as PLflag } from './i18n/PL.svg';
import { ReactComponent as NOflag } from './i18n/NO.svg';
import { ReactComponent as FRflag } from './i18n/FR.svg';
import { ReactComponent as DEflag } from 'i18n/DE.svg';
import { ReactComponent as FRflag } from 'i18n/FR.svg';
import { ReactComponent as GBflag } from 'i18n/GB.svg';
import { ReactComponent as ITflag } from 'i18n/IT.svg';
import { ReactComponent as NLflag } from 'i18n/NL.svg';
import { ReactComponent as NOflag } from 'i18n/NO.svg';
import { ReactComponent as PLflag } from 'i18n/PL.svg';
import { ReactComponent as SVflag } from 'i18n/SV.svg';
import { ReactComponent as TRflag } from 'i18n/TR.svg';
import { I18nContext } from 'i18n/i18n-react';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
const SignIn: FC = () => {
const authenticationContext = useContext(AuthenticationContext);
const { enqueueSnackbar } = useSnackbar();
const { LL, setLocale, locale } = useContext(I18nContext);
const { features } = useContext(FeaturesContext);
const [signInRequest, setSignInRequest] = useState<SignInRequest>({
username: '',
@@ -37,8 +43,29 @@ const SignIn: FC = () => {
const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { send: callSignIn, onSuccess } = useRequest((request: SignInRequest) => AuthenticationApi.signIn(request), {
immediate: false
});
onSuccess((response) => {
if (response.data) {
authenticationContext.signIn(response.data.access_token);
}
});
const updateLoginRequestValue = updateValue(setSignInRequest);
const signIn = async () => {
await callSignIn(signInRequest).catch((event) => {
if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN());
} else {
toast.error(LL.ERROR() + ' ' + event.message);
}
setProcessing(false);
});
};
const validateAndSignIn = async () => {
setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({
@@ -46,34 +73,17 @@ const SignIn: FC = () => {
});
try {
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
signIn();
await signIn();
} catch (errors: any) {
setFieldErrors(errors);
setProcessing(false);
}
};
const signIn = async () => {
try {
const { data: loginResponse } = await AuthenticationApi.signIn(signInRequest);
authenticationContext.signIn(loginResponse.access_token);
} catch (error) {
if (error.response) {
if (error.response?.status === 401) {
enqueueSnackbar(LL.INVALID_LOGIN(), { variant: 'warning' });
}
} else {
enqueueSnackbar(extractErrorMessage(error, LL.ERROR()), { variant: 'error' });
}
setProcessing(false);
}
};
const submitOnEnter = onEnterCallback(signIn);
const { LL, setLocale, locale } = useContext(I18nContext);
const selectLocale = async (loc: Locales) => {
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ target }) => {
const loc = target.value as Locales;
localStorage.setItem('lang', loc);
await loadLocaleAsync(loc);
setLocale(loc);
@@ -93,81 +103,88 @@ const SignIn: FC = () => {
sx={(theme) => ({
textAlign: 'center',
padding: theme.spacing(2),
paddingTop: '200px',
paddingTop: '172px',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% ' + theme.spacing(2),
backgroundSize: 'auto 150px',
width: '100%'
})}
>
<Typography variant="h4">{PROJECT_NAME}</Typography>
<Box
sx={{
'& button, & a, & .MuiCard-root': {
mt: 0.5,
mx: 0.5
}
}}
>
<Button size="small" variant={locale === 'en' ? 'contained' : 'outlined'} onClick={() => selectLocale('en')}>
<GBflag style={{ width: 24 }} />
&nbsp;EN
</Button>
<Button size="small" variant={locale === 'de' ? 'contained' : 'outlined'} onClick={() => selectLocale('de')}>
<DEflag style={{ width: 24 }} />
<Typography variant="subtitle2">{features.version}</Typography>
<TextField name="locale" variant="outlined" value={locale} onChange={onLocaleSelected} size="small" select>
<MenuItem key="de" value="de">
<DEflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</Button>
<Button size="small" variant={locale === 'fr' ? 'contained' : 'outlined'} onClick={() => selectLocale('fr')}>
<FRflag style={{ width: 24 }} />
</MenuItem>
<MenuItem key="en" value="en">
<GBflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<FRflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</Button>
<Button size="small" variant={locale === 'nl' ? 'contained' : 'outlined'} onClick={() => selectLocale('nl')}>
<NLflag style={{ width: 24 }} />
</MenuItem>
<MenuItem key="it" value="it">
<ITflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<NLflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
</Button>
<Button size="small" variant={locale === 'no' ? 'contained' : 'outlined'} onClick={() => selectLocale('no')}>
<NOflag style={{ width: 24 }} />
</MenuItem>
<MenuItem key="no" value="no">
<NOflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NO
</Button>
<Button size="small" variant={locale === 'pl' ? 'contained' : 'outlined'} onClick={() => selectLocale('pl')}>
<PLflag style={{ width: 24 }} />
</MenuItem>
<MenuItem key="pl" value="pl">
<PLflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;PL
</Button>
<Button size="small" variant={locale === 'sv' ? 'contained' : 'outlined'} onClick={() => selectLocale('sv')}>
<SVflag style={{ width: 24 }} />
</MenuItem>
<MenuItem key="sv" value="sv">
<SVflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</Button>
</MenuItem>
<MenuItem key="tr" value="tr">
<TRflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
</TextField>
<Box display="flex" flexDirection="column" alignItems="center">
<ValidatedTextField
fieldErrors={fieldErrors}
disabled={processing}
sx={{
width: 240
}}
name="username"
label={LL.USERNAME(0)}
value={signInRequest.username}
onChange={updateLoginRequestValue}
margin="normal"
variant="outlined"
/>
<ValidatedPasswordField
fieldErrors={fieldErrors}
disabled={processing}
sx={{
width: 240
}}
name="password"
label={LL.PASSWORD()}
value={signInRequest.password}
onChange={updateLoginRequestValue}
onKeyDown={submitOnEnter}
variant="outlined"
/>
</Box>
<ValidatedTextField
fieldErrors={fieldErrors}
disabled={processing}
name="username"
label={LL.USERNAME(0)}
value={signInRequest.username}
onChange={updateLoginRequestValue}
margin="normal"
variant="outlined"
fullWidth
/>
<ValidatedTextField
fieldErrors={fieldErrors}
disabled={processing}
type="password"
name="password"
label={LL.PASSWORD()}
value={signInRequest.password}
onChange={updateLoginRequestValue}
onKeyDown={submitOnEnter}
margin="normal"
variant="outlined"
fullWidth
/>
<Fab variant="extended" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
<Button variant="contained" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
<ForwardIcon sx={{ mr: 1 }} />
{LL.SIGN_IN()}
</Fab>
</Button>
</Paper>
</Box>
);

View File

@@ -1,16 +1,7 @@
import { AxiosPromise } from 'axios';
import { alovaInstance } from './endpoints';
import { APSettings, APStatus } from '../types';
import { AXIOS } from './endpoints';
import type { APSettings, APStatus } from 'types';
export function readAPStatus(): AxiosPromise<APStatus> {
return AXIOS.get('/apStatus');
}
export function readAPSettings(): AxiosPromise<APSettings> {
return AXIOS.get('/apSettings');
}
export function updateAPSettings(apSettings: APSettings): AxiosPromise<APSettings> {
return AXIOS.post('/apSettings', apSettings);
}
export const readAPStatus = () => alovaInstance.Get<APStatus>('/rest/apStatus');
export const readAPSettings = () => alovaInstance.Get<APSettings>('/rest/apSettings');
export const updateAPSettings = (data: APSettings) => alovaInstance.Post<APSettings>('/rest/apSettings', data);

View File

@@ -1,29 +1,16 @@
import { AxiosPromise } from 'axios';
import * as H from 'history';
import jwtDecode from 'jwt-decode';
import { Path } from 'react-router-dom';
import { ACCESS_TOKEN, alovaInstance } from './endpoints';
import type * as H from 'history';
import type { Path } from 'react-router-dom';
import { Features, Me, SignInRequest, SignInResponse } from '../types';
import { ACCESS_TOKEN, AXIOS } from './endpoints';
import { PROJECT_PATH } from './env';
import type { Me, SignInRequest, SignInResponse } from 'types';
export const SIGN_IN_PATHNAME = 'loginPathname';
export const SIGN_IN_SEARCH = 'loginSearch';
export const getDefaultRoute = (features: Features) => (features.project ? `/${PROJECT_PATH}` : '/wifi');
export const verifyAuthorization = () => alovaInstance.Get('/rest/verifyAuthorization');
export const signIn = (request: SignInRequest) => alovaInstance.Post<SignInResponse>('/rest/signIn', request);
export function verifyAuthorization(): AxiosPromise<void> {
return AXIOS.get('/verifyAuthorization');
}
export function signIn(request: SignInRequest): AxiosPromise<SignInResponse> {
return AXIOS.post('/signIn', request);
}
/**
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
*/
export function getStorage() {
return localStorage || sessionStorage;
}
@@ -40,18 +27,18 @@ export function clearLoginRedirect() {
getStorage().removeItem(SIGN_IN_SEARCH);
}
export function fetchLoginRedirect(features: Features): Partial<Path> {
export function fetchLoginRedirect(): Partial<Path> {
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
clearLoginRedirect();
return {
pathname: signInPathname || getDefaultRoute(features),
pathname: signInPathname || `/dashboard`,
search: (signInPathname && signInSearch) || undefined
};
}
export const clearAccessToken = () => localStorage.removeItem(ACCESS_TOKEN);
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken) as Me;
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken);
export function addAccessTokenParameter(url: string) {
const accessToken = getStorage().getItem(ACCESS_TOKEN);

View File

@@ -1,105 +1,60 @@
import axios, { AxiosPromise, CancelToken, AxiosProgressEvent } from 'axios';
import { xhrRequestAdapter } from '@alova/adapter-xhr';
import { createAlova } from 'alova';
import ReactHook from 'alova/react';
import { unpack } from '../api/unpack';
import { decode } from '@msgpack/msgpack';
export const WS_BASE_URL = '/ws/';
export const API_BASE_URL = '/rest/';
export const ES_BASE_URL = '/es/';
export const EMSESP_API_BASE_URL = '/api/';
export const ACCESS_TOKEN = 'access_token';
export const WEB_SOCKET_ROOT = calculateWebSocketRoot(WS_BASE_URL);
export const EVENT_SOURCE_ROOT = calculateEventSourceRoot(ES_BASE_URL);
export const AXIOS = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
transformRequest: [
(data, headers) => {
if (headers) {
if (localStorage.getItem(ACCESS_TOKEN)) {
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
if (headers['Content-Type'] !== 'application/json') {
return data;
}
}
return JSON.stringify(data);
const host = window.location.host;
export const WEB_SOCKET_ROOT = 'ws://' + host + '/ws/';
export const EVENT_SOURCE_ROOT = 'http://' + host + '/es/';
export const alovaInstance = createAlova({
statesHook: ReactHook,
timeout: 3000, // 3 seconds but throwing a timeout error
localCache: null,
// localCache: {
// GET: {
// mode: 'placeholder', // see https://alova.js.org/learning/response-cache/#cache-replaceholder-mode
// expire: 2000
// }
// },
requestAdapter: xhrRequestAdapter(),
beforeRequest(method) {
if (localStorage.getItem(ACCESS_TOKEN)) {
method.config.headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
]
},
responded: {
onSuccess: async (response) => {
// if (response.status === 202) {
// throw new Error('Wait'); // wifi scan in progress
// } else
if (response.status === 205) {
throw new Error('Reboot required');
} else if (response.status === 400) {
throw new Error('Request Failed');
} else if (response.status >= 400) {
throw new Error(response.statusText);
}
const data = await response.data;
if (response.data instanceof ArrayBuffer) {
return unpack(data);
}
return data;
}
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
// onError: (error, method) => {
// alert(error.message);
// }
}
});
export const AXIOS_API = axios.create({
baseURL: EMSESP_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
transformRequest: [
(data, headers) => {
if (headers) {
if (localStorage.getItem(ACCESS_TOKEN)) {
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
if (headers['Content-Type'] !== 'application/json') {
return data;
}
}
return JSON.stringify(data);
}
]
export const alovaInstanceGH = createAlova({
baseURL: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
statesHook: ReactHook,
requestAdapter: xhrRequestAdapter()
});
export const AXIOS_BIN = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
responseType: 'arraybuffer',
transformRequest: [
(data, headers) => {
if (headers) {
if (localStorage.getItem(ACCESS_TOKEN)) {
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
if (headers['Content-Type'] !== 'application/json') {
return data;
}
}
return JSON.stringify(data);
}
],
transformResponse: [
(data) => {
return decode(data);
}
]
});
function calculateWebSocketRoot(webSocketPath: string) {
const location = window.location;
const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
return webProtocol + '//' + location.host + webSocketPath;
}
function calculateEventSourceRoot(endpointPath: string) {
const location = window.location;
return location.protocol + '//' + location.host + endpointPath;
}
export interface FileUploadConfig {
cancelToken?: CancelToken;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
}
export const startUploadFile = (url: string, file: File, config?: FileUploadConfig): AxiosPromise<void> => {
const formData = new FormData();
formData.append('file', file);
return AXIOS.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
...(config || {})
});
};

View File

@@ -1,2 +1 @@
export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME || 'EMS-ESP';
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH || 'project';
export const PROJECT_NAME = 'EMS-ESP';

View File

@@ -1,9 +1,5 @@
import { AxiosPromise } from 'axios';
import { alovaInstance } from './endpoints';
import { Features } from '../types';
import type { Features } from 'types';
import { AXIOS } from './endpoints';
export function readFeatures(): AxiosPromise<Features> {
return AXIOS.get('/features');
}
export const readFeatures = () => alovaInstance.Get<Features>('/rest/features');

View File

@@ -1,16 +1,6 @@
import { AxiosPromise } from 'axios';
import { MqttSettings, MqttStatus } from '../types';
import { alovaInstance } from './endpoints';
import type { MqttSettings, MqttStatus } from 'types';
import { AXIOS } from './endpoints';
export function readMqttStatus(): AxiosPromise<MqttStatus> {
return AXIOS.get('/mqttStatus');
}
export function readMqttSettings(): AxiosPromise<MqttSettings> {
return AXIOS.get('/mqttSettings');
}
export function updateMqttSettings(mqttSettings: MqttSettings): AxiosPromise<MqttSettings> {
return AXIOS.post('/mqttSettings', mqttSettings);
}
export const readMqttStatus = () => alovaInstance.Get<MqttStatus>('/rest/mqttStatus');
export const readMqttSettings = () => alovaInstance.Get<MqttSettings>('/rest/mqttSettings');
export const updateMqttSettings = (data: MqttSettings) => alovaInstance.Post<MqttSettings>('/rest/mqttSettings', data);

View File

@@ -1,25 +1,15 @@
import { AxiosPromise } from 'axios';
import { alovaInstance } from './endpoints';
import { WiFiNetworkList, NetworkSettings, NetworkStatus } from '../types';
import type { WiFiNetworkList, NetworkSettings, NetworkStatus } from 'types';
import { AXIOS } from './endpoints';
export function readNetworkStatus(): AxiosPromise<NetworkStatus> {
return AXIOS.get('/networkStatus');
}
export function scanNetworks(): AxiosPromise<void> {
return AXIOS.get('/scanNetworks');
}
export function listNetworks(): AxiosPromise<WiFiNetworkList> {
return AXIOS.get('/listNetworks');
}
export function readNetworkSettings(): AxiosPromise<NetworkSettings> {
return AXIOS.get('/networkSettings');
}
export function updateNetworkSettings(wifiSettings: NetworkSettings): AxiosPromise<NetworkSettings> {
return AXIOS.post('/networkSettings', wifiSettings);
}
export const readNetworkStatus = () => alovaInstance.Get<NetworkStatus>('/rest/networkStatus');
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
export const listNetworks = () =>
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
name: 'listNetworks',
timeout: 20000 // timeout 20 seconds
});
export const readNetworkSettings = () =>
alovaInstance.Get<NetworkSettings>('/rest/networkSettings', { name: 'networkSettings' });
export const updateNetworkSettings = (wifiSettings: NetworkSettings) =>
alovaInstance.Post<NetworkSettings>('/rest/networkSettings', wifiSettings);

View File

@@ -1,20 +1,11 @@
import { AxiosPromise } from 'axios';
import { NTPSettings, NTPStatus, Time } from '../types';
import { alovaInstance } from './endpoints';
import type { NTPSettings, NTPStatus, Time } from 'types';
import { AXIOS } from './endpoints';
export const readNTPStatus = () => alovaInstance.Get<NTPStatus>('/rest/ntpStatus');
export const readNTPSettings = () =>
alovaInstance.Get<NTPSettings>('/rest/ntpSettings', {
name: 'ntpSettings'
});
export const updateNTPSettings = (data: NTPSettings) => alovaInstance.Post<NTPSettings>('/rest/ntpSettings', data);
export function readNTPStatus(): AxiosPromise<NTPStatus> {
return AXIOS.get('/ntpStatus');
}
export function readNTPSettings(): AxiosPromise<NTPSettings> {
return AXIOS.get('/ntpSettings');
}
export function updateNTPSettings(ntpSettings: NTPSettings): AxiosPromise<NTPSettings> {
return AXIOS.post('/ntpSettings', ntpSettings);
}
export function updateTime(time: Time): AxiosPromise<Time> {
return AXIOS.post('/time', time);
}
export const updateTime = (data: Time) => alovaInstance.Post<Time>('/rest/time', data);

View File

@@ -1,17 +1,13 @@
import { AxiosPromise } from 'axios';
import { alovaInstance } from './endpoints';
import { SecuritySettings, Token } from '../types';
import type { SecuritySettings, Token } from 'types';
import { AXIOS } from './endpoints';
export const readSecuritySettings = () => alovaInstance.Get<SecuritySettings>('/rest/securitySettings');
export function readSecuritySettings(): AxiosPromise<SecuritySettings> {
return AXIOS.get('/securitySettings');
}
export const updateSecuritySettings = (securitySettings: SecuritySettings) =>
alovaInstance.Post('/rest/securitySettings', securitySettings);
export function updateSecuritySettings(securitySettings: SecuritySettings): AxiosPromise<SecuritySettings> {
return AXIOS.post('/securitySettings', securitySettings);
}
export function generateToken(username?: string): AxiosPromise<Token> {
return AXIOS.get('/generateToken', { params: { username } });
}
export const generateToken = (username?: string) =>
alovaInstance.Get<Token>('/rest/generateToken', {
params: { username }
});

View File

@@ -1,44 +1,42 @@
import { AxiosPromise } from 'axios';
import { alovaInstance, alovaInstanceGH } from './endpoints';
import type { OTASettings, SystemStatus, LogSettings } from 'types';
import { OTASettings, SystemStatus, LogSettings, LogEntries } from '../types';
// SystemStatus - also used to ping in Restart monitor for pinging
export const readSystemStatus = () => alovaInstance.Get<SystemStatus>('/rest/systemStatus');
import { AXIOS, AXIOS_BIN, FileUploadConfig, startUploadFile } from './endpoints';
// commands
export const restart = () => alovaInstance.Post('/rest/restart');
export const partition = () => alovaInstance.Post('/rest/partition');
export const factoryReset = () => alovaInstance.Post('/rest/factoryReset');
export function readSystemStatus(timeout?: number): AxiosPromise<SystemStatus> {
return AXIOS.get('/systemStatus', { timeout });
}
// OTA
export const readOTASettings = () => alovaInstance.Get<OTASettings>(`/rest/otaSettings`);
export const updateOTASettings = (data: any) => alovaInstance.Post('/rest/otaSettings', data);
export function restart(): AxiosPromise<void> {
return AXIOS.post('/restart');
}
// SystemLog
export const readLogSettings = () => alovaInstance.Get<LogSettings>(`/rest/logSettings`);
export const updateLogSettings = (data: any) => alovaInstance.Post('/rest/logSettings', data);
export const fetchLog = () => alovaInstance.Post('/rest/fetchLog');
export function partition(): AxiosPromise<void> {
return AXIOS.post('/partition');
}
// Get versions from github
export const getStableVersion = () =>
alovaInstanceGH.Get('latest', {
transformData(response: any) {
return response.data.name.substring(1);
}
});
export const getDevVersion = () =>
alovaInstanceGH.Get('tags/latest', {
transformData(response: any) {
return response.data.name.split(/\s+/).splice(-1)[0].substring(1);
}
});
export function factoryReset(): AxiosPromise<void> {
return AXIOS.post('/factoryReset');
}
export function readOTASettings(): AxiosPromise<OTASettings> {
return AXIOS.get('/otaSettings');
}
export function updateOTASettings(otaSettings: OTASettings): AxiosPromise<OTASettings> {
return AXIOS.post('/otaSettings', otaSettings);
}
export const uploadFile = (file: File, config?: FileUploadConfig): AxiosPromise<void> =>
startUploadFile('/uploadFile', file, config);
export function readLogSettings(): AxiosPromise<LogSettings> {
return AXIOS.get('/logSettings');
}
export function updateLogSettings(logSettings: LogSettings): AxiosPromise<LogSettings> {
return AXIOS.post('/logSettings', logSettings);
}
export function readLogEntries(): AxiosPromise<LogEntries> {
return AXIOS_BIN.get('/fetchLog');
}
export const uploadFile = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: 60000, // override timeout for uploading firmware - 1 minute
enableUpload: true
});
};

1131
interface/src/api/unpack.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,25 @@
import { FC } from 'react';
import { Box, BoxProps } from '@mui/material';
import { Box } from '@mui/material';
import type { BoxProps } from '@mui/material';
import type { FC } from 'react';
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => {
return (
<Box
sx={{
'& button, & a, & .MuiCard-root': {
mt: 2,
mx: 0.6,
'&:last-child': {
mr: 0
},
'&:first-of-type': {
ml: 0
}
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => (
<Box
sx={{
'& button, & a, & .MuiCard-root': {
mt: 2,
mx: 0.6,
'&:last-child': {
mr: 0
},
'&:first-of-type': {
ml: 0
}
}}
{...rest}
>
{children}
</Box>
);
};
}
}}
{...rest}
>
{children}
</Box>
);
export default ButtonRow;

View File

@@ -1,11 +1,10 @@
import { FC } from 'react';
import { Box, BoxProps, SvgIconProps, Theme, Typography, useTheme } from '@mui/material';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import ErrorIcon from '@mui/icons-material/Error';
import { Box, Typography, useTheme } from '@mui/material';
import type { BoxProps, SvgIconProps, Theme } from '@mui/material';
import type { FC } from 'react';
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';

View File

@@ -1,8 +1,7 @@
import { FC } from 'react';
import { Paper, Divider } from '@mui/material';
import type { FC } from 'react';
import { RequiredChildrenProps } from '../utils';
import type { RequiredChildrenProps } from 'utils';
interface SectionContentProps extends RequiredChildrenProps {
title: string;

View File

@@ -6,3 +6,4 @@ export * from './upload';
export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox';
export { default as BlockNavigation } from './routing/BlockNavigation';

View File

@@ -1,5 +1,6 @@
import { FC } from 'react';
import { FormControlLabel, FormControlLabelProps } from '@mui/material';
import { FormControlLabel } from '@mui/material';
import type { FormControlLabelProps } from '@mui/material';
import type { FC } from 'react';
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
<div>

View File

@@ -1,10 +1,11 @@
import { FC, useState } from 'react';
import { IconButton, InputAdornment } from '@mui/material';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { IconButton, InputAdornment } from '@mui/material';
import { useState } from 'react';
import ValidatedTextField, { ValidatedTextFieldProps } from './ValidatedTextField';
import ValidatedTextField from './ValidatedTextField';
import type { ValidatedTextFieldProps } from './ValidatedTextField';
import type { FC } from 'react';
type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
@@ -19,11 +20,7 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ InputProps, .
...InputProps,
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</InputAdornment>

View File

@@ -1,7 +1,7 @@
import { FC } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { FormHelperText, TextField, TextFieldProps } from '@mui/material';
import { FormHelperText, TextField } from '@mui/material';
import type { TextFieldProps } from '@mui/material';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
interface ValidatedFieldProps {
fieldErrors?: ValidateFieldsError;

View File

@@ -1,16 +1,15 @@
import { FC, useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Box, Toolbar } from '@mui/material';
import { PROJECT_NAME } from '../../api/env';
import { RequiredChildrenProps } from '../../utils';
import LayoutDrawer from './LayoutDrawer';
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import LayoutAppBar from './LayoutAppBar';
import LayoutDrawer from './LayoutDrawer';
import { LayoutContext } from './context';
import type { FC } from 'react';
export const DRAWER_WIDTH = 240;
import type { RequiredChildrenProps } from 'utils';
import { PROJECT_NAME } from 'api/env';
export const DRAWER_WIDTH = 210;
const Layout: FC<RequiredChildrenProps> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);

View File

@@ -1,50 +1,36 @@
import { FC, useContext } from 'react';
import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
import LayoutAuthMenu from './LayoutAuthMenu';
import type { FC } from 'react';
import { FeaturesContext } from '../../contexts/features';
export const DRAWER_WIDTH = 240;
export const DRAWER_WIDTH = 210;
interface LayoutAppBarProps {
title: string;
onToggleDrawer: () => void;
}
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
const { features } = useContext(FeaturesContext);
return (
<AppBar
position="fixed"
sx={{
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { md: `${DRAWER_WIDTH}px` },
boxShadow: 'none',
backgroundColor: '#2e586a'
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={onToggleDrawer}
sx={{ mr: 2, display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
{title}
</Typography>
<Box flexGrow={1} />
{features.security && <LayoutAuthMenu />}
</Toolbar>
</AppBar>
);
};
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => (
<AppBar
position="fixed"
sx={{
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { md: `${DRAWER_WIDTH}px` },
boxShadow: 'none',
backgroundColor: '#2e586a'
}}
>
<Toolbar>
<IconButton color="inherit" edge="start" onClick={onToggleDrawer} sx={{ mr: 2, display: { md: 'none' } }}>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
{title}
</Typography>
<Box flexGrow={1} />
<LayoutAuthMenu />
</Toolbar>
</AppBar>
);
export default LayoutAppBar;

View File

@@ -1,5 +1,5 @@
import { FC, useState, useContext, ChangeEventHandler } from 'react';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import PersonIcon from '@mui/icons-material/Person';
import {
Box,
Button,
@@ -9,27 +9,27 @@ import {
Typography,
Avatar,
styled,
TypographyProps,
MenuItem,
TextField
} from '@mui/material';
import { useState, useContext } from 'react';
import type { TypographyProps } from '@mui/material';
import PersonIcon from '@mui/icons-material/Person';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import type { Locales } from 'i18n/i18n-types';
import type { FC, ChangeEventHandler } from 'react';
import { AuthenticatedContext } from 'contexts/authentication';
import { AuthenticatedContext } from '../../contexts/authentication';
import { I18nContext } from '../../i18n/i18n-react';
import type { Locales } from '../../i18n/i18n-types';
import { loadLocaleAsync } from '../../i18n/i18n-util.async';
import { ReactComponent as NLflag } from '../../i18n/NL.svg';
import { ReactComponent as DEflag } from '../../i18n/DE.svg';
import { ReactComponent as GBflag } from '../../i18n/GB.svg';
import { ReactComponent as SVflag } from '../../i18n/SV.svg';
import { ReactComponent as PLflag } from '../../i18n/PL.svg';
import { ReactComponent as NOflag } from '../../i18n/NO.svg';
import { ReactComponent as FRflag } from '../../i18n/FR.svg';
import { ReactComponent as DEflag } from 'i18n/DE.svg';
import { ReactComponent as FRflag } from 'i18n/FR.svg';
import { ReactComponent as GBflag } from 'i18n/GB.svg';
import { ReactComponent as ITflag } from 'i18n/IT.svg';
import { ReactComponent as NLflag } from 'i18n/NL.svg';
import { ReactComponent as NOflag } from 'i18n/NO.svg';
import { ReactComponent as PLflag } from 'i18n/PL.svg';
import { ReactComponent as SVflag } from 'i18n/SV.svg';
import { ReactComponent as TRflag } from 'i18n/TR.svg';
import { I18nContext } from 'i18n/i18n-react';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
const ItemTypography = styled(Typography)<TypographyProps>({
maxWidth: '250px',
@@ -74,19 +74,22 @@ const LayoutAuthMenu: FC = () => {
size="small"
select
>
<MenuItem key="en" value="en">
<GBflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<Divider />
<MenuItem key="de" value="de">
<DEflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</MenuItem>
<MenuItem key="en" value="en">
<GBflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<FRflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</MenuItem>
<MenuItem key="it" value="it">
<ITflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<NLflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
@@ -103,6 +106,10 @@ const LayoutAuthMenu: FC = () => {
<SVflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</MenuItem>
<MenuItem key="tr" value="tr">
<TRflag style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
</TextField>
<IconButton

View File

@@ -1,11 +1,9 @@
import { FC } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { PROJECT_NAME } from '../../api/env';
import LayoutMenu from './LayoutMenu';
import { DRAWER_WIDTH } from './Layout';
import LayoutMenu from './LayoutMenu';
import type { FC } from 'react';
import { PROJECT_NAME } from 'api/env';
const LayoutDrawerLogo = styled('img')(({ theme }) => ({
[theme.breakpoints.down('sm')]: {
@@ -13,7 +11,7 @@ const LayoutDrawerLogo = styled('img')(({ theme }) => ({
marginRight: theme.spacing(2)
},
[theme.breakpoints.up('sm')]: {
height: 36,
height: 38,
marginRight: theme.spacing(2)
}
}));
@@ -29,9 +27,7 @@ const LayoutDrawer: FC<LayoutDrawerProps> = ({ mobileOpen, onClose }) => {
<Toolbar disableGutters>
<Box display="flex" alignItems="center" px={2}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Typography variant="h6" color="textPrimary">
{PROJECT_NAME}
</Typography>
<Typography variant="h6">{PROJECT_NAME}</Typography>
</Box>
<Divider absolute />
</Toolbar>

View File

@@ -1,40 +1,45 @@
import { FC, useContext } from 'react';
import { Divider, List } from '@mui/material';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import SettingsIcon from '@mui/icons-material/Settings';
import InfoIcon from '@mui/icons-material/Info';
import LockIcon from '@mui/icons-material/Lock';
import SettingsIcon from '@mui/icons-material/Settings';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TuneIcon from '@mui/icons-material/Tune';
import { Divider, List } from '@mui/material';
import { useContext } from 'react';
import type { FC } from 'react';
import { FeaturesContext } from '../../contexts/features';
import ProjectMenu from '../../project/ProjectMenu';
import LayoutMenuItem from 'components/layout/LayoutMenuItem';
import LayoutMenuItem from './LayoutMenuItem';
import { AuthenticatedContext } from '../../contexts/authentication';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenu: FC = () => {
const { features } = useContext(FeaturesContext);
const authenticatedContext = useContext(AuthenticatedContext);
const { LL } = useI18nContext();
return (
<>
{features.project && (
<List disablePadding component="nav">
<ProjectMenu />
<Divider />
</List>
)}
<List disablePadding component="nav">
<LayoutMenuItem icon={DashboardIcon} label={LL.DASHBOARD()} to={`/dashboard`} />
<LayoutMenuItem
icon={TuneIcon}
label={LL.SETTINGS_OF('')}
to={`/settings`}
disabled={!authenticatedContext.me.admin}
/>
<LayoutMenuItem icon={InfoIcon} label={LL.HELP_OF('')} to={`/help`} />
<Divider />
</List>
<List disablePadding component="nav">
<LayoutMenuItem icon={SettingsEthernetIcon} label={LL.NETWORK(0)} to="/network" />
<LayoutMenuItem icon={SettingsInputAntennaIcon} label={LL.ACCESS_POINT(0)} to="/ap" />
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label="NTP" to="/ntp" />}
{features.mqtt && <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />}
<LayoutMenuItem icon={AccessTimeIcon} label="NTP" to="/ntp" />
<LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />
<LayoutMenuItem
icon={LockIcon}
label={LL.SECURITY(0)}

View File

@@ -1,11 +1,9 @@
import { FC } from 'react';
import { ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { Link, useLocation } from 'react-router-dom';
import type { SvgIconProps } from '@mui/material';
import type { FC } from 'react';
import { ListItem, ListItemButton, ListItemIcon, ListItemText, SvgIconProps } from '@mui/material';
import { grey } from '@mui/material/colors';
import { routeMatches } from '../../utils';
import { routeMatches } from 'utils';
interface LayoutMenuItemProps {
icon: React.ComponentType<SvgIconProps>;
@@ -17,13 +15,15 @@ interface LayoutMenuItemProps {
const LayoutMenuItem: FC<LayoutMenuItemProps> = ({ icon: Icon, label, to, disabled }) => {
const { pathname } = useLocation();
const selected = routeMatches(to, pathname);
return (
<ListItem disablePadding selected={routeMatches(to, pathname)}>
<ListItemButton component={Link} to={to} disabled={disabled}>
<ListItemIcon sx={{ color: grey[500] }}>
<ListItem disablePadding>
<ListItemButton component={Link} to={to} disabled={disabled} selected={selected}>
<ListItemIcon sx={{ color: selected ? '#90caf9' : '#9e9e9e' }}>
<Icon />
</ListItemIcon>
<ListItemText>{label}</ListItemText>
<ListItemText sx={{ color: selected ? '#90caf9' : '#f5f5f5' }}>{label}</ListItemText>
</ListItemButton>
</ListItem>
);

View File

@@ -1,7 +1,6 @@
import { FC } from 'react';
import { Box, Paper, Typography } from '@mui/material';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Paper, Typography } from '@mui/material';
import type { FC } from 'react';
interface ApplicationErrorProps {
message?: string;

View File

@@ -1,11 +1,10 @@
import { FC } from 'react';
import { Box, Button, CircularProgress, Typography } from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import { Box, Button, CircularProgress, Typography } from '@mui/material';
import type { FC } from 'react';
import { MessageBox } from '..';
import { MessageBox } from 'components';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
interface FormLoaderProps {
message?: string;

View File

@@ -1,8 +1,8 @@
import { FC } from 'react';
import { CircularProgress, Box, Typography } from '@mui/material';
import type { Theme } from '@mui/material';
import type { FC } from 'react';
import { CircularProgress, Box, Typography, Theme } from '@mui/material';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
interface LoadingSpinnerProps {
height?: number | string;

View File

@@ -0,0 +1,32 @@
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
import type { FC } from 'react';
import type { unstable_Blocker as Blocker } from 'react-router-dom';
import { dialogStyle } from 'CustomTheme';
import { useI18nContext } from 'i18n/i18n-react';
interface BlockNavigationProps {
blocker: Blocker;
}
const BlockNavigation: FC<BlockNavigationProps> = ({ blocker }) => {
const { LL } = useI18nContext();
return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
<DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle>
<DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => blocker.reset?.()} color="secondary">
{LL.STAY()}
</Button>
<Button variant="contained" onClick={() => blocker.proceed?.()} color="primary">
{LL.LEAVE()}
</Button>
</DialogActions>
</Dialog>
);
};
export default BlockNavigation;

View File

@@ -1,8 +1,9 @@
import { FC, useContext } from 'react';
import { useContext } from 'react';
import { Navigate } from 'react-router-dom';
import type { FC } from 'react';
import { AuthenticatedContext } from '../../contexts/authentication';
import { RequiredChildrenProps } from '../../utils';
import type { RequiredChildrenProps } from 'utils';
import { AuthenticatedContext } from 'contexts/authentication';
const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
const authenticatedContext = useContext(AuthenticatedContext);

View File

@@ -1,14 +1,12 @@
import { FC, useContext, useEffect } from 'react';
import { useContext, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import {
AuthenticatedContext,
AuthenticatedContextValue,
AuthenticationContext
} from '../../contexts/authentication/context';
import { storeLoginRedirect } from '../../api/authentication';
import type { AuthenticatedContextValue } from 'contexts/authentication/context';
import type { FC } from 'react';
import { RequiredChildrenProps } from '../../utils';
import type { RequiredChildrenProps } from 'utils';
import { storeLoginRedirect } from 'api/authentication';
import { AuthenticatedContext, AuthenticationContext } from 'contexts/authentication/context';
const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
const authenticationContext = useContext(AuthenticationContext);

View File

@@ -1,16 +1,15 @@
import { FC, useContext } from 'react';
import { useContext } from 'react';
import { Navigate } from 'react-router-dom';
import type { FC } from 'react';
import * as AuthenticationApi from '../../api/authentication';
import { AuthenticationContext } from '../../contexts/authentication';
import { RequiredChildrenProps } from '../../utils';
import { FeaturesContext } from '../../contexts/features';
import type { RequiredChildrenProps } from 'utils';
import * as AuthenticationApi from 'api/authentication';
import { AuthenticationContext } from 'contexts/authentication';
const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
const { features } = useContext(FeaturesContext);
const authenticationContext = useContext(AuthenticationContext);
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect(features)} /> : <>{children}</>;
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect()} /> : <>{children}</>;
};
export default RequireUnauthenticated;

View File

@@ -1,9 +1,8 @@
import React, { FC } from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, useMediaQuery, useTheme } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import type { FC } from 'react';
import { RequiredChildrenProps } from '../../utils';
import type { RequiredChildrenProps } from 'utils';
interface RouterTabsProps extends RequiredChildrenProps {
value: string | false;
@@ -15,7 +14,7 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
const handleTabChange = (event: React.ChangeEvent<HTMLInputElement>, path: string) => {
navigate(path);
};

View File

@@ -1,14 +1,14 @@
import { FC, Fragment } from 'react';
import { useDropzone, DropzoneState } from 'react-dropzone';
import { AxiosProgressEvent } from 'axios';
import { Box, Button, LinearProgress, Theme, Typography, useTheme } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CancelIcon from '@mui/icons-material/Cancel';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import { Box, Button, LinearProgress, Typography, useTheme } from '@mui/material';
import { Fragment } from 'react';
import { useDropzone } from 'react-dropzone';
import type { Theme } from '@mui/material';
import type { Progress } from 'alova';
import type { FC } from 'react';
import type { DropzoneState } from 'react-dropzone';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
const getBorderColor = (theme: Theme, props: DropzoneState) => {
if (props.isDragAccept) {
@@ -26,11 +26,13 @@ const getBorderColor = (theme: Theme, props: DropzoneState) => {
export interface SingleUploadProps {
onDrop: (acceptedFiles: File[]) => void;
onCancel: () => void;
uploading: boolean;
progress?: AxiosProgressEvent;
isUploading: boolean;
progress: Progress;
}
const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, uploading, progress }) => {
const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, isUploading, progress }) => {
const uploading = isUploading && progress.total > 0;
const dropzoneState = useDropzone({
onDrop,
accept: {
@@ -38,20 +40,19 @@ const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, uploading, prog
'application/json': ['.json'],
'text/plain': ['.md5']
},
disabled: uploading,
disabled: isUploading,
multiple: false
});
const { getRootProps, getInputProps } = dropzoneState;
const theme = useTheme();
const { LL } = useI18nContext();
const progressText = () => {
if (uploading) {
if (progress?.total) {
if (progress.total) {
return LL.UPLOADING() + ': ' + Math.round((progress.loaded * 100) / progress.total) + '%';
}
return LL.UPLOADING() + `\u2026`;
}
return LL.UPLOAD_DROP_TEXT();
};
@@ -81,8 +82,8 @@ const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, uploading, prog
<Fragment>
<Box width="100%" p={2}>
<LinearProgress
variant={!progress || progress.total ? 'determinate' : 'indeterminate'}
value={!progress ? 0 : progress.total ? Math.round((progress.loaded * 100) / progress.total) : 0}
variant="determinate"
value={progress.total === 0 ? 0 : Math.round((progress.loaded * 100) / progress.total)}
/>
</Box>
<Button startIcon={<CancelIcon />} variant="outlined" color="secondary" onClick={onCancel}>

View File

@@ -1,2 +1 @@
export { default as SingleUpload } from './SingleUpload';
export { default as useFileUpload } from './useFileUpload';

View File

@@ -1,70 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import axios, { AxiosPromise, CancelTokenSource, AxiosProgressEvent } from 'axios';
import { useSnackbar } from 'notistack';
import { extractErrorMessage } from '../../utils';
import { FileUploadConfig } from '../../api/endpoints';
import { useI18nContext } from '../../i18n/i18n-react';
interface MediaUploadOptions {
upload: (file: File, config?: FileUploadConfig) => AxiosPromise<void>;
}
const useFileUpload = ({ upload }: MediaUploadOptions) => {
const { LL } = useI18nContext();
const { enqueueSnackbar } = useSnackbar();
const [uploading, setUploading] = useState<boolean>(false);
const [md5, setMd5] = useState<string>('');
const [uploadProgress, setUploadProgress] = useState<AxiosProgressEvent>();
const [uploadCancelToken, setUploadCancelToken] = useState<CancelTokenSource>();
const resetUploadingStates = () => {
setUploading(false);
setUploadProgress(undefined);
setUploadCancelToken(undefined);
setMd5('');
};
const cancelUpload = useCallback(() => {
uploadCancelToken?.cancel();
resetUploadingStates();
}, [uploadCancelToken]);
useEffect(() => {
return () => {
uploadCancelToken?.cancel();
};
}, [uploadCancelToken]);
const uploadFile = async (images: File[]) => {
try {
const cancelToken = axios.CancelToken.source();
setUploadCancelToken(cancelToken);
setUploading(true);
const response = await upload(images[0], {
onUploadProgress: setUploadProgress,
cancelToken: cancelToken.token
});
resetUploadingStates();
if (response.status === 200) {
enqueueSnackbar(LL.UPLOAD() + ' ' + LL.SUCCESSFUL(), { variant: 'success' });
} else if (response.status === 201) {
setMd5(String(response.data));
enqueueSnackbar(LL.UPLOAD() + ' MD5 ' + LL.SUCCESSFUL(), { variant: 'success' });
}
} catch (error) {
if (axios.isCancel(error)) {
enqueueSnackbar(LL.UPLOAD() + ' ' + LL.ABORTED(), { variant: 'warning' });
} else {
resetUploadingStates();
enqueueSnackbar(extractErrorMessage(error, LL.UPLOAD() + ' ' + LL.FAILED()), { variant: 'error' });
}
}
};
return [uploadFile, cancelUpload, uploading, uploadProgress, md5] as const;
};
export default useFileUpload;

View File

@@ -1,33 +1,35 @@
import { FC, useCallback, useContext, useEffect, useState } from 'react';
import { useSnackbar } from 'notistack';
import { useRequest } from 'alova';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useI18nContext } from '../../i18n/i18n-react';
import * as AuthenticationApi from '../../api/authentication';
import { ACCESS_TOKEN } from '../../api/endpoints';
import { RequiredChildrenProps } from '../../utils';
import { LoadingSpinner } from '../../components';
import { Me } from '../../types';
import { FeaturesContext } from '../features';
import { toast } from 'react-toastify';
import { AuthenticationContext } from './context';
import type { FC } from 'react';
import type { Me } from 'types';
import type { RequiredChildrenProps } from 'utils';
import * as AuthenticationApi from 'api/authentication';
import { ACCESS_TOKEN } from 'api/endpoints';
import { LoadingSpinner } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const { features } = useContext(FeaturesContext);
const { LL } = useI18nContext();
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const [initialized, setInitialized] = useState<boolean>(false);
const [me, setMe] = useState<Me>();
const { send: verifyAuthorization } = useRequest(AuthenticationApi.verifyAuthorization(), {
immediate: false
});
const signIn = (accessToken: string) => {
try {
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
setMe(decodedMe);
enqueueSnackbar(LL.LOGGED_IN({ name: decodedMe.username }), { variant: 'success' });
toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
} catch (error) {
setMe(undefined);
throw new Error('Failed to parse JWT');
@@ -43,29 +45,26 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
};
const refresh = useCallback(async () => {
if (!features.security) {
setMe({ admin: true, username: 'admin' });
setInitialized(true);
return;
}
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
try {
await AuthenticationApi.verifyAuthorization();
setMe(AuthenticationApi.decodeMeJWT(accessToken));
setInitialized(true);
} catch (error) {
setMe(undefined);
setInitialized(true);
}
await verifyAuthorization()
.then(() => {
setMe(AuthenticationApi.decodeMeJWT(accessToken));
setInitialized(true);
})
.catch(() => {
setMe(undefined);
setInitialized(true);
});
} else {
setMe(undefined);
setInitialized(true);
}
}, [features]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
refresh();
void refresh();
}, [refresh]);
if (initialized) {

View File

@@ -1,5 +1,5 @@
import { createContext } from 'react';
import { Me } from '../../types';
import type { Me } from 'types';
export interface AuthenticationContextValue {
refresh: () => Promise<void>;

View File

@@ -1,29 +1,13 @@
import { FC, useCallback, useEffect, useState } from 'react';
import * as FeaturesApi from '../../api/features';
import { extractErrorMessage, RequiredChildrenProps } from '../../utils';
import { Features } from '../../types';
import { ApplicationError, LoadingSpinner } from '../../components';
import { useRequest } from 'alova';
import { FeaturesContext } from '.';
import type { FC } from 'react';
import type { RequiredChildrenProps } from 'utils';
import * as FeaturesApi from 'api/features';
const FeaturesLoader: FC<RequiredChildrenProps> = (props) => {
const [errorMessage, setErrorMessage] = useState<string>();
const [features, setFeatures] = useState<Features>();
const loadFeatures = useCallback(async () => {
try {
const response = await FeaturesApi.readFeatures();
setFeatures(response.data);
} catch (error) {
setErrorMessage(extractErrorMessage(error, 'Failed to fetch application details.'));
}
}, []);
useEffect(() => {
loadFeatures();
}, [loadFeatures]);
const { data: features } = useRequest(FeaturesApi.readFeatures);
if (features) {
return (
@@ -36,12 +20,6 @@ const FeaturesLoader: FC<RequiredChildrenProps> = (props) => {
</FeaturesContext.Provider>
);
}
if (errorMessage) {
return <ApplicationError message={errorMessage} />;
}
return <LoadingSpinner height="100vh" />;
};
export default FeaturesLoader;

View File

@@ -1,6 +1,6 @@
import { createContext } from 'react';
import { Features } from '../../types';
import type { Features } from 'types';
export interface FeaturesContextValue {
features: Features;

View File

@@ -1,32 +1,45 @@
import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { range } from 'lodash';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Button, Checkbox, MenuItem } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { range } from 'lodash-es';
import { useState } from 'react';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import { createAPSettingsValidator, validate } from '../../validators';
import type { APSettings } from 'types';
import * as APApi from 'api/ap';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField
} from '../../components';
ValidatedTextField,
BlockNavigation
} from 'components';
import { APProvisionMode, APSettings } from '../../types';
import { numberValue, updateValue, useRest } from '../../utils';
import * as APApi from '../../api/ap';
import { useI18nContext } from 'i18n/i18n-react';
import { APProvisionMode } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { useI18nContext } from '../../i18n/i18n-react';
import { createAPSettingsValidator, validate } from 'validators';
export const isAPEnabled = ({ provision_mode }: APSettings) => {
return provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
};
export const isAPEnabled = ({ provision_mode }: APSettings) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
const APSettingsForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<APSettings>({
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<APSettings>({
read: APApi.readAPSettings,
update: APApi.updateAPSettings
});
@@ -35,7 +48,7 @@ const APSettingsForm: FC = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setData);
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue);
const content = () => {
if (!data) {
@@ -46,7 +59,7 @@ const APSettingsForm: FC = () => {
try {
setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data);
saveData();
await saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
@@ -163,24 +176,37 @@ const APSettingsForm: FC = () => {
/>
</>
)}
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
{LL.SAVE()}
</Button>
</ButtonRow>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={loadData}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
disabled={saving}
variant="contained"
color="info"
type="submit"
onClick={validateAndSubmit}
>
{LL.APPLY_CHANGES(dirtyFlags.length)}
</Button>
</ButtonRow>
)}
</>
);
};
return (
<SectionContent title={LL.SETTINGS_OF(LL.ACCESS_POINT(1))} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);

View File

@@ -1,17 +1,18 @@
import { FC } from 'react';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ComputerIcon from '@mui/icons-material/Computer';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import RefreshIcon from '@mui/icons-material/Refresh';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, useTheme } from '@mui/material';
import { useRequest } from 'alova';
import type { Theme } from '@mui/material';
import type { FC } from 'react';
import * as APApi from '../../api/ap';
import { APNetworkStatus, APStatus } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { useRest } from '../../utils';
import type { APStatus } from 'types';
import * as APApi from 'api/ap';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
import { APNetworkStatus } from 'types';
export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
switch (status) {
@@ -27,7 +28,7 @@ export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
};
const APStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<APStatus>({ read: APApi.readAPStatus });
const { data: data, send: loadData, error } = useRequest(APApi.readAPStatus);
const { LL } = useI18nContext();
@@ -48,7 +49,7 @@ const APStatusForm: FC = () => {
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (

View File

@@ -1,14 +1,14 @@
import { FC, useContext } from 'react';
import { Tab } from '@mui/material';
import { useContext } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom';
import { Tab } from '@mui/material';
import { AuthenticatedContext } from '../../contexts/authentication';
import APStatusForm from './APStatusForm';
import APSettingsForm from './APSettingsForm';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import APStatusForm from './APStatusForm';
import type { FC } from 'react';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
const AccessPoint: FC = () => {
const { LL } = useI18nContext();
@@ -27,6 +27,7 @@ const AccessPoint: FC = () => {
</RouterTabs>
<Routes>
<Route path="status" element={<APStatusForm />} />
<Route index element={<Navigate to="status" />} />
<Route
path="settings"
element={

View File

@@ -1,15 +1,14 @@
import React, { FC, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import { AuthenticatedContext } from '../../contexts/authentication';
import MqttStatusForm from './MqttStatusForm';
import { useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import MqttSettingsForm from './MqttSettingsForm';
import MqttStatusForm from './MqttStatusForm';
import type { FC } from 'react';
import { useI18nContext } from '../../i18n/i18n-react';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
const Mqtt: FC = () => {
const { LL } = useI18nContext();

View File

@@ -1,26 +1,39 @@
import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Button, Checkbox, MenuItem, Grid, Typography, InputAdornment, TextField } from '@mui/material';
import { useState } from 'react';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import { Button, Checkbox, MenuItem, Grid, Typography, InputAdornment } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { createMqttSettingsValidator, validate } from '../../validators';
import type { MqttSettings } from 'types';
import * as MqttApi from 'api/mqtt';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField
} from '../../components';
import { MqttSettings } from '../../types';
import { numberValue, updateValue, useRest } from '../../utils';
import * as MqttApi from '../../api/mqtt';
ValidatedTextField,
BlockNavigation
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { useI18nContext } from '../../i18n/i18n-react';
import { createMqttSettingsValidator, validate } from 'validators';
const MqttSettingsForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<MqttSettings>({
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<MqttSettings>({
read: MqttApi.readMqttSettings,
update: MqttApi.updateMqttSettings
});
@@ -29,7 +42,7 @@ const MqttSettingsForm: FC = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setData);
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue);
const content = () => {
if (!data) {
@@ -40,7 +53,7 @@ const MqttSettingsForm: FC = () => {
try {
setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data);
saveData();
await saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
@@ -53,7 +66,7 @@ const MqttSettingsForm: FC = () => {
label={LL.ENABLE_MQTT()}
/>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<Grid item xs={12} sm={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="host"
@@ -65,7 +78,7 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<Grid item xs={12} sm={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="port"
@@ -78,9 +91,7 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<Grid item xs={12} sm={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="base"
@@ -92,8 +103,8 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedTextField
<Grid item xs={12} sm={6}>
<TextField
name="client_id"
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'}
fullWidth
@@ -103,10 +114,8 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<ValidatedTextField
<Grid item xs={12} sm={6}>
<TextField
name="username"
label={LL.USERNAME(0)}
fullWidth
@@ -116,7 +125,7 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<Grid item xs={12} sm={6}>
<ValidatedPasswordField
name="password"
label={LL.PASSWORD()}
@@ -127,9 +136,7 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<Grid item xs={12} sm={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="keep_alive"
@@ -145,8 +152,8 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedTextField
<Grid item xs={12} sm={6}>
<TextField
name="mqtt_qos"
label="QoS"
value={data.mqtt_qos}
@@ -159,9 +166,23 @@ const MqttSettingsForm: FC = () => {
<MenuItem value={0}>0</MenuItem>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem>
</ValidatedTextField>
</TextField>
</Grid>
{data.rootCA !== undefined && (
<Grid item xs={12} sm={6}>
<ValidatedPasswordField
name="rootCA"
label={LL.CERT()}
fullWidth
variant="outlined"
value={data.rootCA}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
)}
</Grid>
<BlockFormControlLabel
control={<Checkbox name="clean_session" checked={data.clean_session} onChange={updateFormValue} />}
label={LL.MQTT_CLEAN_SESSION()}
@@ -174,7 +195,7 @@ const MqttSettingsForm: FC = () => {
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.FORMATTING()}
</Typography>
<ValidatedTextField
<TextField
name="nested_format"
label={LL.MQTT_FORMAT()}
value={data.nested_format}
@@ -186,13 +207,20 @@ const MqttSettingsForm: FC = () => {
>
<MenuItem value={1}>{LL.MQTT_NEST_1()}</MenuItem>
<MenuItem value={2}>{LL.MQTT_NEST_2()}</MenuItem>
</ValidatedTextField>
</TextField>
<BlockFormControlLabel
control={<Checkbox name="send_response" checked={data.send_response} onChange={updateFormValue} />}
label={LL.MQTT_RESPONSE()}
/>
{!data.ha_enabled && (
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid
container
rowSpacing={-1}
spacing={1}
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid item>
<BlockFormControlLabel
control={<Checkbox name="publish_single" checked={data.publish_single} onChange={updateFormValue} />}
@@ -215,43 +243,62 @@ const MqttSettingsForm: FC = () => {
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item>
<BlockFormControlLabel
sx={{ pb: 1 }}
control={<Checkbox name="ha_enabled" checked={data.ha_enabled} onChange={updateFormValue} />}
label={LL.MQTT_PUBLISH_TEXT_3()}
/>
</Grid>
{data.ha_enabled && (
<>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item>
<ValidatedTextField
name="discovery_prefix"
label={LL.MQTT_PUBLISH_TEXT_4()}
fullWidth
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item>
<ValidatedTextField
name="entity_format"
label={LL.MQTT_ENTITY_FORMAT()}
value={data.entity_format}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
</ValidatedTextField>
</Grid>
<Grid
container
sx={{ pl: 1 }}
spacing={1}
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="discovery_type"
label={LL.MQTT_PUBLISH_TEXT_5()}
value={data.discovery_type}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>Home Assistant</MenuItem>
<MenuItem value={1}>Domoticz</MenuItem>
</TextField>
</Grid>
</>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="discovery_prefix"
label={LL.MQTT_PUBLISH_TEXT_4()}
fullWidth
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<TextField
name="entity_format"
label={LL.MQTT_ENTITY_FORMAT()}
value={data.entity_format}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>{LL.MQTT_ENTITY_FORMAT_0()}</MenuItem>
<MenuItem value={1}>{LL.MQTT_ENTITY_FORMAT_1()}</MenuItem>
<MenuItem value={2}>{LL.MQTT_ENTITY_FORMAT_2()}</MenuItem>
</TextField>
</Grid>
</Grid>
)}
</Grid>
)}
@@ -259,11 +306,11 @@ const MqttSettingsForm: FC = () => {
{LL.MQTT_PUBLISH_INTERVALS()}&nbsp;(0=auto)
</Typography>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6} sm={4}>
<Grid item xs={12} sm={6} md={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_heartbeat"
label={LL.MQTT_INT_HEARTBEAT()}
label="Heartbeat"
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}}
@@ -275,9 +322,8 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6} sm={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_boiler"
label={LL.MQTT_INT_BOILER()}
InputProps={{
@@ -291,9 +337,8 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6} sm={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_thermostat"
label={LL.MQTT_INT_THERMOSTATS()}
InputProps={{
@@ -307,9 +352,8 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6} sm={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_solar"
label={LL.MQTT_INT_SOLAR()}
InputProps={{
@@ -323,9 +367,8 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6} sm={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_mixer"
label={LL.MQTT_INT_MIXER()}
InputProps={{
@@ -339,9 +382,8 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6} sm={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_sensor"
label={LL.TEMP_SENSORS()}
InputProps={{
@@ -355,9 +397,8 @@ const MqttSettingsForm: FC = () => {
margin="normal"
/>
</Grid>
<Grid item xs={6} sm={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
<Grid item xs={12} sm={6} md={4}>
<TextField
name="publish_time_other"
InputProps={{
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
@@ -372,24 +413,38 @@ const MqttSettingsForm: FC = () => {
/>
</Grid>
</Grid>
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
{LL.SAVE()}
</Button>
</ButtonRow>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={loadData}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
disabled={saving}
variant="contained"
color="info"
type="submit"
onClick={validateAndSubmit}
>
{LL.APPLY_CHANGES(dirtyFlags.length)}
</Button>
</ButtonRow>
)}
</>
);
};
return (
<SectionContent title={LL.SETTINGS_OF('MQTT')} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);

View File

@@ -1,18 +1,18 @@
import { FC } from 'react';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import RefreshIcon from '@mui/icons-material/Refresh';
import ReportIcon from '@mui/icons-material/Report';
import SpeakerNotesOffIcon from '@mui/icons-material/SpeakerNotesOff';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, useTheme } from '@mui/material';
import { useRequest } from 'alova';
import type { Theme } from '@mui/material';
import type { FC } from 'react';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { MqttStatus, MqttDisconnectReason } from '../../types';
import * as MqttApi from '../../api/mqtt';
import { useRest } from '../../utils';
import { useI18nContext } from '../../i18n/i18n-react';
import type { MqttStatus } from 'types';
import * as MqttApi from 'api/mqtt';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { MqttDisconnectReason } from 'types';
export const mqttStatusHighlight = ({ enabled, connected }: MqttStatus, theme: Theme) => {
if (!enabled) {
@@ -26,7 +26,6 @@ export const mqttStatusHighlight = ({ enabled, connected }: MqttStatus, theme: T
export const mqttPublishHighlight = ({ mqtt_fails }: MqttStatus, theme: Theme) => {
if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main;
@@ -39,7 +38,7 @@ export const mqttQueueHighlight = ({ mqtt_queued }: MqttStatus, theme: Theme) =>
};
const MqttStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<MqttStatus>({ read: MqttApi.readMqttStatus });
const { data: data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
const { LL } = useI18nContext();
@@ -69,10 +68,8 @@ const MqttStatusForm: FC = () => {
return 'Malformed credentials';
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return 'Not authorized';
case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE:
return 'Device out of memory';
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return 'Server fingerprint invalid';
return 'TSL fingerprint invalid';
default:
return 'Unknown';
}
@@ -80,53 +77,51 @@ const MqttStatusForm: FC = () => {
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
const renderConnectionStatus = () => {
return (
<>
{!data.connected && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.DISCONNECT_REASON()} secondary={disconnectReason(data)} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
<AutoAwesomeMotionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
};
const renderConnectionStatus = () => (
<>
{!data.connected && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.DISCONNECT_REASON()} secondary={disconnectReason(data)} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
<AutoAwesomeMotionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
return (
<>

View File

@@ -1,17 +1,16 @@
import React, { FC, useCallback, useContext, useState } from 'react';
import { Navigate, Routes, Route, useNavigate } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import { WiFiNetwork } from '../../types';
import { AuthenticatedContext } from '../../contexts/authentication';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import NetworkStatusForm from './NetworkStatusForm';
import WiFiNetworkScanner from './WiFiNetworkScanner';
import { useCallback, useContext, useState } from 'react';
import { Navigate, Routes, Route, useNavigate } from 'react-router-dom';
import NetworkSettingsForm from './NetworkSettingsForm';
import NetworkStatusForm from './NetworkStatusForm';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import WiFiNetworkScanner from './WiFiNetworkScanner';
import type { FC } from 'react';
import { useI18nContext } from '../../i18n/i18n-react';
import type { WiFiNetwork } from 'types';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
const NetworkConnection: FC = () => {
const { LL } = useI18nContext();

View File

@@ -1,6 +1,9 @@
import { FC, useContext, useEffect, useState } from 'react';
import { useSnackbar } from 'notistack';
import CancelIcon from '@mui/icons-material/Cancel';
import DeleteIcon from '@mui/icons-material/Delete';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import WarningIcon from '@mui/icons-material/Warning';
import {
Avatar,
Button,
@@ -12,15 +15,22 @@ import {
ListItemSecondaryAction,
ListItemText,
Typography,
InputAdornment
InputAdornment,
TextField
} from '@mui/material';
// eslint-disable-next-line import/named
import { updateState, useRequest } from 'alova';
import { useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import RestartMonitor from '../system/RestartMonitor';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import DeleteIcon from '@mui/icons-material/Delete';
import SaveIcon from '@mui/icons-material/Save';
import LockIcon from '@mui/icons-material/Lock';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import type { NetworkSettings } from 'types';
import * as NetworkApi from 'api/network';
import * as SystemApi from 'api/system';
import {
BlockFormControlLabel,
ButtonRow,
@@ -28,42 +38,52 @@ import {
SectionContent,
ValidatedPasswordField,
ValidatedTextField,
MessageBox
} from '../../components';
import { NetworkSettings } from '../../types';
import * as NetworkApi from '../../api/network';
import { numberValue, updateValue, useRest } from '../../utils';
import * as EMSESP from '../../project/api';
MessageBox,
BlockNavigation
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
import { ValidateFieldsError } from 'async-validator';
import { validate } from '../../validators';
import { createNetworkSettingsValidator } from '../../validators/network';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { useI18nContext } from '../../i18n/i18n-react';
import RestartMonitor from '../system/RestartMonitor';
import { validate } from 'validators';
import { createNetworkSettingsValidator } from 'validators/network';
const WiFiSettingsForm: FC = () => {
const { LL } = useI18nContext();
const { enqueueSnackbar } = useSnackbar();
const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
const [initialized, setInitialized] = useState(false);
const [restarting, setRestarting] = useState(false);
const { loadData, saving, data, setData, saveData, errorMessage, restartNeeded } = useRest<NetworkSettings>({
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage,
restartNeeded
} = useRest<NetworkSettings>({
read: NetworkApi.readNetworkSettings,
update: NetworkApi.updateNetworkSettings
});
const { send: restartCommand } = useRequest(SystemApi.restart(), {
immediate: false
});
useEffect(() => {
if (!initialized && data) {
if (selectedNetwork) {
setData({
updateState('networkSettings', (current_data) => ({
ssid: selectedNetwork.ssid,
password: '',
hostname: data?.hostname,
hostname: current_data?.hostname,
static_ip_config: false,
enableIPv6: false,
bandwidth20: false,
@@ -72,13 +92,13 @@ const WiFiSettingsForm: FC = () => {
enableMDNS: true,
enableCORS: false,
CORSOrigin: '*'
});
}));
}
setInitialized(true);
}
}, [initialized, setInitialized, data, setData, selectedNetwork]);
}, [initialized, setInitialized, data, selectedNetwork]);
const updateFormValue = updateValue(setData);
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -93,19 +113,17 @@ const WiFiSettingsForm: FC = () => {
try {
setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data);
saveData();
await saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
const restart = async () => {
try {
await EMSESP.restart();
setRestarting(true);
} catch (error) {
enqueueSnackbar(LL.PROBLEM_UPDATING(), { variant: 'error' });
}
await restartCommand().catch((error) => {
toast.error(error.message);
});
setRestarting(true);
};
return (
@@ -124,7 +142,7 @@ const WiFiSettingsForm: FC = () => {
secondary={'Security: ' + networkSecurityMode(selectedNetwork) + ', Ch: ' + selectedNetwork.channel}
/>
<ListItemSecondaryAction>
<IconButton aria-label="Manual Config" onClick={deselectNetwork}>
<IconButton onClick={deselectNetwork}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
@@ -154,7 +172,6 @@ const WiFiSettingsForm: FC = () => {
margin="normal"
/>
)}
<ValidatedTextField
fieldErrors={fieldErrors}
name="tx_power"
@@ -169,21 +186,17 @@ const WiFiSettingsForm: FC = () => {
type="number"
margin="normal"
/>
<BlockFormControlLabel
control={<Checkbox name="nosleep" checked={data.nosleep} onChange={updateFormValue} />}
label={LL.NETWORK_DISABLE_SLEEP()}
/>
<BlockFormControlLabel
control={<Checkbox name="bandwidth20" checked={data.bandwidth20} onChange={updateFormValue} />}
label={LL.NETWORK_LOW_BAND()}
/>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
{LL.GENERAL_OPTIONS()}
</Typography>
<ValidatedTextField
fieldErrors={fieldErrors}
name="hostname"
@@ -194,19 +207,16 @@ const WiFiSettingsForm: FC = () => {
onChange={updateFormValue}
margin="normal"
/>
<BlockFormControlLabel
control={<Checkbox name="enableMDNS" checked={data.enableMDNS} onChange={updateFormValue} />}
label={LL.NETWORK_USE_DNS()}
/>
<BlockFormControlLabel
control={<Checkbox name="enableCORS" checked={data.enableCORS} onChange={updateFormValue} />}
label={LL.NETWORK_ENABLE_CORS()}
/>
{data.enableCORS && (
<ValidatedTextField
fieldErrors={fieldErrors}
<TextField
name="CORSOrigin"
label={LL.NETWORK_CORS_ORIGIN()}
fullWidth
@@ -216,12 +226,10 @@ const WiFiSettingsForm: FC = () => {
margin="normal"
/>
)}
<BlockFormControlLabel
control={<Checkbox name="enableIPv6" checked={data.enableIPv6} onChange={updateFormValue} />}
label={LL.NETWORK_ENABLE_IPV6()}
/>
<BlockFormControlLabel
control={<Checkbox name="static_ip_config" checked={data.static_ip_config} onChange={updateFormValue} />}
label={LL.NETWORK_FIXED_IP()}
@@ -287,17 +295,28 @@ const WiFiSettingsForm: FC = () => {
</Button>
</MessageBox>
)}
{!restartNeeded && (
{!restartNeeded && dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<SaveIcon />}
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={loadData}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
disabled={saving}
variant="contained"
color="info"
type="submit"
onClick={validateAndSubmit}
>
{LL.SAVE()}
{LL.APPLY_CHANGES(dirtyFlags.length)}
</Button>
</ButtonRow>
)}
@@ -307,6 +326,7 @@ const WiFiSettingsForm: FC = () => {
return (
<SectionContent title={LL.SETTINGS_OF(LL.NETWORK(1))} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{restarting ? <RestartMonitor /> : content()}
</SectionContent>
);

View File

@@ -1,20 +1,21 @@
import { FC } from 'react';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import WifiIcon from '@mui/icons-material/Wifi';
import DnsIcon from '@mui/icons-material/Dns';
import RefreshIcon from '@mui/icons-material/Refresh';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
import WifiIcon from '@mui/icons-material/Wifi';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, useTheme } from '@mui/material';
import { useRequest } from 'alova';
import type { Theme } from '@mui/material';
import type { FC } from 'react';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { NetworkConnectionStatus, NetworkStatus } from '../../types';
import * as NetworkApi from '../../api/network';
import { useRest } from '../../utils';
import type { NetworkStatus } from 'types';
import * as NetworkApi from 'api/network';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
import { NetworkConnectionStatus } from 'types';
const isConnected = ({ status }: NetworkStatus) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
@@ -58,7 +59,7 @@ const IPs = (status: NetworkStatus) => {
};
const NetworkStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<NetworkStatus>({ read: NetworkApi.readNetworkStatus });
const { data: data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus);
const { LL } = useI18nContext();
@@ -77,7 +78,7 @@ const NetworkStatusForm: FC = () => {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED();
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST();
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -89,7 +90,7 @@ const NetworkStatusForm: FC = () => {
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (

View File

@@ -1,5 +1,5 @@
import { createContext } from 'react';
import { WiFiNetwork } from '../../types';
import type { WiFiNetwork } from 'types';
export interface WiFiConnectionContextValue {
selectedNetwork?: WiFiNetwork;

View File

@@ -1,88 +1,52 @@
import { useEffect, FC, useState, useCallback, useRef } from 'react';
import { useSnackbar } from 'notistack';
import { Button } from '@mui/material';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import * as NetworkApi from '../../api/network';
import { WiFiNetwork, WiFiNetworkList } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { Button } from '@mui/material';
// eslint-disable-next-line import/named
import { updateState, useRequest } from 'alova';
import { useState, useRef } from 'react';
import WiFiNetworkSelector from './WiFiNetworkSelector';
import type { FC } from 'react';
import * as NetworkApi from 'api/network';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
const NUM_POLLS = 10;
const POLLING_FREQUENCY = 500;
const compareNetworks = (network1: WiFiNetwork, network2: WiFiNetwork) => {
if (network1.rssi < network2.rssi) return 1;
if (network1.rssi > network2.rssi) return -1;
return 0;
};
const POLLING_FREQUENCY = 1000;
const WiFiNetworkScanner: FC = () => {
const { LL } = useI18nContext();
const { enqueueSnackbar } = useSnackbar();
const pollCount = useRef(0);
const [networkList, setNetworkList] = useState<WiFiNetworkList>();
const { LL } = useI18nContext();
const [errorMessage, setErrorMessage] = useState<string>();
const finishedWithError = useCallback(
(message: string) => {
enqueueSnackbar(message, { variant: 'error' });
setNetworkList(undefined);
setErrorMessage(message);
},
[enqueueSnackbar]
);
const { send: scanNetworks, onComplete: onCompleteScanNetworks } = useRequest(NetworkApi.scanNetworks); // is called on page load to start network scan
const {
data: networkList,
send: getNetworkList,
onSuccess: onSuccessNetworkList
} = useRequest(NetworkApi.listNetworks, {
immediate: false
});
const pollNetworkList = useCallback(async () => {
try {
const response = await NetworkApi.listNetworks();
if (response.status === 202) {
const completedPollCount = pollCount.current + 1;
if (completedPollCount < NUM_POLLS) {
pollCount.current = completedPollCount;
setTimeout(pollNetworkList, POLLING_FREQUENCY);
} else {
finishedWithError(LL.PROBLEM_LOADING());
}
onSuccessNetworkList((event) => {
if (!event.data) {
const completedPollCount = pollCount.current + 1;
if (completedPollCount < NUM_POLLS) {
pollCount.current = completedPollCount;
setTimeout(getNetworkList, POLLING_FREQUENCY);
} else {
const newNetworkList = response.data;
newNetworkList.networks.sort(compareNetworks);
setNetworkList(newNetworkList);
}
} catch (error) {
if (error.response) {
finishedWithError(LL.PROBLEM_LOADING() + ' ' + error.response?.data.message);
} else {
finishedWithError(LL.PROBLEM_LOADING());
setErrorMessage(LL.PROBLEM_LOADING());
pollCount.current = 0;
}
}
}, [finishedWithError, LL]);
});
const startNetworkScan = useCallback(async () => {
onCompleteScanNetworks(() => {
pollCount.current = 0;
setNetworkList(undefined);
setErrorMessage(undefined);
try {
await NetworkApi.scanNetworks();
setTimeout(pollNetworkList, POLLING_FREQUENCY);
} catch (error) {
if (error.response) {
finishedWithError(LL.PROBLEM_LOADING() + ' ' + error.response?.data.message);
} else {
finishedWithError(LL.PROBLEM_LOADING());
}
}
}, [finishedWithError, pollNetworkList, LL]);
useEffect(() => {
startNetworkScan();
}, [startNetworkScan]);
updateState('listNetworks', () => undefined);
void getNetworkList();
});
const renderNetworkScanner = () => {
if (!networkList) {
@@ -99,7 +63,7 @@ const WiFiNetworkScanner: FC = () => {
startIcon={<PermScanWifiIcon />}
variant="outlined"
color="secondary"
onClick={startNetworkScan}
onClick={scanNetworks}
disabled={!errorMessage && !networkList}
>
{LL.SCAN_AGAIN()}&hellip;

View File

@@ -1,18 +1,16 @@
import { FC, useContext } from 'react';
import { Avatar, Badge, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText } from '@mui/material';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import WifiIcon from '@mui/icons-material/Wifi';
import { MessageBox } from '../../components';
import { WiFiEncryptionType, WiFiNetwork, WiFiNetworkList } from '../../types';
import { Avatar, Badge, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText } from '@mui/material';
import { useContext } from 'react';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import type { FC } from 'react';
import type { WiFiNetwork, WiFiNetworkList } from 'types';
import { MessageBox } from 'components';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
import { WiFiEncryptionType } from 'types';
interface WiFiNetworkSelectorProps {
networkList: WiFiNetworkList;
@@ -35,8 +33,12 @@ export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
return 'WPA2 Enterprise';
case WiFiEncryptionType.WIFI_AUTH_OPEN:
return 'None';
case WiFiEncryptionType.WIFI_AUTH_WPA3_PSK:
return 'WPA3';
case WiFiEncryptionType.WIFI_AUTH_WPA2_WPA3_PSK:
return 'WPA2/WPA3';
default:
return 'Unknown';
return 'Unknown: ' + encryption_type;
}
};
@@ -45,24 +47,22 @@ const WiFiNetworkSelector: FC<WiFiNetworkSelectorProps> = ({ networkList }) => {
const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => {
return (
<ListItem key={network.bssid} button onClick={() => wifiConnectionContext.selectNetwork(network)}>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={'Security: ' + networkSecurityMode(network) + ', Ch: ' + network.channel}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'db'}>
<WifiIcon />
</Badge>
</ListItemIcon>
</ListItem>
);
};
const renderNetwork = (network: WiFiNetwork) => (
<ListItem key={network.bssid} onClick={() => wifiConnectionContext.selectNetwork(network)}>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={'Security: ' + networkSecurityMode(network) + ', Ch: ' + network.channel}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'db'}>
<WifiIcon />
</Badge>
</ListItemIcon>
</ListItem>
);
if (networkList.networks.length === 0) {
return <MessageBox mt={2} mb={1} message={LL.NETWORK_NO_WIFI()} level="info" />;

View File

@@ -1,28 +1,48 @@
import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Button, Checkbox, MenuItem } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { validate } from '../../validators';
import { BlockFormControlLabel, ButtonRow, FormLoader, SectionContent, ValidatedTextField } from '../../components';
import { NTPSettings } from '../../types';
import { updateValue, useRest } from '../../utils';
import * as NTPApi from '../../api/ntp';
// eslint-disable-next-line import/named
import { updateState } from 'alova';
import { useState } from 'react';
import { selectedTimeZone, timeZoneSelectItems, TIME_ZONES } from './TZ';
import { NTP_SETTINGS_VALIDATOR } from '../../validators/ntp';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import { useI18nContext } from '../../i18n/i18n-react';
import type { NTPSettings } from 'types';
import * as NTPApi from 'api/ntp';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedTextField,
BlockNavigation
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValueDirty, useRest } from 'utils';
import { validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
const NTPSettingsForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<NTPSettings>({
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<NTPSettings>({
read: NTPApi.readNTPSettings,
update: NTPApi.updateNTPSettings
});
const { LL } = useI18nContext();
const updateFormValue = updateValue(setData);
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
@@ -35,18 +55,20 @@ const NTPSettingsForm: FC = () => {
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
saveData();
await saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
setData({
...data,
updateFormValue(event);
updateState('ntpSettings', (settings) => ({
...settings,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
});
}));
};
return (
@@ -79,24 +101,37 @@ const NTPSettingsForm: FC = () => {
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
{timeZoneSelectItems()}
</ValidatedTextField>
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
{LL.SAVE()}
</Button>
</ButtonRow>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={loadData}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
disabled={saving}
variant="contained"
color="info"
type="submit"
onClick={validateAndSubmit}
>
{LL.APPLY_CHANGES(dirtyFlags.length)}
</Button>
</ButtonRow>
)}
</>
);
};
return (
<SectionContent title={LL.SETTINGS_OF('NTP')} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);

View File

@@ -1,6 +1,9 @@
import { FC, useContext, useState } from 'react';
import { useSnackbar } from 'notistack';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
import DnsIcon from '@mui/icons-material/Dns';
import RefreshIcon from '@mui/icons-material/Refresh';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
import UpdateIcon from '@mui/icons-material/Update';
import {
Avatar,
Box,
@@ -15,24 +18,24 @@ import {
ListItemAvatar,
ListItemText,
TextField,
Theme,
useTheme,
Typography
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
import UpdateIcon from '@mui/icons-material/Update';
import DnsIcon from '@mui/icons-material/Dns';
import CancelIcon from '@mui/icons-material/Cancel';
import { useRequest } from 'alova';
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import type { Theme } from '@mui/material';
import type { FC } from 'react';
import * as NTPApi from '../../api/ntp';
import { NTPStatus, NTPSyncStatus } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { extractErrorMessage, formatDateTime, formatLocalDateTime, useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
import type { NTPStatus } from 'types';
import { dialogStyle } from 'CustomTheme';
import * as NTPApi from 'api/ntp';
import { ButtonRow, FormLoader, SectionContent } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
import { NTPSyncStatus } from 'types';
import { formatDateTime, formatLocalDateTime } from 'utils';
export const isNtpActive = ({ status }: NTPStatus) => status === NTPSyncStatus.NTP_ACTIVE;
export const isNtpEnabled = ({ status }: NTPStatus) => status !== NTPSyncStatus.NTP_DISABLED;
@@ -51,15 +54,21 @@ export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => {
};
const NTPStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<NTPStatus>({ read: NTPApi.readNTPStatus });
const { data: data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const { enqueueSnackbar } = useSnackbar();
const { me } = useContext(AuthenticatedContext);
const { LL } = useI18nContext();
const { send: updateTime } = useRequest((local_time) => NTPApi.updateTime(local_time), {
immediate: false
});
NTPApi.updateTime;
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value);
const openSetTime = () => {
@@ -72,7 +81,7 @@ const NTPStatusForm: FC = () => {
const ntpStatus = ({ status }: NTPStatus) => {
switch (status) {
case NTPSyncStatus.NTP_DISABLED:
return LL.DISABLED(0);
return LL.NOT_ENABLED();
case NTPSyncStatus.NTP_INACTIVE:
return LL.INACTIVE(0);
case NTPSyncStatus.NTP_ACTIVE:
@@ -84,22 +93,23 @@ const NTPStatusForm: FC = () => {
const configureTime = async () => {
setProcessing(true);
try {
await NTPApi.updateTime({
local_time: formatLocalDateTime(new Date(localTime))
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) })
.then(async () => {
toast.success(LL.TIME_SET());
setSettingTime(false);
await loadData();
})
.catch(() => {
toast.error(LL.PROBLEM_UPDATING());
})
.finally(() => {
setProcessing(false);
});
enqueueSnackbar(LL.TIME_SET(), { variant: 'success' });
setSettingTime(false);
loadData();
} catch (error) {
enqueueSnackbar(extractErrorMessage(error, LL.PROBLEM_UPDATING()), { variant: 'error' });
} finally {
setProcessing(false);
}
};
const renderSetTimeDialog = () => (
<Dialog open={settingTime} onClose={() => setSettingTime(false)}>
<Dialog sx={dialogStyle} open={settingTime} onClose={() => setSettingTime(false)}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
@@ -127,9 +137,8 @@ const NTPStatusForm: FC = () => {
onClick={configureTime}
disabled={processing}
color="primary"
autoFocus
>
{LL.SAVE()}
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
@@ -137,7 +146,7 @@ const NTPStatusForm: FC = () => {
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (

View File

@@ -1,15 +1,14 @@
import React, { FC, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import { AuthenticatedContext } from '../../contexts/authentication';
import NTPStatusForm from './NTPStatusForm';
import { useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import NTPSettingsForm from './NTPSettingsForm';
import NTPStatusForm from './NTPStatusForm';
import type { FC } from 'react';
import { useI18nContext } from '../../i18n/i18n-react';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
const NetworkTime: FC = () => {
const { LL } = useI18nContext();

View File

@@ -1,4 +1,4 @@
import { FC, useCallback, useState, useEffect } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import {
Dialog,
DialogTitle,
@@ -10,16 +10,15 @@ import {
TextField,
Button
} from '@mui/material';
import { useRequest } from 'alova';
import { useEffect } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import type { FC } from 'react';
import { dialogStyle } from 'CustomTheme';
import * as SecurityApi from 'api/security';
import { MessageBox } from 'components';
import { extractErrorMessage } from '../../utils';
import { useSnackbar } from 'notistack';
import { MessageBox } from '../../components';
import * as SecurityApi from '../../api/security';
import { Token } from '../../types';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
interface GenerateTokenProps {
username?: string;
@@ -27,30 +26,22 @@ interface GenerateTokenProps {
}
const GenerateToken: FC<GenerateTokenProps> = ({ username, onClose }) => {
const [token, setToken] = useState<Token>();
const { LL } = useI18nContext();
const open = !!username;
const { LL } = useI18nContext();
const { enqueueSnackbar } = useSnackbar();
const getToken = useCallback(async () => {
try {
setToken((await SecurityApi.generateToken(username)).data);
} catch (error) {
enqueueSnackbar(extractErrorMessage(error, LL.PROBLEM_UPDATING()), { variant: 'error' });
}
}, [username, enqueueSnackbar, LL]);
const { data: token, send: generateToken } = useRequest(SecurityApi.generateToken(username), {
immediate: false
});
useEffect(() => {
if (open) {
getToken();
void generateToken();
}
}, [open, getToken]);
}, [open, generateToken]);
return (
<Dialog onClose={onClose} aria-labelledby="generate-token-dialog-title" open={!!username} fullWidth maxWidth="sm">
<DialogTitle id="generate-token-dialog-title">{LL.ACCESS_TOKEN_FOR() + ' ' + username}</DialogTitle>
<Dialog sx={dialogStyle} onClose={onClose} open={!!username} fullWidth maxWidth="sm">
<DialogTitle>{LL.ACCESS_TOKEN_FOR() + ' ' + username}</DialogTitle>
<DialogContent dividers>
{token ? (
<>

View File

@@ -1,41 +1,42 @@
import { FC, useContext, useState } from 'react';
import { Button, IconButton, Box } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import DeleteIcon from '@mui/icons-material/Delete';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import EditIcon from '@mui/icons-material/Edit';
import CancelIcon from '@mui/icons-material/Cancel';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import WarningIcon from '@mui/icons-material/Warning';
import { Button, IconButton, Box } from '@mui/material';
import { Table } from '@table-library/react-table-library/table';
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table';
import * as SecurityApi from '../../api/security';
import { SecuritySettings, User } from '../../types';
import { ButtonRow, FormLoader, MessageBox, SectionContent } from '../../components';
import { createUserValidator } from '../../validators';
import { useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
import { useI18nContext } from '../../i18n/i18n-react';
import { useContext, useState } from 'react';
import { unstable_useBlocker as useBlocker } from 'react-router-dom';
import GenerateToken from './GenerateToken';
import UserForm from './UserForm';
import type { FC } from 'react';
import type { SecuritySettings, User } from 'types';
import * as SecurityApi from 'api/security';
import { ButtonRow, FormLoader, MessageBox, SectionContent, BlockNavigation } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useRest } from 'utils';
import { createUserValidator } from 'validators';
const ManageUsersForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<SecuritySettings>({
const { loadData, saveData, saving, data, updateDataValue, errorMessage } = useRest<SecuritySettings>({
read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings
});
const [user, setUser] = useState<User>();
const [creating, setCreating] = useState<boolean>(false);
const [changed, setChanged] = useState<number>(0);
const [generatingToken, setGeneratingToken] = useState<string>();
const authenticatedContext = useContext(AuthenticatedContext);
const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext();
const table_theme = useTheme({
@@ -51,8 +52,7 @@ const ManageUsersForm: FC = () => {
color: #90CAF9;
.th {
padding: 8px;
height: 42px;
font-weight: 500;
height: 36px;
border-bottom: 1px solid #565656;
}
`,
@@ -62,7 +62,6 @@ const ManageUsersForm: FC = () => {
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
@@ -89,7 +88,8 @@ const ManageUsersForm: FC = () => {
const removeUser = (toRemove: User) => {
const users = data.users.filter((u) => u.username !== toRemove.username);
setData({ ...data, users });
updateDataValue({ ...data, users });
setChanged(changed + 1);
};
const createUser = () => {
@@ -112,9 +112,10 @@ const ManageUsersForm: FC = () => {
const doneEditingUser = () => {
if (user) {
const users = [...data.users.filter((u) => u.username !== user.username), user];
setData({ ...data, users });
const users = [...data.users.filter((u: { username: string }) => u.username !== user.username), user];
updateDataValue({ ...data, users });
setUser(undefined);
setChanged(changed + 1);
}
};
@@ -128,7 +129,13 @@ const ManageUsersForm: FC = () => {
const onSubmit = async () => {
await saveData();
authenticatedContext.refresh();
await authenticatedContext.refresh();
setChanged(0);
};
const onCancelSubmit = async () => {
await loadData();
setChanged(0);
};
const user_table = data.users.map((u) => ({ ...u, id: u.username }));
@@ -154,15 +161,14 @@ const ManageUsersForm: FC = () => {
<IconButton
size="small"
disabled={!authenticatedContext.me.admin}
aria-label="Generate Token"
onClick={() => generateToken(u.username)}
>
<VpnKeyIcon />
</IconButton>
<IconButton size="small" aria-label="Delete" onClick={() => removeUser(u)}>
<IconButton size="small" onClick={() => removeUser(u)}>
<DeleteIcon />
</IconButton>
<IconButton size="small" aria-label="Edit" onClick={() => editUser(u)}>
<IconButton size="small" onClick={() => editUser(u)}>
<EditIcon />
</IconButton>
</Cell>
@@ -177,16 +183,30 @@ const ManageUsersForm: FC = () => {
<Box display="flex" flexWrap="wrap">
<Box flexGrow={1} sx={{ '& button': { mt: 2 } }}>
<Button
startIcon={<SaveIcon />}
disabled={saving || noAdminConfigured()}
variant="outlined"
color="primary"
type="submit"
onClick={onSubmit}
>
{LL.SAVE()}
</Button>
{changed !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={onCancelSubmit}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
disabled={saving || noAdminConfigured()}
variant="contained"
color="info"
type="submit"
onClick={onSubmit}
>
{LL.APPLY_CHANGES(changed)}
</Button>
</ButtonRow>
)}
</Box>
<Box flexWrap="nowrap" whiteSpace="nowrap">
@@ -213,6 +233,7 @@ const ManageUsersForm: FC = () => {
return (
<SectionContent title={LL.MANAGE_USERS()} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);

View File

@@ -1,14 +1,12 @@
import { FC } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RouterTabs, useRouterTab, useLayoutTitle } from '../../components';
import SecuritySettingsForm from './SecuritySettingsForm';
import { Navigate, Routes, Route } from 'react-router-dom';
import ManageUsersForm from './ManageUsersForm';
import SecuritySettingsForm from './SecuritySettingsForm';
import type { FC } from 'react';
import { useI18nContext } from '../../i18n/i18n-react';
import { RouterTabs, useRouterTab, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
const Security: FC = () => {
const { LL } = useI18nContext();

View File

@@ -1,29 +1,42 @@
import { FC, useContext, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Button } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { useContext, useState } from 'react';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import * as SecurityApi from '../../api/security';
import { SecuritySettings } from '../../types';
import { ButtonRow, FormLoader, MessageBox, SectionContent, ValidatedPasswordField } from '../../components';
import { SECURITY_SETTINGS_VALIDATOR, validate } from '../../validators';
import { updateValue, useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
import type { SecuritySettings } from 'types';
import * as SecurityApi from 'api/security';
import { ButtonRow, FormLoader, MessageBox, SectionContent, ValidatedPasswordField, BlockNavigation } from 'components';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from '../../i18n/i18n-react';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValueDirty, useRest } from 'utils';
import { SECURITY_SETTINGS_VALIDATOR, validate } from 'validators';
const SecuritySettingsForm: FC = () => {
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<SecuritySettings>({
const {
loadData,
saving,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<SecuritySettings>({
read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings
});
const authenticatedContext = useContext(AuthenticatedContext);
const updateFormValue = updateValue(setData);
const updateFormValue = updateValueDirty(origData, dirtyFlags, setDirtyFlags, updateDataValue);
const content = () => {
if (!data) {
@@ -54,24 +67,37 @@ const SecuritySettingsForm: FC = () => {
margin="normal"
/>
<MessageBox level="info" message={LL.SU_TEXT()} mt={1} />
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
{LL.SAVE()}
</Button>
</ButtonRow>
{dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={loadData}
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
disabled={saving}
variant="contained"
color="info"
type="submit"
onClick={validateAndSubmit}
>
{LL.APPLY_CHANGES(dirtyFlags.length)}
</Button>
</ButtonRow>
)}
</>
);
};
return (
<SectionContent title={LL.SETTINGS_OF(LL.SECURITY(1))} titleGutter>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);

View File

@@ -1,18 +1,19 @@
import { FC, useState, useEffect } from 'react';
import Schema, { ValidateFieldsError } from 'async-validator';
import CancelIcon from '@mui/icons-material/Cancel';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import SaveIcon from '@mui/icons-material/Save';
import { Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
import { useState, useEffect } from 'react';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import type { FC } from 'react';
import { BlockFormControlLabel, ValidatedPasswordField, ValidatedTextField } from '../../components';
import { User } from '../../types';
import { updateValue } from '../../utils';
import { validate } from '../../validators';
import { useI18nContext } from '../../i18n/i18n-react';
import type { User } from 'types';
import { dialogStyle } from 'CustomTheme';
import { BlockFormControlLabel, ValidatedPasswordField, ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { validate } from 'validators';
interface UserFormProps {
creating: boolean;
@@ -51,7 +52,7 @@ const UserForm: FC<UserFormProps> = ({ creating, validator, user, setUser, onDon
};
return (
<Dialog onClose={onCancelEditing} open={!!user} fullWidth maxWidth="sm">
<Dialog sx={dialogStyle} onClose={onCancelEditing} open={!!user} fullWidth maxWidth="sm">
{user && (
<>
<DialogTitle id="user-form-dialog-title">
@@ -93,9 +94,8 @@ const UserForm: FC<UserFormProps> = ({ creating, validator, user, setUser, onDon
variant="outlined"
onClick={validateAndDone}
color="primary"
autoFocus
>
{creating ? LL.ADD(0) : LL.SAVE()}
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
</DialogActions>
</>

Some files were not shown because too many files have changed in this diff Show More