Merge branch 'develop' into develop

This commit is contained in:
jc21
2026-08-28 07:57:25 +10:00
committed by GitHub
85 changed files with 3368 additions and 1991 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
with:
stale-issue-label: 'stale'
stale-pr-label: 'stale'
+72 -89
View File
@@ -1,91 +1,74 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"!**/dist/**/*"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4,
"lineWidth": 120,
"formatWithErrors": true
},
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":BUN:",
":NODE:",
[
"npm:*",
"npm:*/**"
],
":PACKAGE_WITH_PROTOCOL:",
":URL:",
":PACKAGE:",
[
"/src/*",
"/src/**"
],
[
"/**"
],
[
"#*",
"#*/**"
],
":PATH:"
]
}
}
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"useUniqueElementIds": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"performance": {
"noDelete": "off"
},
"nursery": "off",
"a11y": {
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "!**/dist/**/*"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4,
"lineWidth": 120,
"formatWithErrors": true
},
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":BUN:",
":NODE:",
["npm:*", "npm:*/**"],
":PACKAGE_WITH_PROTOCOL:",
":URL:",
":PACKAGE:",
["/src/*", "/src/**"],
["/**"],
["#*", "#*/**"],
":PATH:"
]
}
}
}
}
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"useUniqueElementIds": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"performance": {
"noDelete": "off"
},
"nursery": "off",
"a11y": {
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
}
+11 -3
View File
@@ -629,7 +629,7 @@
"full_plugin_name": "dns-tencentcloud",
"name": "Tencent Cloud",
"package_name": "certbot-dns-tencentcloud",
"version": "~=2.0.2"
"version": "~=2.1.1"
},
"timeweb": {
"credentials": "dns_timeweb_api_key = XXXXXXXXXXXXXXXXXXX",
@@ -637,7 +637,7 @@
"full_plugin_name": "dns-timeweb",
"name": "Timeweb Cloud",
"package_name": "certbot-dns-timeweb",
"version": "~=1.0.1"
"version": "~=2.0.0"
},
"transip": {
"credentials": "dns_transip_username = my_username\ndns_transip_key_file = /etc/letsencrypt/transip-rsa.key",
@@ -661,7 +661,7 @@
"full_plugin_name": "dns-websupport",
"name": "Websupport.sk",
"package_name": "certbot-dns-websupport",
"version": "~=2.0.1"
"version": "~=5.0.0"
},
"wedos": {
"credentials": "dns_wedos_user = <wedos_registration>\ndns_wedos_auth = <wapi_password>",
@@ -686,5 +686,13 @@
"name": "RcodeZero",
"package_name": "certbot-dns-rcode0",
"version": "~=0.0.0.2"
},
"lws": {
"credentials": "dns_lws_login = 123456\ndns_lws_api_key = YOUR_API_KEY",
"dependencies": "",
"full_plugin_name": "dns-lws",
"name": "LWS",
"package_name": "certbot-dns-lws",
"version": "~=1.0.0"
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
import knex from "knex";
import {configGet, configHas} from "./lib/config.js";
import { configGet, configHas } from "./lib/config.js";
let instance = null;
@@ -23,8 +23,8 @@ const generateDbConfig = () => {
user: cfg.user,
password: cfg.password,
database: cfg.name,
port: cfg.port,
...(cfg.ssl ? { ssl: cfg.ssl } : {})
port: cfg.port,
...(cfg.ssl ? { ssl: cfg.ssl } : {}),
},
migrations: {
tableName: "migrations",
@@ -37,6 +37,6 @@ const getInstance = () => {
instance = knex(generateDbConfig());
}
return instance;
}
};
export default getInstance;
+7 -11
View File
@@ -161,12 +161,12 @@ const internal2fa = {
}
const result = await verify({
token: code,
secret: auth.meta.totp_secret,
guardrails: createGuardrails({
MIN_SECRET_BYTES: 10,
}),
});
token: code,
secret: auth.meta.totp_secret,
guardrails: createGuardrails({
MIN_SECRET_BYTES: 10,
}),
});
if (!result.valid) {
throw new errs.AuthError("Invalid verification code");
@@ -288,11 +288,7 @@ const internal2fa = {
},
getUserPasswordAuth: async (userId) => {
const auth = await authModel
.query()
.where("user_id", userId)
.andWhere("type", "password")
.first();
const auth = await authModel.query().where("user_id", userId).andWhere("type", "password").first();
if (!auth) {
throw new errs.ItemNotFoundError("Auth not found");
+21 -34
View File
@@ -66,7 +66,7 @@ const internalAccessList = {
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
},
true // skip masking
true, // skip masking
);
// Audit log
@@ -180,10 +180,10 @@ const internalAccessList = {
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"],
},
true // skip masking
true, // skip masking
);
await internalAccessList.build(freshRow)
await internalAccessList.build(freshRow);
if (Number.parseInt(freshRow.proxy_host_count, 10)) {
await internalNginx.bulkGenerateConfigs("proxy_host", freshRow.proxy_hosts);
}
@@ -202,17 +202,13 @@ const internalAccessList = {
*/
get: async (access, data, skipMasking) => {
const thisData = data || {};
const accessData = await access.can("access_lists:get", thisData.id)
const accessData = await access.can("access_lists:get", thisData.id);
const query = accessListModel
.query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () {
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
"proxy_host.is_deleted",
"=",
0,
);
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
})
.where("access_list.is_deleted", 0)
.andWhere("access_list.id", thisData.id)
@@ -267,19 +263,13 @@ const internalAccessList = {
// 4. audit log
// 1. update row to be deleted
await accessListModel
.query()
.where("id", row.id)
.patch({
is_deleted: 1,
});
await accessListModel.query().where("id", row.id).patch({
is_deleted: 1,
});
// 2. update any proxy hosts that were using it (ignoring permissions)
if (row.proxy_hosts) {
await proxyHostModel
.query()
.where("access_list_id", "=", row.id)
.patch({ access_list_id: 0 });
await proxyHostModel.query().where("access_list_id", "=", row.id).patch({ access_list_id: 0 });
// 3. reconfigure those hosts, then reload nginx
// set the access_list_id to zero for these items
@@ -325,11 +315,7 @@ const internalAccessList = {
.query()
.select("access_list.*", accessListModel.raw("COUNT(proxy_host.id) as proxy_host_count"))
.leftJoin("proxy_host", function () {
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn(
"proxy_host.is_deleted",
"=",
0,
);
this.on("proxy_host.access_list_id", "=", "access_list.id").andOn("proxy_host.is_deleted", "=", 0);
})
.where("access_list.is_deleted", 0)
.groupBy("access_list.id")
@@ -371,10 +357,7 @@ const internalAccessList = {
* @returns {Promise}
*/
getCount: async (userId, visibility) => {
const query = accessListModel
.query()
.count("id as count")
.where("is_deleted", 0);
const query = accessListModel.query().count("id as count").where("is_deleted", 0);
if (visibility !== "all") {
query.andWhere("owner_user_id", userId);
@@ -436,20 +419,24 @@ const internalAccessList = {
}
// 2. create empty access file
fs.writeFileSync(htpasswdFile, '', {encoding: 'utf8'});
fs.writeFileSync(htpasswdFile, "", { encoding: "utf8" });
// 3. generate password for each user
if (list.items.length) {
await new Promise((resolve, reject) => {
batchflow(list.items).sequential()
batchflow(list.items)
.sequential()
.each((_i, item, next) => {
if (item.password?.length) {
logger.info(`Adding: ${item.username}`);
utils.execFile('openssl', ['passwd', '-apr1', item.password])
utils
.execFile("openssl", ["passwd", "-apr1", item.password])
.then((res) => {
try {
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {encoding: 'utf8'});
fs.appendFileSync(htpasswdFile, `${item.username}:${res}\n`, {
encoding: "utf8",
});
} catch (err) {
reject(err);
}
@@ -471,7 +458,7 @@ const internalAccessList = {
});
});
}
}
}
},
};
export default internalAccessList;
+1 -6
View File
@@ -3,7 +3,6 @@ import { castJsonIfNeed } from "../lib/helpers.js";
import auditLogModel from "../models/audit-log.js";
const internalAuditLog = {
/**
* All logs
*
@@ -46,11 +45,7 @@ const internalAuditLog = {
get: async (access, data) => {
await access.can("auditlog:list");
const query = auditLogModel
.query()
.andWhere("id", data.id)
.allowGraph("[user]")
.first();
const query = auditLogModel.query().andWhere("id", data.id).allowGraph("[user]").first();
if (typeof data.expand !== "undefined" && data.expand !== null) {
query.withGraphFetched(`[${data.expand.join(", ")}]`);
+25 -37
View File
@@ -54,9 +54,7 @@ const internalDeadHost = {
thisData.advanced_config = "";
}
const row = await deadHostModel.query()
.insertAndFetch(thisData)
.then(utils.omitRow(omissions()));
const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
// Add to audit log
await internalAuditLog.add(access, {
@@ -153,12 +151,8 @@ const internalDeadHost = {
thisData = internalHost.cleanSslHstsData(thisData, row);
// do the row update
await deadHostModel
.query()
.where({id: data.id})
.patch(data);
await deadHostModel.query().where({ id: data.id }).patch(data);
// Add to audit log
await internalAuditLog.add(access, {
@@ -168,15 +162,18 @@ const internalDeadHost = {
meta: thisData,
});
const thisRow = await internalDeadHost
.get(access, {
id: thisData.id,
expand: ["owner", "certificate"],
});
const thisRow = await internalDeadHost.get(access, {
id: thisData.id,
expand: ["owner", "certificate"],
});
// Configure nginx
const newMeta = await internalNginx.configure(deadHostModel, "dead_host", row);
row.meta = newMeta;
if (!thisRow.enabled) {
// No need to add nginx config if host is disabled
return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
}
const newMeta = await internalNginx.configure(deadHostModel, "dead_host", thisRow);
thisRow.meta = newMeta;
return _.omit(internalHost.cleanRowCertificateMeta(thisRow), omissions());
},
@@ -224,18 +221,15 @@ const internalDeadHost = {
* @returns {Promise}
*/
delete: async (access, data) => {
await access.can("dead_hosts:delete", data.id)
await access.can("dead_hosts:delete", data.id);
const row = await internalDeadHost.get(access, { id: data.id });
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
}
await deadHostModel
.query()
.where("id", row.id)
.patch({
is_deleted: 1,
});
await deadHostModel.query().where("id", row.id).patch({
is_deleted: 1,
});
// Delete Nginx Config
await internalNginx.deleteConfig("dead_host", row);
@@ -259,7 +253,7 @@ const internalDeadHost = {
* @returns {Promise}
*/
enable: async (access, data) => {
await access.can("dead_hosts:update", data.id)
await access.can("dead_hosts:update", data.id);
const row = await internalDeadHost.get(access, {
id: data.id,
expand: ["certificate", "owner"],
@@ -273,12 +267,9 @@ const internalDeadHost = {
row.enabled = 1;
await deadHostModel
.query()
.where("id", row.id)
.patch({
enabled: 1,
});
await deadHostModel.query().where("id", row.id).patch({
enabled: 1,
});
// Configure nginx
await internalNginx.configure(deadHostModel, "dead_host", row);
@@ -301,7 +292,7 @@ const internalDeadHost = {
* @returns {Promise}
*/
disable: async (access, data) => {
await access.can("dead_hosts:update", data.id)
await access.can("dead_hosts:update", data.id);
const row = await internalDeadHost.get(access, { id: data.id });
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
@@ -312,12 +303,9 @@ const internalDeadHost = {
row.enabled = 0;
await deadHostModel
.query()
.where("id", row.id)
.patch({
enabled: 0,
});
await deadHostModel.query().where("id", row.id).patch({
enabled: 0,
});
// Delete Nginx Config
await internalNginx.deleteConfig("dead_host", row);
@@ -342,7 +330,7 @@ const internalDeadHost = {
* @returns {Promise}
*/
getAll: async (access, expand, searchQuery) => {
const accessData = await access.can("dead_hosts:list")
const accessData = await access.can("dead_hosts:list");
const query = deadHostModel
.query()
.where("is_deleted", 0)
+4 -1
View File
@@ -217,7 +217,10 @@ const internalNginx = {
}
// For redirection hosts, if the scheme is not http or https, set it to $scheme
if (nice_host_type === "redirection_host" && ['http', 'https'].indexOf(host.forward_scheme.toLowerCase()) === -1) {
if (
nice_host_type === "redirection_host" &&
["http", "https"].indexOf(host.forward_scheme.toLowerCase()) === -1
) {
host.forward_scheme = "$scheme";
}
+4 -14
View File
@@ -38,11 +38,7 @@ export default {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
}
const auth = await authModel
.query()
.where("user_id", "=", user.id)
.where("type", "=", "password")
.first();
const auth = await authModel.query().where("user_id", "=", user.id).where("type", "=", "password").first();
if (!auth) {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
@@ -50,10 +46,7 @@ export default {
const valid = await auth.verifyPassword(data.secret);
if (!valid) {
throw new errs.AuthError(
ERROR_MESSAGE_INVALID_AUTH,
ERROR_MESSAGE_INVALID_AUTH_I18N,
);
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
}
if (data.scope !== "user" && _.indexOf(user.roles, data.scope) === -1) {
@@ -171,7 +164,7 @@ export default {
}
// Check scope
if (!tokenData.scope || tokenData.scope[0] !== "2fa-challenge") {
if (tokenData.scope?.[0] !== "2fa-challenge") {
throw new errs.AuthError("Invalid challenge token");
}
@@ -183,10 +176,7 @@ export default {
// Verify 2FA code
const valid = await twoFactor.verifyForLogin(userId, code);
if (!valid) {
throw new errs.AuthError(
ERROR_MESSAGE_INVALID_2FA,
ERROR_MESSAGE_INVALID_2FA_I18N,
);
throw new errs.AuthError(ERROR_MESSAGE_INVALID_2FA, ERROR_MESSAGE_INVALID_2FA_I18N);
}
// Create full token
+3 -5
View File
@@ -257,11 +257,9 @@ const internalUser = {
},
deleteAll: async () => {
await userModel
.query()
.patch({
is_deleted: 1,
});
await userModel.query().patch({
is_deleted: 1,
});
},
/**
+11 -11
View File
@@ -1,19 +1,19 @@
module.exports = {
development: {
client: 'mysql2',
client: "mysql2",
migrations: {
tableName: 'migrations',
stub: 'lib/migrate_template.js',
directory: 'migrations'
}
tableName: "migrations",
stub: "lib/migrate_template.js",
directory: "migrations",
},
},
production: {
client: 'mysql2',
client: "mysql2",
migrations: {
tableName: 'migrations',
stub: 'lib/migrate_template.js',
directory: 'migrations'
}
}
tableName: "migrations",
stub: "lib/migrate_template.js",
directory: "migrations",
},
},
};
+2 -5
View File
@@ -119,10 +119,7 @@ export default function (tokenString) {
// Proxy Hosts
case "proxy_hosts": {
const query = proxyHostModel
.query()
.select("id")
.andWhere("is_deleted", 0);
const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
if (permissions.visibility === "user") {
query.andWhere("owner_user_id", tokenUserId);
@@ -271,7 +268,7 @@ export default function (tokenString) {
err.permission = permission;
err.permission_data = data;
logger.error(permission, data, err.message);
throw errs.PermissionError("Permission Denied", err);
throw new errs.PermissionError("Permission Denied", err);
}
},
};
+37 -17
View File
@@ -2,13 +2,13 @@ import fs from "node:fs";
import NodeRSA from "node-rsa";
import { global as logger } from "../logger.js";
const keysFile = '/data/keys.json';
const mysqlEngine = 'mysql2';
const postgresEngine = 'pg';
const sqliteClientName = 'better-sqlite3';
const keysFile = "/data/keys.json";
const mysqlEngine = "mysql2";
const postgresEngine = "pg";
const sqliteClientName = "better-sqlite3";
// Not used for new setups anymore but may exist in legacy setups
const legacySqliteClientName = 'sqlite3';
const legacySqliteClientName = "sqlite3";
let instance = null;
@@ -40,14 +40,20 @@ const configure = () => {
}
}
const toBool = (v) => /^(1|true|yes|on)$/i.test((v || '').trim());
const toBool = (v) => /^(1|true|yes|on)$/i.test((v || "").trim());
const envMysqlHost = process.env.DB_MYSQL_HOST || null;
const envMysqlUser = process.env.DB_MYSQL_USER || null;
const envMysqlName = process.env.DB_MYSQL_NAME || null;
const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL);
const envMysqlSSLRejectUnauthorized = process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined ? true : toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED);
const envMysqlSSLVerifyIdentity = process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined ? true : toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY);
const envMysqlHost = process.env.DB_MYSQL_HOST || null;
const envMysqlUser = process.env.DB_MYSQL_USER || null;
const envMysqlName = process.env.DB_MYSQL_NAME || null;
const envMysqlSSL = toBool(process.env.DB_MYSQL_SSL);
const envMysqlSSLRejectUnauthorized =
process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED === undefined
? true
: toBool(process.env.DB_MYSQL_SSL_REJECT_UNAUTHORIZED);
const envMysqlSSLVerifyIdentity =
process.env.DB_MYSQL_SSL_VERIFY_IDENTITY === undefined
? true
: toBool(process.env.DB_MYSQL_SSL_VERIFY_IDENTITY);
if (envMysqlHost && envMysqlUser && envMysqlName) {
// we have enough mysql creds to go with mysql
logger.info("Using MySQL configuration");
@@ -58,8 +64,10 @@ const configure = () => {
port: process.env.DB_MYSQL_PORT || 3306,
user: envMysqlUser,
password: process.env.DB_MYSQL_PASSWORD,
name: envMysqlName,
ssl: envMysqlSSL ? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity } : false,
name: envMysqlName,
ssl: envMysqlSSL
? { rejectUnauthorized: envMysqlSSLRejectUnauthorized, verifyIdentity: envMysqlSSLVerifyIdentity }
: false,
},
keys: getKeys(),
};
@@ -137,7 +145,7 @@ const generateKeys = () => {
// Write keys config
try {
fs.writeFileSync(keysFile, JSON.stringify(keys, null, 2));
fs.writeFileSync(keysFile, JSON.stringify(keys, null, 2), { mode: 0o600 });
} catch (err) {
logger.error(`Could not write JWT key pair to config file: ${keysFile}: ${err.message}`);
process.exit(1);
@@ -222,7 +230,7 @@ const isDebugMode = () => !!process.env.DEBUG;
*
* @returns {boolean}
*/
const isCI = () => process.env.CI === 'true' && process.env.DEBUG === 'true';
const isCI = () => process.env.CI === "true" && process.env.DEBUG === "true";
/**
* Returns a public key
@@ -259,4 +267,16 @@ const useLetsencryptServer = () => {
return null;
};
export { isCI, configHas, configGet, isSqlite, isMysql, isPostgres, isDebugMode, getPrivateKey, getPublicKey, useLetsencryptStaging, useLetsencryptServer };
export {
isCI,
configHas,
configGet,
isSqlite,
isMysql,
isPostgres,
isDebugMode,
getPrivateKey,
getPublicKey,
useLetsencryptStaging,
useLetsencryptServer,
};
+1 -1
View File
@@ -1,4 +1,4 @@
import _ from "lodash";
import _ from "lodash";
export default (default_sort, default_offset, default_limit, max_limit) => {
/**
+2 -2
View File
@@ -1,6 +1,6 @@
export default (req, res, next) => {
if (req.params.user_id === 'me' && res.locals.access) {
req.params.user_id = res.locals.access.token.get('attrs').id;
if (req.params.user_id === "me" && res.locals.access) {
req.params.user_id = res.locals.access.token.get("attrs").id;
} else {
req.params.user_id = Number.parseInt(req.params.user_id, 10);
}
+1 -5
View File
@@ -24,21 +24,17 @@ const apiValidator = async (schema, payload /*, description*/) => {
throw new errs.ValidationError("Payload is undefined");
}
const validate = ajv.compile(schema);
const valid = validate(payload);
if (valid && !validate.errors) {
return payload;
}
const message = ajv.errorsText(validate.errors);
const err = new errs.ValidationError(message);
err.debug = {validationErrors: validate.errors, payload};
err.debug = { validationErrors: validate.errors, payload };
throw err;
};
+1 -1
View File
@@ -1,4 +1,4 @@
import Ajv from 'ajv/dist/2020.js';
import Ajv from "ajv/dist/2020.js";
import _ from "lodash";
import commonDefinitions from "../../schema/common.json" with { type: "json" };
import errs from "../error.js";
@@ -13,13 +13,14 @@ const migrateName = "settings";
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema.createTable('setting', (table) => {
table.string('id').notNull().primary();
table.string('name', 100).notNull();
table.string('description', 255).notNull();
table.string('value', 255).notNull();
table.json('meta').notNull();
})
return knex.schema
.createTable("setting", (table) => {
table.string("id").notNull().primary();
table.string("name", 100).notNull();
table.string("description", 255).notNull();
table.string("value", 255).notNull();
table.json("meta").notNull();
})
.then(() => {
logger.info(`[${migrateName}] setting Table created`);
});
@@ -17,9 +17,7 @@ const up = (knex) => {
.table("redirection_host", async (table) => {
// change the column default from $scheme to auto
await table.string("forward_scheme").notNull().defaultTo("auto").alter();
await knex('redirection_host')
.where('forward_scheme', '$scheme')
.update({ forward_scheme: 'auto' });
await knex("redirection_host").where("forward_scheme", "$scheme").update({ forward_scheme: "auto" });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
@@ -38,9 +36,7 @@ const down = (knex) => {
return knex.schema
.table("redirection_host", async (table) => {
await table.string("forward_scheme").notNull().defaultTo("$scheme").alter();
await knex('redirection_host')
.where('forward_scheme', 'auto')
.update({ forward_scheme: '$scheme' });
await knex("redirection_host").where("forward_scheme", "auto").update({ forward_scheme: "$scheme" });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
@@ -11,15 +11,15 @@ const migrateName = "trust_forwarded_proto";
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema
.alterTable('proxy_host', (table) => {
table.tinyint('trust_forwarded_proto').notNullable().defaultTo(0);
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
return knex.schema
.alterTable("proxy_host", (table) => {
table.tinyint("trust_forwarded_proto").notNullable().defaultTo(0);
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
};
/**
@@ -29,15 +29,15 @@ const up = (knex) => {
* @returns {Promise}
*/
const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);
logger.info(`[${migrateName}] Migrating Down...`);
return knex.schema
.alterTable('proxy_host', (table) => {
table.dropColumn('trust_forwarded_proto');
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
return knex.schema
.alterTable("proxy_host", (table) => {
table.dropColumn("trust_forwarded_proto");
})
.then(() => {
logger.info(`[${migrateName}] proxy_host Table altered`);
});
};
export { up, down };
export { up, down };
+8 -8
View File
@@ -7,23 +7,23 @@ import db from "../db.js";
Model.knex(db());
class Setting extends Model {
$beforeInsert () {
$beforeInsert() {
// Default for meta
if (typeof this.meta === 'undefined') {
if (typeof this.meta === "undefined") {
this.meta = {};
}
}
static get name () {
return 'Setting';
static get name() {
return "Setting";
}
static get tableName () {
return 'setting';
static get tableName() {
return "setting";
}
static get jsonAttributes () {
return ['meta'];
static get jsonAttributes() {
return ["meta"];
}
}
+7 -7
View File
@@ -8,21 +8,21 @@ import now from "./now_helper.js";
Model.knex(db());
class UserPermission extends Model {
$beforeInsert () {
this.created_on = now();
$beforeInsert() {
this.created_on = now();
this.modified_on = now();
}
$beforeUpdate () {
$beforeUpdate() {
this.modified_on = now();
}
static get name () {
return 'UserPermission';
static get name() {
return "UserPermission";
}
static get tableName () {
return 'user_permission';
static get tableName() {
return "user_permission";
}
}
+10 -10
View File
@@ -13,37 +13,37 @@
"regenerate-config": "node scripts/regenerate-config"
},
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^15.3.5",
"@apidevtools/json-schema-ref-parser": "^16.0.0",
"ajv": "^8.20.0",
"archiver": "^8.0.0",
"batchflow": "^0.4.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.10.0",
"body-parser": "^2.2.2",
"better-sqlite3": "^13.0.3",
"body-parser": "^2.3.0",
"chalk": "5.6.2",
"compression": "^1.8.1",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"gravatar": "^1.8.2",
"jsonwebtoken": "^9.0.3",
"knex": "3.2.10",
"liquidjs": "10.27.0",
"knex": "3.3.0",
"liquidjs": "10.29.0",
"lodash": "^4.18.1",
"moment": "^2.30.1",
"mysql2": "^3.22.3",
"mysql2": "^3.23.4",
"node-rsa": "^2.0.0",
"objection": "3.1.5",
"otplib": "^13.4.0",
"otplib": "^13.5.0",
"path": "^0.12.7",
"pg": "^8.21.0",
"proxy-agent": "^8.0.1",
"pg": "^8.23.0",
"proxy-agent": "^8.0.2",
"signale": "1.4.0",
"sqlite3": "^6.0.1",
"temp-write": "^6.0.1"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^12.1.0",
"@biomejs/biome": "^2.4.15",
"@biomejs/biome": "^2.5.10",
"nodemon": "^3.1.14"
},
"signale": {
+1 -4
View File
@@ -86,10 +86,7 @@ router
},
{
event_id: req.params.event_id,
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
},
);
+7 -29
View File
@@ -44,18 +44,11 @@ router
},
},
{
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
query: typeof req.query.query === "string" ? req.query.query : null,
},
);
const rows = await internalCertificate.getAll(
res.locals.access,
data.expand,
data.query,
);
const rows = await internalCertificate.getAll(res.locals.access, data.expand, data.query);
res.status(200).send(rows);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -70,15 +63,9 @@ router
*/
.post(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/nginx/certificates", "post"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/nginx/certificates", "post"), req.body);
req.setTimeout(900000); // 15 minutes timeout
const result = await internalCertificate.create(
res.locals.access,
payload,
);
const result = await internalCertificate.create(res.locals.access, payload);
res.status(201).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -139,16 +126,10 @@ router
*/
.post(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/nginx/certificates/test-http", "post"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/nginx/certificates/test-http", "post"), req.body);
req.setTimeout(60000); // 1 minute timeout
const result = await internalCertificate.testHttpsChallenge(
res.locals.access,
payload,
);
const result = await internalCertificate.testHttpsChallenge(res.locals.access, payload);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -224,10 +205,7 @@ router
},
{
certificate_id: req.params.certificate_id,
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
},
);
const row = await internalCertificate.get(res.locals.access, {
+4 -2
View File
@@ -194,9 +194,11 @@ router
/**
* POST /api/nginx/dead-hosts/123/disable
*/
.post((req, res, next) => {
.post(async (req, res, next) => {
try {
const result = internalDeadHost.disable(res.locals.access, { id: Number.parseInt(req.params.host_id, 10) });
const result = await internalDeadHost.disable(res.locals.access, {
id: Number.parseInt(req.params.host_id, 10),
});
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
+10 -41
View File
@@ -48,18 +48,11 @@ router
},
},
{
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
query: typeof req.query.query === "string" ? req.query.query : null,
},
);
const users = await internalUser.getAll(
res.locals.access,
data.expand,
data.query,
);
const users = await internalUser.getAll(res.locals.access, data.expand, data.query);
res.status(200).send(users);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -95,10 +88,7 @@ router
}
}
const payload = await apiValidator(
getValidationSchema("/users", "post"),
body,
);
const payload = await apiValidator(getValidationSchema("/users", "post"), body);
const user = await internalUser.create(res.locals.access, payload);
res.status(201).send(user);
} catch (err) {
@@ -169,20 +159,14 @@ router
},
{
user_id: req.params.user_id,
expand:
typeof req.query.expand === "string"
? req.query.expand.split(",")
: null,
expand: typeof req.query.expand === "string" ? req.query.expand.split(",") : null,
},
);
const user = await internalUser.get(res.locals.access, {
id: data.user_id,
expand: data.expand,
omit: internalUser.getUserOmisionsByAccess(
res.locals.access,
data.user_id,
),
omit: internalUser.getUserOmisionsByAccess(res.locals.access, data.user_id),
});
res.status(200).send(user);
} catch (err) {
@@ -198,10 +182,7 @@ router
*/
.put(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/users/{userID}", "put"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/users/{userID}", "put"), req.body);
payload.id = req.params.user_id;
const result = await internalUser.update(res.locals.access, payload);
res.status(200).send(result);
@@ -248,10 +229,7 @@ router
*/
.put(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/users/{userID}/auth", "put"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/users/{userID}/auth", "put"), req.body);
payload.id = req.params.user_id;
const result = await internalUser.setPassword(res.locals.access, payload);
res.status(200).send(result);
@@ -281,15 +259,9 @@ router
*/
.put(async (req, res, next) => {
try {
const payload = await apiValidator(
getValidationSchema("/users/{userID}/permissions", "put"),
req.body,
);
const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
payload.id = req.params.user_id;
const result = await internalUser.setPermissions(
res.locals.access,
payload,
);
const result = await internalUser.setPermissions(res.locals.access, payload);
res.status(200).send(result);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
@@ -408,10 +380,7 @@ router
*/
.post(async (req, res, next) => {
try {
const { code } = await apiValidator(
getValidationSchema("/users/{userID}/2fa/enable", "post"),
req.body,
);
const { code } = await apiValidator(getValidationSchema("/users/{userID}/2fa/enable", "post"), req.body);
const result = await internal2FA.enable(res.locals.access, req.params.user_id, code);
res.status(200).send(result);
} catch (err) {
+32 -35
View File
@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import { installPlugins } from "./lib/certbot.js";
import utils from "./lib/utils.js";
import { setup as logger } from "./logger.js";
@@ -6,12 +7,11 @@ import certificateModel from "./models/certificate.js";
import settingModel from "./models/setting.js";
import userModel from "./models/user.js";
import userPermissionModel from "./models/user_permission.js";
import fs from "fs/promises";
export const isSetup = async () => {
const row = await userModel.query().select("id").where("is_deleted", 0).first();
return row?.id > 0;
}
};
/**
* Creates a default admin users if one doesn't already exist in the database
@@ -45,18 +45,14 @@ const setupDefaultUser = async () => {
roles: ["admin"],
};
const user = await userModel
.query()
.insertAndFetch(data);
const user = await userModel.query().insertAndFetch(data);
await authModel
.query()
.insert({
user_id: user.id,
type: "password",
secret: initialAdminPassword,
meta: {},
});
await authModel.query().insert({
user_id: user.id,
type: "password",
secret: initialAdminPassword,
meta: {},
});
await userPermissionModel.query().insert({
user_id: user.id,
@@ -78,22 +74,16 @@ const setupDefaultUser = async () => {
* @returns {Promise}
*/
const setupDefaultSettings = async () => {
const row = await settingModel
.query()
.select("id")
.where({ id: "default-site" })
.first();
const row = await settingModel.query().select("id").where({ id: "default-site" }).first();
if (!row?.id) {
await settingModel
.query()
.insert({
id: "default-site",
name: "Default Site",
description: "What to show when Nginx is hit with an unknown Host",
value: "congratulations",
meta: {},
});
await settingModel.query().insert({
id: "default-site",
name: "Default Site",
description: "What to show when Nginx is hit with an unknown Host",
value: "congratulations",
meta: {},
});
logger.info("Default settings added");
}
};
@@ -104,10 +94,7 @@ const setupDefaultSettings = async () => {
* @returns {Promise}
*/
const setupCertbotPlugins = async () => {
const certificates = await certificateModel
.query()
.where("is_deleted", 0)
.andWhere("provider", "letsencrypt");
const certificates = await certificateModel.query().where("is_deleted", 0).andWhere("provider", "letsencrypt");
if (certificates?.length) {
const plugins = [];
@@ -122,14 +109,24 @@ const setupCertbotPlugins = async () => {
// Make sure credentials file exists
const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
if (typeof certificate.meta.dns_provider_credentials === "string") {
promises.push(fs.mkdir("/etc/letsencrypt/credentials", { recursive: true })
.then(() => fs.writeFile(credentials_loc, certificate.meta.dns_provider_credentials, { mode: 0o600, flag: "wx" }))
.catch((err) => { if (err.code !== "EEXIST") throw err; }));
promises.push(
fs
.mkdir("/etc/letsencrypt/credentials", { recursive: true })
.then(() =>
fs.writeFile(credentials_loc, certificate.meta.dns_provider_credentials, {
mode: 0o600,
flag: "wx",
}),
)
.catch((err) => {
if (err.code !== "EEXIST") throw err;
}),
);
}
}
return true;
});
await installPlugins(plugins);
if (promises.length) {
+386 -339
View File
File diff suppressed because it is too large Load Diff
@@ -46,6 +46,12 @@ for loc in "${locations[@]}"; do
chownit "$loc"
done
# Ensure the JWT key file is owned by the runtime user, even when the /data
# directory ownership already matches PUID:PGID (chownit skips recursion then)
if [ -f /data/keys.json ]; then
chown "$PUID:$PGID" /data/keys.json
fi
if [ "$(is_true "${SKIP_CERTBOT_OWNERSHIP:-}")" = '1' ]; then
log_info 'Skipping ownership change of certbot directories'
else
+73 -90
View File
@@ -1,92 +1,75 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"!**/dist/**/*"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4,
"lineWidth": 120,
"formatWithErrors": true
},
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":BUN:",
":NODE:",
[
"npm:*",
"npm:*/**"
],
":PACKAGE_WITH_PROTOCOL:",
":URL:",
":PACKAGE:",
[
"/src/*",
"/src/**"
],
[
"/**"
],
[
"#*",
"#*/**"
],
":PATH:"
]
}
}
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"useUniqueElementIds": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
},
"performance": {
"noDelete": "off"
},
"nursery": "off",
"a11y": {
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "!**/dist/**/*"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4,
"lineWidth": 120,
"formatWithErrors": true
},
"assist": {
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
":BUN:",
":NODE:",
["npm:*", "npm:*/**"],
":PACKAGE_WITH_PROTOCOL:",
":URL:",
":PACKAGE:",
["/src/*", "/src/**"],
["/**"],
["#*", "#*/**"],
":PATH:"
]
}
}
}
}
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"useUniqueElementIds": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
},
"performance": {
"noDelete": "off"
},
"nursery": "off",
"a11y": {
"useSemanticElements": "off",
"useValidAnchor": "off"
},
"style": {
"noParameterAssign": "error",
"useAsConstAssertion": "error",
"useDefaultParameterLast": "error",
"useEnumInitializers": "error",
"useSelfClosingElements": "error",
"useSingleVarDeclarator": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
}
}
}
}
+1
View File
@@ -29,6 +29,7 @@ const allLocales = [
["tr", "tr-TR"],
["hu", "hu-HU"],
["no", "no-NO"],
["uk", "uk-UA"],
];
const ignoreUnused = [/^.*$/];
+26 -25
View File
@@ -17,50 +17,51 @@
},
"dependencies": {
"@tabler/core": "^1.4.0",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"@tabler/icons-react": "^3.46.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-table": "^9.1.2",
"@uiw/react-textarea-code-editor": "^3.1.1",
"classnames": "^2.5.1",
"country-flag-icons": "^1.6.17",
"country-flag-icons": "^1.6.20",
"date-fns": "^4.4.0",
"ez-modal-react": "^1.0.5",
"formik": "^2.4.9",
"generate-password-browser": "^1.1.0",
"humps": "^2.0.1",
"query-string": "^9.4.0",
"react": "^19.2.7",
"query-string": "^9.5.0",
"react": "^19.2.8",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.7",
"react-intl": "^10.1.11",
"react-dom": "^19.2.8",
"react-intl": "^10.1.22",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.16.0",
"react-qr-code": "^2.2.0",
"react-router-dom": "^7.18.2",
"react-select": "^5.10.2",
"react-toastify": "^11.1.0",
"rooks": "^9.8.0"
"rooks": "^9.9.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@formatjs/cli": "^6.16.6",
"@tanstack/react-query-devtools": "^5.100.14",
"@biomejs/biome": "^2.5.10",
"@formatjs/cli": "^6.16.19",
"@tanstack/react-query-devtools": "^5.101.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@types/country-flag-icons": "^1.2.2",
"@types/humps": "^2.0.6",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/react-table": "^7.7.20",
"@vitejs/plugin-react": "^6.0.2",
"happy-dom": "^20.9.0",
"postcss": "^8.5.15",
"@vitejs/plugin-react": "^6.1.0",
"happy-dom": "^20.11.6",
"postcss": "^8.5.26",
"postcss-simple-vars": "^7.0.1",
"sass": "^1.100.0",
"sass": "^1.103.1",
"tmp": "^0.2.7",
"typescript": "6.0.3",
"vite": "^8.0.16",
"vite-plugin-checker": "^0.14.1",
"vitest": "^4.1.8"
"typescript": "7.0.2",
"vite": "^8.2.2",
"vite-plugin-checker": "^0.14.5",
"vitest": "^4.1.11"
}
}
+6 -5
View File
@@ -1,12 +1,13 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Table as ReactTable, RowData } from "@tanstack/react-table";
import cn from "classnames";
import type { ReactNode } from "react";
import { Button, HasPermission } from "src/components";
import type { Features } from "src/components/Table/features";
import { T } from "src/locale";
import { type ADMIN, MANAGE, type Permission, type Section } from "src/modules/Permissions";
interface Props {
tableInstance: ReactTable<any>;
interface Props<TData extends RowData> {
tableInstance: ReactTable<Features, TData>;
onNew?: () => void;
isFiltered?: boolean;
object: string;
@@ -16,7 +17,7 @@ interface Props {
permissionSection?: Section | typeof ADMIN;
permission?: Permission;
}
function EmptyData({
function EmptyData<TData extends RowData>({
tableInstance,
onNew,
isFiltered,
@@ -26,7 +27,7 @@ function EmptyData({
customAddBtn,
permissionSection,
permission,
}: Props) {
}: Props<TData>) {
return (
<tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}>
@@ -65,8 +65,12 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
value={client.directive}
onChange={(e) => handleChange(idx, "directive", e.target.value)}
>
<option value="allow"><T id="action.allow" /></option>
<option value="deny"><T id="action.deny" /></option>
<option value="allow">
<T id="action.allow" />
</option>
<option value="deny">
<T id="action.deny" />
</option>
</select>
</span>
<input
@@ -81,16 +85,13 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
</div>
</div>
<div className="col-1">
<a
role="button"
<button
type="button"
className="btn btn-ghost btn-danger p-0"
onClick={(e) => {
e.preventDefault();
handleRemove(idx);
}}
onClick={() => handleRemove(idx)}
>
<IconX size={16} />
</a>
</button>
</div>
</div>
))}
@@ -112,7 +113,9 @@ export function AccessClientFields({ initialValues, name = "clients" }: Props) {
value="deny"
disabled
>
<option value="deny"><T id="action.deny" /></option>
<option value="deny">
<T id="action.deny" />
</option>
</select>
</span>
<input
@@ -82,16 +82,13 @@ export function BasicAuthFields({ initialValues, name = "items" }: Props) {
/>
</div>
<div className="col-1">
<a
role="button"
<button
type="button"
className="btn btn-ghost btn-danger p-0"
onClick={(e) => {
e.preventDefault();
handleRemove(idx);
}}
onClick={() => handleRemove(idx)}
>
<IconX size={16} />
</a>
</button>
</div>
</div>
))}
@@ -1,3 +1,51 @@
/* card-active points --tblr-card-border-color at --tblr-primary, which both the
card outline and the card header's bottom border are drawn from. Tabler's
stylesheet is loaded after this one, so the override needs !important. */
.locationCard {
border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
--tblr-card-border-color: light-dark(var(--tblr-gray-200), var(--tblr-gray-700)) !important;
}
.filter {
max-width: 20rem;
}
/* The header is a plain toggle rather than a button-styled control, so that a
list of collapsed locations reads as rows instead of a stack of buttons. */
.toggle {
display: flex;
flex: 1 1 auto;
align-self: stretch;
align-items: center;
min-width: 0;
padding: 0;
color: inherit;
text-align: left;
background: transparent;
border: 0;
}
.toggle:focus-visible {
outline: 2px solid var(--tblr-primary);
outline-offset: -2px;
}
/* Keeps the marker on one line next to the delete button, and lets it drop out
of the way before the path does when the row runs out of room. */
.marker {
display: flex;
flex: 0 1 auto;
align-items: center;
overflow: hidden;
white-space: nowrap;
}
.path {
font-weight: 500;
white-space: nowrap;
}
.summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+276 -131
View File
@@ -1,20 +1,43 @@
import { IconSettings } from "@tabler/icons-react";
import {
IconChevronDown,
IconChevronRight,
IconPlus,
IconSearch,
IconSettings,
IconTrash,
IconX,
} from "@tabler/icons-react";
import CodeEditor from "@uiw/react-textarea-code-editor";
import cn from "classnames";
import { useFormikContext } from "formik";
import { useState } from "react";
import { useRef, useState } from "react";
import type { ProxyLocation } from "src/api/backend";
import { intl, T } from "src/locale";
import styles from "./LocationsFields.module.css";
// Below this many locations the list is short enough to scan by eye, and the
// filter would only take up space.
const FILTER_THRESHOLD = 5;
// Locations are identified by a client-side id rather than their array index,
// so that expanded/advanced state stays with the right row when one is removed.
interface Row {
id: number;
value: ProxyLocation;
}
interface Props {
initialValues: ProxyLocation[];
name?: string;
}
export function LocationsFields({ initialValues, name = "locations" }: Props) {
const [values, setValues] = useState<ProxyLocation[]>(initialValues || []);
const [rows, setRows] = useState<Row[]>(() => (initialValues || []).map((value, id) => ({ id, value })));
const { setFieldValue } = useFormikContext();
const [expanded, setExpanded] = useState<number[]>([]);
const [advVisible, setAdvVisible] = useState<number[]>([]);
const [filter, setFilter] = useState("");
const nextId = useRef(rows.length);
const scrollToId = useRef<number | null>(null);
const blankItem: ProxyLocation = {
path: "",
@@ -24,32 +47,62 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
forwardPort: 80,
};
const toggleAdvVisible = (idx: number) => {
setAdvVisible(advVisible.includes(idx) ? advVisible.filter((i) => i !== idx) : [...advVisible, idx]);
const toggleExpanded = (id: number) => {
setExpanded(expanded.includes(id) ? expanded.filter((i) => i !== id) : [...expanded, id]);
};
const toggleAdvVisible = (id: number) => {
setAdvVisible(advVisible.includes(id) ? advVisible.filter((i) => i !== id) : [...advVisible, id]);
};
const handleAdd = () => {
setValues([...values, blankItem]);
const id = nextId.current++;
setRows([...rows, { id, value: blankItem }]);
// A new location starts empty, so open it and make sure an active filter
// doesn't hide the row that was just added.
setExpanded([...expanded, id]);
setFilter("");
scrollToId.current = id;
};
const handleRemove = (idx: number) => {
const newValues = values.filter((_: ProxyLocation, i: number) => i !== idx);
setValues(newValues);
setFormField(newValues);
const handleRemove = (id: number) => {
const newRows = rows.filter((r: Row) => r.id !== id);
setRows(newRows);
setExpanded(expanded.filter((i) => i !== id));
setAdvVisible(advVisible.filter((i) => i !== id));
setFormField(newRows);
};
const handleChange = (idx: number, field: string, fieldValue: string) => {
const newValues = values.map((v: ProxyLocation, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v));
setValues(newValues);
setFormField(newValues);
const handleChange = (id: number, field: string, fieldValue: string) => {
const newRows = rows.map((r: Row) => (r.id === id ? { ...r, value: { ...r.value, [field]: fieldValue } } : r));
setRows(newRows);
setFormField(newRows);
};
const setFormField = (newValues: ProxyLocation[]) => {
const filtered = newValues.filter((v: ProxyLocation) => v?.path?.trim() !== "");
const setFormField = (newRows: Row[]) => {
const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== "");
setFieldValue(name, filtered);
};
if (values.length === 0) {
const forwardSummary = (item: ProxyLocation) => {
if (!item.forwardHost) {
return "";
}
return `${item.forwardScheme}://${item.forwardHost}${item.forwardPort ? `:${item.forwardPort}` : ""}`;
};
// Matches the path as well as the destination, so a location can be found by
// the host or port it forwards to and not just by its path.
const matchesFilter = (item: ProxyLocation, query: string) =>
[item.path, item.forwardScheme, item.forwardHost, item.forwardPort, forwardSummary(item)]
.join(" ")
.toLowerCase()
.includes(query);
const query = filter.trim().toLowerCase();
const visibleRows = query ? rows.filter((r: Row) => matchesFilter(r.value, query)) : rows;
if (rows.length === 0) {
return (
<div className="text-center">
<button type="button" className="btn my-3" onClick={handleAdd}>
@@ -61,125 +114,217 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
return (
<>
{values.map((item: ProxyLocation, idx: number) => (
<div key={idx} className={cn("card", "card-active", "mb-3", styles.locationCard)}>
<div className="card-body">
<div className="row">
<div className="col-md-10">
<div className="input-group mb-3">
<span className="input-group-text">Location</span>
<input
type="text"
className="form-control"
placeholder="/path"
autoComplete="off"
value={item.path}
onChange={(e) => handleChange(idx, "path", e.target.value)}
/>
</div>
</div>
<div className="col-md-2 text-end">
<button
type="button"
className="btn p-0"
title="Advanced"
onClick={() => toggleAdvVisible(idx)}
>
<IconSettings size={20} />
</button>
</div>
</div>
<div className="row">
<div className="col-md-3">
<div className="mb-3">
<label className="form-label" htmlFor="forwardScheme">
<T id="host.forward-scheme" />
</label>
<select
id="forwardScheme"
className="form-control"
value={item.forwardScheme}
onChange={(e) => handleChange(idx, "forwardScheme", e.target.value)}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label" htmlFor="forwardHost">
<T id="proxy-host.forward-host" />
</label>
<input
id="forwardHost"
type="text"
className="form-control"
required
placeholder="eg: 10.0.0.1/path/"
value={item.forwardHost}
onChange={(e) => handleChange(idx, "forwardHost", e.target.value)}
/>
</div>
</div>
<div className="col-md-3">
<div className="mb-3">
<label className="form-label" htmlFor="forwardPort">
<T id="host.forward-port" />
</label>
<input
id="forwardPort"
type="number"
min={1}
max={65535}
className="form-control"
required
placeholder="eg: 8081"
value={item.forwardPort}
onChange={(e) => handleChange(idx, "forwardPort", e.target.value)}
/>
</div>
</div>
</div>
{advVisible.includes(idx) && (
<div className="">
<CodeEditor
language="nginx"
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
padding={15}
data-color-mode="dark"
minHeight={170}
indentWidth={2}
value={item.advancedConfig}
onChange={(e) => handleChange(idx, "advancedConfig", e.target.value)}
style={{
fontFamily:
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
minHeight: "170px",
}}
/>
</div>
)}
<div className="mt-1">
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleRemove(idx);
}}
<div className="d-flex align-items-center mb-3">
{rows.length >= FILTER_THRESHOLD && (
<div className={cn("input-group", styles.filter)}>
<span className="input-group-text">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control"
autoComplete="off"
placeholder={intl.formatMessage({ id: "location.filter" })}
aria-label={intl.formatMessage({ id: "location.filter" })}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{filter ? (
<button
type="button"
className="btn btn-icon"
title={intl.formatMessage({ id: "action.clear" })}
aria-label={intl.formatMessage({ id: "action.clear" })}
onClick={() => setFilter("")}
>
<T id="action.delete" />
</a>
</div>
<IconX size={16} />
</button>
) : null}
</div>
</div>
))}
<div>
<button type="button" className="btn btn-sm" onClick={handleAdd}>
)}
<button type="button" className="btn ms-auto" onClick={handleAdd}>
<IconPlus size={16} className="me-1" />
<T id="action.add-location" />
</button>
</div>
{visibleRows.length === 0 ? (
<div className="text-secondary text-center my-3">
<T id="empty-search" />
</div>
) : (
visibleRows.map((row: Row) => {
const item = row.value;
const isOpen = expanded.includes(row.id);
const bodyId = `location-body-${row.id}`;
return (
<div
key={row.id}
ref={(node) => {
if (node && scrollToId.current === row.id) {
scrollToId.current = null;
node.scrollIntoView({ block: "nearest" });
}
}}
className={cn("card", "card-active", "mb-2", styles.locationCard)}
>
<div className={cn("card-header", "p-2", !isOpen && "border-bottom-0")}>
<button
type="button"
className={styles.toggle}
aria-expanded={isOpen}
aria-controls={bodyId}
onClick={() => toggleExpanded(row.id)}
>
{isOpen ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
<span className={cn("ms-2", styles.path)}>{item.path}</span>
<span className={cn("ms-2", "text-secondary", styles.summary)}>
{forwardSummary(item)}
</span>
</button>
{item.advancedConfig ? (
// Deliberately the same icon as the advanced-config toggle in the
// body below, so the marker reads as "this row has that section
// filled in" rather than as a decoration of its own.
<span
className={cn("ms-2", "text-secondary", styles.marker)}
role="img"
title={intl.formatMessage({ id: "location.advanced-config" })}
aria-label={intl.formatMessage({ id: "location.advanced-config" })}
>
<IconSettings size={16} />
</span>
) : null}
<button
type="button"
className="btn btn-action ms-2"
title={intl.formatMessage({ id: "action.delete" })}
aria-label={intl.formatMessage({ id: "action.delete" })}
onClick={() => handleRemove(row.id)}
>
<IconTrash size={16} className="icon" />
</button>
</div>
{isOpen && (
<div className="card-body" id={bodyId}>
<div className="row">
<div className="col-md-10">
<div className="input-group mb-3">
<span className="input-group-text">Location</span>
<input
type="text"
className="form-control"
placeholder="/path"
autoComplete="off"
value={item.path}
onChange={(e) => handleChange(row.id, "path", e.target.value)}
/>
</div>
</div>
<div className="col-md-2 text-end">
<button
type="button"
className="btn p-0"
title="Advanced"
aria-expanded={advVisible.includes(row.id)}
onClick={() => toggleAdvVisible(row.id)}
>
<IconSettings size={20} />
</button>
</div>
</div>
<div className="row">
<div className="col-md-3">
<div className="mb-3">
<label
className="form-label"
htmlFor={`location-forwardScheme-${row.id}`}
>
<T id="host.forward-scheme" />
</label>
<select
id={`location-forwardScheme-${row.id}`}
className="form-control"
value={item.forwardScheme}
onChange={(e) =>
handleChange(row.id, "forwardScheme", e.target.value)
}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label
className="form-label"
htmlFor={`location-forwardHost-${row.id}`}
>
<T id="proxy-host.forward-host" />
</label>
<input
id={`location-forwardHost-${row.id}`}
type="text"
className="form-control"
required
placeholder="eg: 10.0.0.1/path/"
value={item.forwardHost}
onChange={(e) =>
handleChange(row.id, "forwardHost", e.target.value)
}
/>
</div>
</div>
<div className="col-md-3">
<div className="mb-3">
<label
className="form-label"
htmlFor={`location-forwardPort-${row.id}`}
>
<T id="host.forward-port" />
</label>
<input
id={`location-forwardPort-${row.id}`}
type="number"
min={1}
max={65535}
className="form-control"
required
placeholder="eg: 8081"
value={item.forwardPort}
onChange={(e) =>
handleChange(row.id, "forwardPort", e.target.value)
}
/>
</div>
</div>
</div>
{advVisible.includes(row.id) && (
<div className="">
<CodeEditor
language="nginx"
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
padding={15}
data-color-mode="dark"
minHeight={170}
indentWidth={2}
value={item.advancedConfig}
onChange={(e) => handleChange(row.id, "advancedConfig", e.target.value)}
style={{
fontFamily:
"ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
minHeight: "170px",
}}
/>
</div>
)}
</div>
)}
</div>
);
})
)}
</>
);
}
@@ -10,7 +10,13 @@ interface Props {
requireDomainNames?: boolean; // used for streams
color?: string;
}
export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) {
export function SSLOptionsFields({
forHttp = true,
forProxyHost = false,
forceDNSForNew,
requireDomainNames,
color = "bg-cyan",
}: Props) {
const { values, setFieldValue } = useFormikContext();
const v: any = values || {};
@@ -116,10 +122,12 @@ export function SSLOptionsFields({ forHttp = true, forProxyHost = false, forceDN
</div>
);
const getHttpAdvancedOptions = () =>(
const getHttpAdvancedOptions = () => (
<div>
<details>
<summary className="mb-1"><T id="domains.advanced" /></summary>
<summary className="mb-1">
<T id="domains.advanced" />
</summary>
<div className="row">
<div className="col-12">
<Field name="trustForwardedProto">
+4 -1
View File
@@ -72,7 +72,10 @@ export function SiteHeader() {
<div className="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<div className="d-md-none">
{/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: This div is not interactive. */}
<div className="p-2 pb-1 pe-1 d-flex align-items-center" onClick={e => e.stopPropagation()}>
<div
className="p-2 pb-1 pe-1 d-flex align-items-center"
onClick={(e) => e.stopPropagation()}
>
<div className="ps-2 pe-1 me-auto">
<div>{currentUser?.nickname}</div>
<div className="mt-1 small text-secondary text-nowrap">
+8 -8
View File
@@ -143,7 +143,6 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
className="nav-link dropdown-toggle"
href={item.to}
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
role="button"
>
@@ -176,13 +175,14 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
};
export function SiteMenu() {
const closeMenu = () => setTimeout(() => {
const navbarToggler = document.querySelector<HTMLElement>(".navbar-toggler");
const navbarMenu = document.querySelector("#navbar-menu");
if (navbarToggler && navbarMenu?.classList.contains("show")) {
navbarToggler.click();
}
}, 300);
const closeMenu = () =>
setTimeout(() => {
const navbarToggler = document.querySelector<HTMLElement>(".navbar-toggler");
const navbarMenu = document.querySelector("#navbar-menu");
if (navbarToggler && navbarMenu?.classList.contains("show")) {
navbarToggler.click();
}
}, 300);
return (
<header className="navbar-expand-md">
+5 -4
View File
@@ -1,9 +1,10 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Table as ReactTable, RowData } from "@tanstack/react-table";
import type { Features } from "./features";
interface Props {
tableInstance: ReactTable<any>;
interface Props<TData extends RowData> {
tableInstance: ReactTable<Features, TData>;
}
function EmptyRow({ tableInstance }: Props) {
function EmptyRow<TData extends RowData>({ tableInstance }: Props<TData>) {
return (
<tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}>
+2 -2
View File
@@ -1,8 +1,8 @@
import { flexRender } from "@tanstack/react-table";
import { flexRender, type RowData } from "@tanstack/react-table";
import type { TableLayoutProps } from "src/components";
import { EmptyRow } from "./EmptyRow";
function TableBody<T>(props: TableLayoutProps<T>) {
function TableBody<T extends RowData>(props: TableLayoutProps<T>) {
const { tableInstance, extraStyles, emptyState } = props;
const rows = tableInstance.getRowModel().rows;
@@ -1,8 +1,8 @@
import { IconArrowsSort, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { flexRender } from "@tanstack/react-table";
import { flexRender, type RowData } from "@tanstack/react-table";
import type { TableLayoutProps } from "src/components";
function TableHeader<T>(props: TableLayoutProps<T>) {
function TableHeader<T extends RowData>(props: TableLayoutProps<T>) {
const { tableInstance } = props;
const headerGroups = tableInstance.getHeaderGroups();
@@ -61,4 +61,4 @@ const tableEventReducer = (state: any, { type, payload }: any) => {
}
};
export { tableEvents, tableEventReducer };
export { tableEventReducer, tableEvents };
@@ -1,15 +1,16 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Table as ReactTable, RowData } from "@tanstack/react-table";
import type { Features } from "./features";
import { TableBody } from "./TableBody";
import { TableHeader } from "./TableHeader";
interface TableLayoutProps<TFields> {
tableInstance: ReactTable<TFields>;
interface TableLayoutProps<TFields extends RowData> {
tableInstance: ReactTable<Features, TFields>;
emptyState?: React.ReactNode;
extraStyles?: {
row: (rowData: TFields) => any | undefined;
};
}
function TableLayout<TFields>(props: TableLayoutProps<TFields>) {
function TableLayout<TFields extends RowData>(props: TableLayoutProps<TFields>) {
const hasRows = props.tableInstance.getRowModel().rows.length > 0;
return (
<div className="table-responsive">
+35
View File
@@ -0,0 +1,35 @@
import {
columnVisibilityFeature,
createSortedRowModel,
metaHelper,
rowSortingFeature,
tableFeatures,
} from "@tanstack/react-table";
interface ColumnMeta {
className?: string;
}
interface TableMeta {
isFetching?: boolean;
}
/**
* Shared TanStack Table v9 feature registration for every table in the app.
* Sorting and column visibility are used (or their APIs are called
* unconditionally, e.g. `getVisibleFlatColumns`/`getVisibleCells`) by the
* shared TableLayout/TableHeader/TableBody/EmptyData components, so every
* table instance must register them even when a particular table doesn't
* wire up controlled sorting state itself.
*/
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
columnVisibilityFeature,
columnMeta: metaHelper<ColumnMeta>(),
tableMeta: metaHelper<TableMeta>(),
});
type Features = typeof features;
export { type ColumnMeta, type Features, features, type TableMeta };
+1
View File
@@ -1,4 +1,5 @@
export * from "./Formatter";
export * from "./features";
export * from "./TableHeader";
export * from "./TableHelpers";
export * from "./TableLayout";
+76 -73
View File
@@ -21,49 +21,51 @@ import langZh from "./lang/zh.json";
import langTr from "./lang/tr.json";
import langHu from "./lang/hu.json";
import langNo from "./lang/no.json";
import langUk from "./lang/uk.json";
import langList from "./lang/lang-list.json";
// first item of each array should be the language code,
// not the country code
// Remember when adding to this list, also update check-locales.js script
const localeOptions = [
["en", "en-US", langEn],
["de", "de-DE", langDe],
["es", "es-ES", langEs],
["et", "et-EE", langEt],
["pt", "pt-PT", langPt],
["fr", "fr-FR", langFr],
["ga", "ga-IE", langGa],
["ja", "ja-JP", langJa],
["it", "it-IT", langIt],
["nl", "nl-NL", langNl],
["pl", "pl-PL", langPl],
["ru", "ru-RU", langRu],
["sk", "sk-SK", langSk],
["cs", "cs-CZ", langCs],
["vi", "vi-VN", langVi],
["zh", "zh-CN", langZh],
["ko", "ko-KR", langKo],
["bg", "bg-BG", langBg],
["id", "id-ID", langId],
["tr", "tr-TR", langTr],
["hu", "hu-HU", langHu],
["no", "no-NO", langNo],
["en", "en-US", langEn],
["de", "de-DE", langDe],
["es", "es-ES", langEs],
["et", "et-EE", langEt],
["pt", "pt-PT", langPt],
["fr", "fr-FR", langFr],
["ga", "ga-IE", langGa],
["ja", "ja-JP", langJa],
["it", "it-IT", langIt],
["nl", "nl-NL", langNl],
["pl", "pl-PL", langPl],
["ru", "ru-RU", langRu],
["sk", "sk-SK", langSk],
["cs", "cs-CZ", langCs],
["vi", "vi-VN", langVi],
["zh", "zh-CN", langZh],
["ko", "ko-KR", langKo],
["bg", "bg-BG", langBg],
["id", "id-ID", langId],
["tr", "tr-TR", langTr],
["hu", "hu-HU", langHu],
["no", "no-NO", langNo],
["uk", "uk-UA", langUk],
];
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
const thisLocale = (locale || "en").slice(0, 2);
const thisLocale = (locale || "en").slice(0, 2);
// ensure this lang exists in localeOptions above, otherwise fallback to en
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
return Object.assign({}, langList, langEn);
}
// ensure this lang exists in localeOptions above, otherwise fallback to en
if (thisLocale === "en" || !localeOptions.some(([code]) => code === thisLocale)) {
return Object.assign({}, langList, langEn);
}
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
return Object.assign({}, langList, langEn, localeOptions.find(([code]) => code === thisLocale)?.[2]);
};
const getFlagCodeForLocale = (locale?: string) => {
const thisLocale = (locale || "en").slice(0, 2);
const thisLocale = (locale || "en").slice(0, 2);
// only add to this if your flag is different from the locale code
const specialCases: Record<string, string> = {
@@ -74,27 +76,28 @@ const getFlagCodeForLocale = (locale?: string) => {
cs: "cz", // Czechia
ga: "ie", // Ireland (Irish)
et: "ee", // Estonia
uk: "ua", // Ukraine
};
if (specialCases[thisLocale]) {
return specialCases[thisLocale].toUpperCase();
}
return thisLocale.toUpperCase();
if (specialCases[thisLocale]) {
return specialCases[thisLocale].toUpperCase();
}
return thisLocale.toUpperCase();
};
const getLocale = (short = false) => {
let loc = window.localStorage.getItem("locale");
if (!loc) {
loc = document.documentElement.lang;
}
if (short) {
return loc.slice(0, 2);
}
// finally, fallback
if (!loc) {
loc = "en";
}
return loc;
let loc = window.localStorage.getItem("locale");
if (!loc) {
loc = document.documentElement.lang;
}
if (short) {
return loc.slice(0, 2);
}
// finally, fallback
if (!loc) {
loc = "en";
}
return loc;
};
const cache = createIntlCache();
@@ -103,41 +106,41 @@ const initialMessages = loadMessages(getLocale());
let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache);
const changeLocale = (locale: string): void => {
const messages = loadMessages(locale);
intl = createIntl({ locale, messages }, cache);
window.localStorage.setItem("locale", locale);
document.documentElement.lang = locale;
const messages = loadMessages(locale);
intl = createIntl({ locale, messages }, cache);
window.localStorage.setItem("locale", locale);
document.documentElement.lang = locale;
};
// This is a translation component that wraps the translation in a span with a data
// attribute so devs can inspect the element to see the translation ID
const T = ({
id,
data,
tData,
id,
data,
tData,
}: {
id: string;
data?: Record<string, string | number | undefined>;
tData?: Record<string, string>;
id: string;
data?: Record<string, string | number | undefined>;
tData?: Record<string, string>;
}) => {
const translatedData: Record<string, string> = {};
if (tData) {
// iterate over tData and translate each value
Object.entries(tData).forEach(([key, value]) => {
translatedData[key] = intl.formatMessage({ id: value });
});
}
return (
<span data-translation-id={id}>
{intl.formatMessage(
{ id },
{
...data,
...translatedData,
},
)}
</span>
);
const translatedData: Record<string, string> = {};
if (tData) {
// iterate over tData and translate each value
Object.entries(tData).forEach(([key, value]) => {
translatedData[key] = intl.formatMessage({ id: value });
});
}
return (
<span data-translation-id={id}>
{intl.formatMessage(
{ id },
{
...data,
...translatedData,
},
)}
</span>
);
};
//console.log("L:", localeOptions);
+1 -6
View File
@@ -1,9 +1,4 @@
import {
fromUnixTime,
type IntlFormatFormatOptions,
intlFormat,
parseISO,
} from "date-fns";
import { fromUnixTime, type IntlFormatFormatOptions, intlFormat, parseISO } from "date-fns";
const isUnixTimestamp = (value: unknown): boolean => {
if (typeof value !== "number" && typeof value !== "string") return false;
+1 -1
View File
@@ -3,4 +3,4 @@ export * as Certificates from "./Certificates.md";
export * as DeadHosts from "./DeadHosts.md";
export * as ProxyHosts from "./ProxyHosts.md";
export * as RedirectionHosts from "./RedirectionHosts.md";
export * as Streams from "./Streams.md";
export * as Streams from "./Streams.md";
+10 -10
View File
@@ -19,21 +19,21 @@ import * as vi from "./vi/index";
import * as zh from "./zh/index";
import * as tr from "./tr/index";
import * as hu from "./hu/index";
import * as uk from "./uk/index";
const items: any = { en, de, pt, es, et, ja, sk, cs, zh, pl, ru, it, vi, nl, bg, ko, ga, id, fr, tr, hu };
const items: any = { en, de, pt, es, et, ja, sk, cs, zh, pl, ru, it, vi, nl, bg, ko, ga, id, fr, tr, hu, uk };
const fallbackLang = "en";
export const getHelpFile = (lang: string, section: string): string => {
if (typeof items[lang] !== "undefined" && typeof items[lang][section] !== "undefined") {
return items[lang][section].default;
}
// Fallback to English
if (typeof items[fallbackLang] !== "undefined" && typeof items[fallbackLang][section] !== "undefined") {
return items[fallbackLang][section].default;
}
throw new Error(`Cannot load help doc for ${lang}-${section}`);
if (typeof items[lang] !== "undefined" && typeof items[lang][section] !== "undefined") {
return items[lang][section].default;
}
// Fallback to English
if (typeof items[fallbackLang] !== "undefined" && typeof items[fallbackLang][section] !== "undefined") {
return items[fallbackLang][section].default;
}
throw new Error(`Cannot load help doc for ${lang}-${section}`);
};
export default items;
@@ -4,4 +4,3 @@ export * as DeadHosts from "./DeadHosts.md";
export * as ProxyHosts from "./ProxyHosts.md";
export * as RedirectionHosts from "./RedirectionHosts.md";
export * as Streams from "./Streams.md";
@@ -4,4 +4,3 @@ export * as DeadHosts from "./DeadHosts.md";
export * as ProxyHosts from "./ProxyHosts.md";
export * as RedirectionHosts from "./RedirectionHosts.md";
export * as Streams from "./Streams.md";
@@ -0,0 +1,7 @@
## Що таке список доступу?
Списки доступу дають змогу створити список дозволених або заборонених IP-адрес клієнтів, а також налаштувати автентифікацію для проксі-хостів за допомогою базової HTTP-автентифікації.
Для одного списку доступу можна налаштувати кілька правил для клієнтів, імен користувачів і паролів, а потім застосувати його до одного або кількох _проксі-хостів_.
Це особливо корисно для проксійованих вебсервісів, які не мають власних механізмів автентифікації, або коли потрібно захистити їх від невідомих клієнтів.
@@ -0,0 +1,21 @@
## Довідка щодо сертифікатів
### HTTP-сертифікат
Під час перевірки сертифіката через HTTP сервери Let's Encrypt спробують отримати доступ до ваших доменів через HTTP (не HTTPS!). Якщо перевірка буде успішною, вони видадуть сертифікат.
Для цього методу потрібно створити _проксі-хост_ для відповідних доменів. Він має бути доступним через HTTP і вказувати на цей екземпляр Nginx. Після отримання сертифіката можна налаштувати _проксі-хост_ на використання цього сертифіката для HTTPS-з'єднань. Однак для автоматичного поновлення сертифіката _проксі-хост_ і надалі має бути доступним через HTTP.
Цей спосіб _не підтримує_ підстановчі домени.
### DNS-сертифікат
Для перевірки сертифіката через DNS потрібно використати плагін DNS-провайдера. За допомогою цього плагіна буде створено тимчасові записи у вашому домені. Let's Encrypt перевірить ці записи, щоб підтвердити, що домен належить вам, і в разі успішної перевірки видасть сертифікат.
Перед запитом такого сертифіката не потрібно створювати _проксі-хост_ або налаштовувати для нього доступ через HTTP.
Цей спосіб _підтримує_ підстановчі домени.
### Власний сертифікат
Скористайтеся цим варіантом, щоб завантажити власний SSL-сертифікат, виданий вашим центром сертифікації.
@@ -0,0 +1,7 @@
## Що таке 404-хост?
404-хост — це хост, налаштований для показу сторінки з помилкою 404.
Він може бути корисним, якщо ваш домен проіндексовано пошуковими системами й ви хочете показати зрозумілішу сторінку помилки або повідомити пошуковим роботам, що сторінки домену більше не існують.
Також цей хост дає змогу відстежувати звернення до нього в журналах і переглядати джерела переходів.
@@ -0,0 +1,7 @@
## Що таке проксі-хост?
Проксі-хост — це вхідна точка для вебсервісу, до якого потрібно перенаправляти запити.
Він дає змогу за потреби завершувати SSL-з'єднання для сервісу, який може не мати вбудованої підтримки SSL.
Проксі-хости — найпоширеніший спосіб використання Nginx Proxy Manager.
@@ -0,0 +1,5 @@
## Що таке редирект-хост?
Редирект-хост перенаправляє запити з вхідного домену на інший домен.
Найчастіше такий хост використовують після зміни домену вебсайту, коли в пошукових системах або на інших сайтах ще залишилися посилання на старий домен.
@@ -0,0 +1,5 @@
## Що таке потік?
Потік — це відносно нова функція Nginx, яка дає змогу перенаправляти TCP/UDP-трафік безпосередньо на інший комп'ютер у мережі.
Це може бути корисним, якщо ви використовуєте ігрові, FTP- або SSH-сервери.
@@ -0,0 +1,6 @@
export * as AccessLists from "./AccessLists.md";
export * as Certificates from "./Certificates.md";
export * as DeadHosts from "./DeadHosts.md";
export * as ProxyHosts from "./ProxyHosts.md";
export * as RedirectionHosts from "./RedirectionHosts.md";
export * as Streams from "./Streams.md";
+122 -2
View File
@@ -1,4 +1,61 @@
{
"2fa.backup-codes-remaining": {
"defaultMessage": "Verbleibende Backup-Codes: {count}"
},
"2fa.backup-warning": {
"defaultMessage": "Bewahre diese Backup-Codes an einem sicheren Ort auf. Jeder Code kann nur einmal verwendet werden."
},
"2fa.disable": {
"defaultMessage": "Zwei-Faktor-Authentifizierung deaktivieren"
},
"2fa.disable-confirm": {
"defaultMessage": "2FA deaktivieren"
},
"2fa.disable-warning": {
"defaultMessage": "Das Deaktivieren der Zwei-Faktor-Authentifizierung macht dein Konto weniger sicher."
},
"2fa.disabled": {
"defaultMessage": "Deaktiviert"
},
"2fa.done": {
"defaultMessage": "Ich habe meine Backup-Codes gespeichert"
},
"2fa.enable": {
"defaultMessage": "Zwei-Faktor-Authentifizierung aktivieren"
},
"2fa.enabled": {
"defaultMessage": "Aktiviert"
},
"2fa.enter-code": {
"defaultMessage": "Verifizierungscode eingeben"
},
"2fa.enter-code-disable": {
"defaultMessage": "Verifizierungscode zum Deaktivieren eingeben"
},
"2fa.regenerate": {
"defaultMessage": "Neu generieren"
},
"2fa.regenerate-backup": {
"defaultMessage": "Backup-Codes neu generieren"
},
"2fa.regenerate-instructions": {
"defaultMessage": "Gib einen Verifizierungscode ein, um neue Backup-Codes zu generieren. Deine alten Codes werden dadurch ungültig."
},
"2fa.secret-key": {
"defaultMessage": "Geheimer Schlüssel"
},
"2fa.setup-instructions": {
"defaultMessage": "Scanne diesen QR-Code mit deiner Authenticator-App oder gib den geheimen Schlüssel manuell ein."
},
"2fa.status": {
"defaultMessage": "Status"
},
"2fa.title": {
"defaultMessage": "Zwei-Faktor-Authentifizierung"
},
"2fa.verify-enable": {
"defaultMessage": "Verifizieren und aktivieren"
},
"access-list": {
"defaultMessage": "Zugriffsliste"
},
@@ -23,6 +80,9 @@
"access-list.public.subtitle": {
"defaultMessage": "Keine Authentifizierung erforderlich"
},
"access-list.rule-source.placeholder": {
"defaultMessage": "192.168.1.100 oder 192.168.1.0/24 oder 2001:0db8::/32"
},
"access-list.satisfy-any": {
"defaultMessage": "Satisfy Any"
},
@@ -38,12 +98,18 @@
"action.add-location": {
"defaultMessage": "Pfad hinzufügen"
},
"action.allow": {
"defaultMessage": "Erlauben"
},
"action.close": {
"defaultMessage": "Schließen"
},
"action.delete": {
"defaultMessage": "Löschen"
},
"action.deny": {
"defaultMessage": "Verweigern"
},
"action.disable": {
"defaultMessage": "Deaktivieren"
},
@@ -68,6 +134,9 @@
"auditlogs": {
"defaultMessage": "Protokolle"
},
"auto": {
"defaultMessage": "Automatisch"
},
"cancel": {
"defaultMessage": "Abbrechen"
},
@@ -128,6 +197,9 @@
"certificates.dns.provider": {
"defaultMessage": "DNS Provider"
},
"certificates.dns.provider.placeholder": {
"defaultMessage": "Anbieter auswählen..."
},
"certificates.dns.warning": {
"defaultMessage": "Dieser Abschnitt erfordert einige Kenntnisse über Certbot und seine DNS-Plugins. Bitte konsultieren Sie die jeweilige Plugin-Dokumentation."
},
@@ -275,6 +347,9 @@
"domain-names.wildcards-not-supported": {
"defaultMessage": "Wildcards werden für diese Zertifizierungsstelle nicht unterstützt."
},
"domains.advanced": {
"defaultMessage": "Erweitert"
},
"domains.force-ssl": {
"defaultMessage": "Erzwinge SSL"
},
@@ -287,6 +362,9 @@
"domains.http2-support": {
"defaultMessage": "HTTP/2 Support"
},
"domains.trust-forwarded-proto": {
"defaultMessage": "Upstream Forwarded-Proto-Header vertrauen"
},
"domains.use-dns": {
"defaultMessage": "Nutze DNS Challenge"
},
@@ -354,7 +432,7 @@
"defaultMessage": "Pfad beibehalten"
},
"host.flags.protocols": {
"defaultMessage": "Protokole"
"defaultMessage": "Protokolle"
},
"host.flags.websockets-upgrade": {
"defaultMessage": "Websockets Support"
@@ -383,6 +461,21 @@
"loading": {
"defaultMessage": "Laden…"
},
"login.2fa-code": {
"defaultMessage": "Verifizierungscode"
},
"login.2fa-code-placeholder": {
"defaultMessage": "Code eingeben"
},
"login.2fa-description": {
"defaultMessage": "Gib den Code aus deiner Authenticator-App ein"
},
"login.2fa-title": {
"defaultMessage": "Zwei-Faktor-Authentifizierung"
},
"login.2fa-verify": {
"defaultMessage": "Verifizieren"
},
"login.title": {
"defaultMessage": "Anmelden"
},
@@ -530,6 +623,24 @@
"redirection-hosts.count": {
"defaultMessage": "{count} {count, plural, one {Redirection Host} other {Redirection Hosts}}"
},
"redirection-hosts.http-code.300": {
"defaultMessage": "300 Mehrere Auswahlmöglichkeiten"
},
"redirection-hosts.http-code.301": {
"defaultMessage": "301 Dauerhaft verschoben"
},
"redirection-hosts.http-code.302": {
"defaultMessage": "302 Vorübergehend verschoben"
},
"redirection-hosts.http-code.303": {
"defaultMessage": "303 Siehe andere"
},
"redirection-hosts.http-code.307": {
"defaultMessage": "307 Temporäre Umleitung"
},
"redirection-hosts.http-code.308": {
"defaultMessage": "308 Permanente Umleitung"
},
"role.admin": {
"defaultMessage": "Administrator"
},
@@ -587,6 +698,9 @@
"stream.forward-host": {
"defaultMessage": "Forward Host"
},
"stream.forward-host.placeholder": {
"defaultMessage": "example.com oder 10.0.0.1 oder 2001:db8:3333:4444:5555:6666:7777:8888"
},
"stream.incoming-port": {
"defaultMessage": "Incoming Port"
},
@@ -605,6 +719,9 @@
"test": {
"defaultMessage": "Test"
},
"update-available": {
"defaultMessage": "Update verfügbar: {latestVersion}"
},
"user": {
"defaultMessage": "User"
},
@@ -647,10 +764,13 @@
"user.switch-light": {
"defaultMessage": "Zum Light Mode wechseln"
},
"user.two-factor": {
"defaultMessage": "Zwei-Faktor-Auth"
},
"username": {
"defaultMessage": "Benutzername"
},
"users": {
"defaultMessage": "Benutzer"
}
}
}
+9
View File
@@ -101,6 +101,9 @@
"action.allow": {
"defaultMessage": "Allow"
},
"action.clear": {
"defaultMessage": "Clear"
},
"action.close": {
"defaultMessage": "Close"
},
@@ -461,6 +464,12 @@
"loading": {
"defaultMessage": "Loading…"
},
"location.advanced-config": {
"defaultMessage": "Has custom Nginx configuration"
},
"location.filter": {
"defaultMessage": "Filter by path or destination"
},
"login.2fa-code": {
"defaultMessage": "Verification Code"
},
+3
View File
@@ -64,5 +64,8 @@
},
"locale-no-NO": {
"defaultMessage": "Norsk"
},
"locale-uk-UA": {
"defaultMessage": "Українська"
}
}
+9
View File
@@ -44,6 +44,9 @@
"action.allow": {
"defaultMessage": "İzin Ver"
},
"action.clear": {
"defaultMessage": "Temizle"
},
"action.close": {
"defaultMessage": "Kapat"
},
@@ -386,6 +389,12 @@
"loading": {
"defaultMessage": "Yükleniyor…"
},
"location.advanced-config": {
"defaultMessage": "Özel Nginx yapılandırması var"
},
"location.filter": {
"defaultMessage": "Yol veya hedefe göre filtrele"
},
"login.title": {
"defaultMessage": "Hesabınıza giriş yapın"
},
+785
View File
@@ -0,0 +1,785 @@
{
"2fa.backup-codes-remaining": {
"defaultMessage": "Резервних кодів залишилося: {count}"
},
"2fa.backup-warning": {
"defaultMessage": "Збережіть ці резервні коди в безпечному місці. Кожен код можна використати лише один раз."
},
"2fa.disable": {
"defaultMessage": "Вимкнути двофакторну автентифікацію"
},
"2fa.disable-confirm": {
"defaultMessage": "Вимкнути 2FA"
},
"2fa.disable-warning": {
"defaultMessage": "Вимкнення двофакторної автентифікації знизить рівень безпеки вашого облікового запису."
},
"2fa.disabled": {
"defaultMessage": "Вимкнено"
},
"2fa.done": {
"defaultMessage": "Резервні коди збережено"
},
"2fa.enable": {
"defaultMessage": "Увімкнути двофакторну автентифікацію"
},
"2fa.enabled": {
"defaultMessage": "Увімкнено"
},
"2fa.enter-code": {
"defaultMessage": "Введіть код підтвердження"
},
"2fa.enter-code-disable": {
"defaultMessage": "Введіть код підтвердження для вимкнення"
},
"2fa.regenerate": {
"defaultMessage": "Згенерувати повторно"
},
"2fa.regenerate-backup": {
"defaultMessage": "Повторно згенерувати резервні коди"
},
"2fa.regenerate-instructions": {
"defaultMessage": "Введіть код підтвердження, щоб згенерувати нові резервні коди. Ваші старі коди стануть недійсними."
},
"2fa.secret-key": {
"defaultMessage": "Секретний ключ"
},
"2fa.setup-instructions": {
"defaultMessage": "Відскануйте цей QR-код у застосунку-автентифікаторі або введіть секретний ключ вручну."
},
"2fa.status": {
"defaultMessage": "Статус"
},
"2fa.title": {
"defaultMessage": "Двофакторна автентифікація"
},
"2fa.verify-enable": {
"defaultMessage": "Підтвердити й увімкнути"
},
"access-list": {
"defaultMessage": "Список доступу"
},
"access-list.access-count": {
"defaultMessage": "{count} {count, plural, one {правило} few {правила} many {правил} other {правила}}"
},
"access-list.auth-count": {
"defaultMessage": "{count} {count, plural, one {користувач} few {користувача} many {користувачів} other {користувача}}"
},
"access-list.help-rules-last": {
"defaultMessage": "Якщо є хоча б одне правило, правило 'заборонити все' буде додано останнім"
},
"access-list.help.rules-order": {
"defaultMessage": "Зверніть увагу: дозволяючі та забороняючі директиви застосовуються в порядку їх визначення."
},
"access-list.pass-auth": {
"defaultMessage": "Передавати авторизацію на upstream-сервер"
},
"access-list.public": {
"defaultMessage": "Загальнодоступний"
},
"access-list.public.subtitle": {
"defaultMessage": "Без аутентифікації"
},
"access-list.rule-source.placeholder": {
"defaultMessage": "192.168.1.100 або 192.168.1.0/24 або 2001:0db8::/32"
},
"access-list.satisfy-any": {
"defaultMessage": "Будь-який збіг"
},
"access-list.subtitle": {
"defaultMessage": "{users} {users, plural, one {користувач} few {користувача} many {користувачів} other {користувача}}, {rules} {rules, plural, one {правило} few {правила} many {правил} other {правила}} - створено: {date}"
},
"access-lists": {
"defaultMessage": "Списки доступу"
},
"action.add": {
"defaultMessage": "Додати"
},
"action.add-location": {
"defaultMessage": "Додати маршрут"
},
"action.allow": {
"defaultMessage": "Дозволити"
},
"action.clear": {
"defaultMessage": "Очистити"
},
"action.close": {
"defaultMessage": "Закрити"
},
"action.delete": {
"defaultMessage": "Видалити"
},
"action.deny": {
"defaultMessage": "Заборонити"
},
"action.disable": {
"defaultMessage": "Вимкнути"
},
"action.download": {
"defaultMessage": "Завантажити"
},
"action.edit": {
"defaultMessage": "Змінити"
},
"action.enable": {
"defaultMessage": "Увімкнути"
},
"action.permissions": {
"defaultMessage": "Дозволи"
},
"action.renew": {
"defaultMessage": "Продовжити"
},
"action.view-details": {
"defaultMessage": "Переглянути відомості"
},
"auditlogs": {
"defaultMessage": "Журнал аудиту"
},
"auto": {
"defaultMessage": "Автоматично"
},
"cancel": {
"defaultMessage": "Скасувати"
},
"certificate": {
"defaultMessage": "Сертифікат"
},
"certificate.custom-certificate": {
"defaultMessage": "Сертифікат"
},
"certificate.custom-certificate-key": {
"defaultMessage": "Ключ сертифіката"
},
"certificate.custom-intermediate": {
"defaultMessage": "Проміжний сертифікат"
},
"certificate.in-use": {
"defaultMessage": "Використовується"
},
"certificate.none.subtitle": {
"defaultMessage": "Сертифікат не призначено"
},
"certificate.none.subtitle.for-http": {
"defaultMessage": "Цей хост не використовуватиме HTTPS"
},
"certificate.none.title": {
"defaultMessage": "Немає"
},
"certificate.not-in-use": {
"defaultMessage": "Не використовується"
},
"certificate.renew": {
"defaultMessage": "Продовжити сертифікат"
},
"certificates": {
"defaultMessage": "Сертифікати"
},
"certificates.custom": {
"defaultMessage": "Власний сертифікат"
},
"certificates.custom.warning": {
"defaultMessage": "Файли ключів, захищені паролем, не підтримуються."
},
"certificates.dns.credentials": {
"defaultMessage": "Вміст файлу облікових даних"
},
"certificates.dns.credentials-note": {
"defaultMessage": "Цей плагін потребує файл конфігурації, що містить API-токен або інші облікові дані вашого провайдера"
},
"certificates.dns.credentials-warning": {
"defaultMessage": "Ці дані зберігатимуться у незашифрованому вигляді в базі даних та файлі!"
},
"certificates.dns.propagation-seconds": {
"defaultMessage": "Очікування поширення (сек.)"
},
"certificates.dns.propagation-seconds-note": {
"defaultMessage": "Залиште порожнім для значення за замовчуванням плагіна. Секунди очікування поширення DNS."
},
"certificates.dns.provider": {
"defaultMessage": "DNS-провайдер"
},
"certificates.dns.provider.placeholder": {
"defaultMessage": "Виберіть провайдера…"
},
"certificates.dns.warning": {
"defaultMessage": "Цей розділ потребує знань про Certbot та його DNS-плагіни. Будь ласка, зверніться до документації відповідних плагінів."
},
"certificates.http.reachability-404": {
"defaultMessage": "На цьому домені знайдено сервер, але, схоже, це не Nginx Proxy Manager. Переконайтеся, що ваш домен вказує на IP-адресу, де запущено ваш екземпляр NPM."
},
"certificates.http.reachability-failed-to-check": {
"defaultMessage": "Не вдалося перевірити доступність через помилку зв'язку з site24x7.com."
},
"certificates.http.reachability-not-resolved": {
"defaultMessage": "На цьому домені недоступний сервер. Переконайтеся, що домен існує та вказує на IP-адресу, де запущено ваш екземпляр NPM, і за потреби порт 80 проброшено на вашому роутері."
},
"certificates.http.reachability-ok": {
"defaultMessage": "Сервер доступний, випуск сертифікатів можливий."
},
"certificates.http.reachability-other": {
"defaultMessage": "На цьому домені знайдено сервер, але він повернув неочікуваний статус-код {code}. Це сервер NPM? Переконайтеся, що ваш домен вказує на IP-адресу, де запущено ваш екземпляр NPM."
},
"certificates.http.reachability-wrong-data": {
"defaultMessage": "На цьому домені знайдено сервер, але він повернув неочікувані дані. Це сервер NPM? Переконайтеся, що ваш домен вказує на IP-адресу, де запущено ваш екземпляр NPM."
},
"certificates.http.test-results": {
"defaultMessage": "Результати перевірки"
},
"certificates.http.warning": {
"defaultMessage": "Ці домени мають бути налаштовані та вказувати на цей екземпляр."
},
"certificates.key-type": {
"defaultMessage": "Тип ключа"
},
"certificates.key-type-description": {
"defaultMessage": "RSA широко сумісний, ECDSA швидший і безпечніший, але може не підтримуватися старими системами"
},
"certificates.key-type-ecdsa": {
"defaultMessage": "ECDSA 256"
},
"certificates.key-type-rsa": {
"defaultMessage": "RSA 2048"
},
"certificates.request.subtitle": {
"defaultMessage": "через Let's Encrypt"
},
"certificates.request.title": {
"defaultMessage": "Отримати новий сертифікат"
},
"column.access": {
"defaultMessage": "Доступ"
},
"column.authorization": {
"defaultMessage": "Авторизація"
},
"column.authorizations": {
"defaultMessage": "Авторизації"
},
"column.custom-locations": {
"defaultMessage": "Власні маршрути"
},
"column.destination": {
"defaultMessage": "Призначення"
},
"column.details": {
"defaultMessage": "Відомості"
},
"column.email": {
"defaultMessage": "Ел. пошта"
},
"column.event": {
"defaultMessage": "Подія"
},
"column.expires": {
"defaultMessage": "Закінчується"
},
"column.http-code": {
"defaultMessage": "HTTP-код"
},
"column.incoming-port": {
"defaultMessage": "Вхідний порт"
},
"column.name": {
"defaultMessage": "Ім'я"
},
"column.protocol": {
"defaultMessage": "Протокол"
},
"column.provider": {
"defaultMessage": "Провайдер"
},
"column.roles": {
"defaultMessage": "Ролі"
},
"column.rules": {
"defaultMessage": "Правила"
},
"column.satisfy": {
"defaultMessage": "Умови"
},
"column.satisfy-all": {
"defaultMessage": "Усі"
},
"column.satisfy-any": {
"defaultMessage": "Будь-яке"
},
"column.scheme": {
"defaultMessage": "Схема"
},
"column.source": {
"defaultMessage": "Джерело"
},
"column.ssl": {
"defaultMessage": "SSL"
},
"column.status": {
"defaultMessage": "Статус"
},
"created-on": {
"defaultMessage": "Створено: {date}"
},
"dashboard": {
"defaultMessage": "Огляд"
},
"dead-host": {
"defaultMessage": "404-хост"
},
"dead-hosts": {
"defaultMessage": "404-хости"
},
"dead-hosts.count": {
"defaultMessage": "{count} {count, plural, one {404-хост} few {404-хоста} many {404-хостів} other {404-хоста}}"
},
"disabled": {
"defaultMessage": "Вимкнено"
},
"domain-names": {
"defaultMessage": "Домени"
},
"domain-names.max": {
"defaultMessage": "Максимум {count} доменів"
},
"domain-names.placeholder": {
"defaultMessage": "Почніть вводити, щоб додати домен..."
},
"domain-names.wildcards-not-permitted": {
"defaultMessage": "Підстановчі домени не дозволені для цього типу"
},
"domain-names.wildcards-not-supported": {
"defaultMessage": "Підстановчі домени не підтримуються цим центром сертифікації"
},
"domains.advanced": {
"defaultMessage": "Розширені налаштування"
},
"domains.force-ssl": {
"defaultMessage": "Завжди SSL"
},
"domains.hsts-enabled": {
"defaultMessage": "Підтримка HSTS"
},
"domains.hsts-subdomains": {
"defaultMessage": "Піддомени HSTS"
},
"domains.http2-support": {
"defaultMessage": "Підтримка HTTP/2"
},
"domains.trust-forwarded-proto": {
"defaultMessage": "Довіряти заголовкам X-Forwarded-Proto від upstream-сервера"
},
"domains.use-dns": {
"defaultMessage": "Перевірка через DNS"
},
"email-address": {
"defaultMessage": "Адреса ел. пошти"
},
"empty-search": {
"defaultMessage": "Нічого не знайдено"
},
"empty-subtitle": {
"defaultMessage": "Чому б не створити його?"
},
"enabled": {
"defaultMessage": "Увімкнено"
},
"error.access.at-least-one": {
"defaultMessage": "Потрібна хоча б одна авторизація або одне правило доступу"
},
"error.access.duplicate-usernames": {
"defaultMessage": "Імена користувачів для авторизації мають бути унікальними"
},
"error.invalid-auth": {
"defaultMessage": "Невірна адреса ел. пошти або пароль"
},
"error.invalid-domain": {
"defaultMessage": "Невірний домен: {domain}"
},
"error.invalid-email": {
"defaultMessage": "Невірна адреса ел. пошти"
},
"error.max-character-length": {
"defaultMessage": "Максимальна довжина {max} {max, plural, one {символ} few {символи} many {символів} other {символа}}"
},
"error.max-domains": {
"defaultMessage": "Занадто багато доменів, максимум {max}"
},
"error.maximum": {
"defaultMessage": "Максимум {max}"
},
"error.min-character-length": {
"defaultMessage": "Мінімальна довжина {min} {min, plural, one {символ} few {символи} many {символів} other {символа}}"
},
"error.minimum": {
"defaultMessage": "Мінімум {min}"
},
"error.passwords-must-match": {
"defaultMessage": "Паролі повинні збігатися"
},
"error.required": {
"defaultMessage": "Обов'язкове поле"
},
"expires.on": {
"defaultMessage": "Закінчується: {date}"
},
"footer.github-fork": {
"defaultMessage": "Зробити форк на GitHub"
},
"host.flags.block-exploits": {
"defaultMessage": "Блокувати відомі експлойти"
},
"host.flags.cache-assets": {
"defaultMessage": "Кешувати ресурси"
},
"host.flags.preserve-path": {
"defaultMessage": "Зберігати шлях"
},
"host.flags.protocols": {
"defaultMessage": "Протоколи"
},
"host.flags.websockets-upgrade": {
"defaultMessage": "Підтримка WebSocket"
},
"host.forward-port": {
"defaultMessage": "Порт перенаправлення"
},
"host.forward-scheme": {
"defaultMessage": "Схема"
},
"hosts": {
"defaultMessage": "Хости"
},
"http-only": {
"defaultMessage": "Тільки HTTP"
},
"lets-encrypt": {
"defaultMessage": "Let's Encrypt"
},
"lets-encrypt-via-dns": {
"defaultMessage": "Let's Encrypt через DNS"
},
"lets-encrypt-via-http": {
"defaultMessage": "Let's Encrypt через HTTP"
},
"loading": {
"defaultMessage": "Завантаження…"
},
"location.advanced-config": {
"defaultMessage": "Має власну конфігурацію Nginx"
},
"location.filter": {
"defaultMessage": "Фільтрувати за шляхом або призначенням"
},
"login.2fa-code": {
"defaultMessage": "Код підтвердження"
},
"login.2fa-code-placeholder": {
"defaultMessage": "Введіть код"
},
"login.2fa-description": {
"defaultMessage": "Введіть код із застосунку-автентифікатора"
},
"login.2fa-title": {
"defaultMessage": "Двофакторна автентифікація"
},
"login.2fa-verify": {
"defaultMessage": "Підтвердити"
},
"login.title": {
"defaultMessage": "Авторизація"
},
"nginx-config.label": {
"defaultMessage": "Власна Nginx-конфігурація"
},
"nginx-config.placeholder": {
"defaultMessage": "# Введіть тут свою Nginx-конфігурацію, будьте обережні!"
},
"no-permission-error": {
"defaultMessage": "У вас немає доступу для перегляду."
},
"notfound.action": {
"defaultMessage": "Повернутися на головну"
},
"notfound.content": {
"defaultMessage": "Вибачте, але сторінку, яку ви шукаєте, не знайдено"
},
"notfound.title": {
"defaultMessage": "Ой… Ви потрапили на сторінку помилки"
},
"notification.error": {
"defaultMessage": "Помилка"
},
"notification.object-deleted": {
"defaultMessage": "{object} видалено"
},
"notification.object-disabled": {
"defaultMessage": "{object} вимкнено"
},
"notification.object-enabled": {
"defaultMessage": "{object} увімкнено"
},
"notification.object-renewed": {
"defaultMessage": "{object} продовжено"
},
"notification.object-saved": {
"defaultMessage": "{object} збережено"
},
"notification.success": {
"defaultMessage": "Успішно"
},
"object.actions-title": {
"defaultMessage": "{object} #{id}"
},
"object.add": {
"defaultMessage": "Додати {object}"
},
"object.delete": {
"defaultMessage": "Видалити {object}"
},
"object.delete.content": {
"defaultMessage": "Ви впевнені, що хочете видалити {object}?"
},
"object.edit": {
"defaultMessage": "Змінити {object}"
},
"object.empty": {
"defaultMessage": "{objects} відсутні"
},
"object.event.created": {
"defaultMessage": "{object} створено"
},
"object.event.deleted": {
"defaultMessage": "{object} видалено"
},
"object.event.disabled": {
"defaultMessage": "{object} вимкнено"
},
"object.event.enabled": {
"defaultMessage": "{object} увімкнено"
},
"object.event.renewed": {
"defaultMessage": "{object} продовжено"
},
"object.event.updated": {
"defaultMessage": "{object} оновлено"
},
"offline": {
"defaultMessage": "Офлайн"
},
"online": {
"defaultMessage": "Онлайн"
},
"options": {
"defaultMessage": "Параметри"
},
"password": {
"defaultMessage": "Пароль"
},
"password.generate": {
"defaultMessage": "Згенерувати випадковий пароль"
},
"password.hide": {
"defaultMessage": "Сховати пароль"
},
"password.show": {
"defaultMessage": "Показати пароль"
},
"permissions.hidden": {
"defaultMessage": "Приховано"
},
"permissions.manage": {
"defaultMessage": "Керування"
},
"permissions.view": {
"defaultMessage": "Тільки перегляд"
},
"permissions.visibility.all": {
"defaultMessage": "Усі елементи"
},
"permissions.visibility.title": {
"defaultMessage": "Видимість елементів"
},
"permissions.visibility.user": {
"defaultMessage": "Створені елементи"
},
"proxy-host": {
"defaultMessage": "Проксі-хост"
},
"proxy-host.forward-host": {
"defaultMessage": "Хост / IP перенаправлення"
},
"proxy-hosts": {
"defaultMessage": "Проксі-хости"
},
"proxy-hosts.count": {
"defaultMessage": "{count} {count, plural, one {проксі-хост} few {проксі-хоста} many {проксі-хостів} other {проксі-хоста}}"
},
"public": {
"defaultMessage": "Загальнодоступний"
},
"redirection-host": {
"defaultMessage": "Редирект-хост"
},
"redirection-host.forward-domain": {
"defaultMessage": "Домен перенаправлення"
},
"redirection-host.forward-http-code": {
"defaultMessage": "HTTP-код"
},
"redirection-hosts": {
"defaultMessage": "Редирект-хости"
},
"redirection-hosts.count": {
"defaultMessage": "{count} {count, plural, one {редирект-хост} few {редирект-хоста} many {редирект-хостів} other {редирект-хоста}}"
},
"redirection-hosts.http-code.300": {
"defaultMessage": "300 Кілька варіантів"
},
"redirection-hosts.http-code.301": {
"defaultMessage": "301 Переміщено назавжди"
},
"redirection-hosts.http-code.302": {
"defaultMessage": "302 Тимчасово переміщено"
},
"redirection-hosts.http-code.303": {
"defaultMessage": "303 Переглянути інше"
},
"redirection-hosts.http-code.307": {
"defaultMessage": "307 Тимчасове перенаправлення"
},
"redirection-hosts.http-code.308": {
"defaultMessage": "308 Постійне перенаправлення"
},
"role.admin": {
"defaultMessage": "Адміністратор"
},
"role.standard-user": {
"defaultMessage": "Звичайний користувач"
},
"save": {
"defaultMessage": "Зберегти"
},
"setting": {
"defaultMessage": "Налаштування"
},
"settings": {
"defaultMessage": "Налаштування"
},
"settings.default-site": {
"defaultMessage": "Сторінка за замовчуванням"
},
"settings.default-site.404": {
"defaultMessage": "404-сторінка"
},
"settings.default-site.444": {
"defaultMessage": "Немає відповіді (444)"
},
"settings.default-site.congratulations": {
"defaultMessage": "Сторінка привітання"
},
"settings.default-site.description": {
"defaultMessage": "Що показувати, коли Nginx отримує невідомий хост"
},
"settings.default-site.html": {
"defaultMessage": "Власний HTML"
},
"settings.default-site.html.placeholder": {
"defaultMessage": ""
},
"settings.default-site.redirect": {
"defaultMessage": "Перенаправлення"
},
"setup.preamble": {
"defaultMessage": "Почніть із створення облікового запису адміністратора."
},
"setup.title": {
"defaultMessage": "Ласкаво просимо!"
},
"sign-in": {
"defaultMessage": "Увійти"
},
"ssl-certificate": {
"defaultMessage": "SSL-сертифікат"
},
"stream": {
"defaultMessage": "Потік"
},
"stream.forward-host": {
"defaultMessage": "Хост перенаправлення"
},
"stream.forward-host.placeholder": {
"defaultMessage": "example.com або 10.0.0.1 або 2001:db8:3333:4444:5555:6666:7777:8888"
},
"stream.incoming-port": {
"defaultMessage": "Вхідний порт"
},
"streams": {
"defaultMessage": "Потоки"
},
"streams.count": {
"defaultMessage": "{count} {count, plural, one {потік} few {потоки} many {потоків} other {потока}}"
},
"streams.tcp": {
"defaultMessage": "TCP"
},
"streams.udp": {
"defaultMessage": "UDP"
},
"test": {
"defaultMessage": "Перевірити"
},
"update-available": {
"defaultMessage": "Доступне оновлення: {latestVersion}"
},
"user": {
"defaultMessage": "Користувач"
},
"user.change-password": {
"defaultMessage": "Змінити пароль"
},
"user.confirm-password": {
"defaultMessage": "Повторіть пароль"
},
"user.current-password": {
"defaultMessage": "Поточний пароль"
},
"user.edit-profile": {
"defaultMessage": "Змінити профіль"
},
"user.full-name": {
"defaultMessage": "Повне ім'я"
},
"user.login-as": {
"defaultMessage": "Увійти як {name}"
},
"user.logout": {
"defaultMessage": "Вийти"
},
"user.new-password": {
"defaultMessage": "Новий пароль"
},
"user.nickname": {
"defaultMessage": "Псевдонім"
},
"user.set-password": {
"defaultMessage": "Задати пароль"
},
"user.set-permissions": {
"defaultMessage": "Задати дозволи для {name}"
},
"user.switch-dark": {
"defaultMessage": "Увімкнути темну тему"
},
"user.switch-light": {
"defaultMessage": "Увімкнути світлу тему"
},
"user.two-factor": {
"defaultMessage": "Двофакторна автентифікація"
},
"username": {
"defaultMessage": "Ім'я користувача"
},
"users": {
"defaultMessage": "Користувачі"
}
}
+1 -5
View File
@@ -70,11 +70,7 @@ const DNSCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPro
<label htmlFor="keyType" className="form-label">
<T id="certificates.key-type" />
</label>
<select
id="keyType"
className="form-select"
{...field}
>
<select id="keyType" className="form-select" {...field}>
<option value="rsa">
<T id="certificates.key-type-rsa" />
</option>
+1 -5
View File
@@ -151,11 +151,7 @@ const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPr
<label htmlFor="keyType" className="form-label">
<T id="certificates.key-type" />
</label>
<select
id="keyType"
className="form-select"
{...field}
>
<select id="keyType" className="form-select" {...field}>
<option value="rsa">
<T id="certificates.key-type-rsa" />
</option>
+21 -7
View File
@@ -162,7 +162,9 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
required
{...field}
>
<option value="auto"><T id="auto" /></option>
<option value="auto">
<T id="auto" />
</option>
<option value="http">http</option>
<option value="https">https</option>
</select>
@@ -224,12 +226,24 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
required
{...field}
>
<option value="300"><T id="redirection-hosts.http-code.300" /></option>
<option value="301"><T id="redirection-hosts.http-code.301" /></option>
<option value="302"><T id="redirection-hosts.http-code.302" /></option>
<option value="303"><T id="redirection-hosts.http-code.303" /></option>
<option value="307"><T id="redirection-hosts.http-code.307" /></option>
<option value="308"><T id="redirection-hosts.http-code.308" /></option>
<option value="300">
<T id="redirection-hosts.http-code.300" />
</option>
<option value="301">
<T id="redirection-hosts.http-code.301" />
</option>
<option value="302">
<T id="redirection-hosts.http-code.302" />
</option>
<option value="303">
<T id="redirection-hosts.http-code.303" />
</option>
<option value="307">
<T id="redirection-hosts.http-code.307" />
</option>
<option value="308">
<T id="redirection-hosts.http-code.308" />
</option>
</select>
{form.errors.forwardHttpCode ? (
<div className="invalid-feedback">
+3 -1
View File
@@ -154,7 +154,9 @@ const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => {
type="text"
className={`form-control ${form.errors.forwardingHost && form.touched.forwardingHost ? "is-invalid" : ""}`}
required
placeholder={intl.formatMessage({ id: "stream.forward-host.placeholder" })}
placeholder={intl.formatMessage({
id: "stream.forward-host.placeholder",
})}
{...field}
/>
{form.errors.forwardingHost ? (
@@ -0,0 +1,5 @@
.qrcode {
max-width: "200px";
height: "auto";
border: 6px solid #fff;
}
+5 -19
View File
@@ -3,16 +3,12 @@ import { Field, Form, Formik } from "formik";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import {
disable2FA,
enable2FA,
get2FAStatus,
regenerateBackupCodes,
start2FASetup,
} from "src/api/backend";
import QRCode from "react-qr-code";
import { disable2FA, enable2FA, get2FAStatus, regenerateBackupCodes, start2FASetup } from "src/api/backend";
import { Button } from "src/components";
import { T } from "src/locale";
import { validateString } from "src/modules/Validations";
import styles from "./TwoFactorModal.module.css";
type Step = "loading" | "status" | "setup" | "verify" | "backup" | "disable";
@@ -137,12 +133,7 @@ const TwoFactorModal = EasyModal.create(({ id, visible, remove }: Props) => {
)}
</div>
{!isEnabled ? (
<Button
fullWidth
color="azure"
onClick={handleStartSetup}
isLoading={isSubmitting}
>
<Button fullWidth color="azure" onClick={handleStartSetup} isLoading={isSubmitting}>
<T id="2fa.enable" />
</Button>
) : (
@@ -166,12 +157,7 @@ const TwoFactorModal = EasyModal.create(({ id, visible, remove }: Props) => {
<T id="2fa.setup-instructions" />
</p>
<div className="text-center mb-3">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(setupData.otpauthUrl)}`}
alt="QR Code"
className="img-fluid"
style={{ maxWidth: "200px" }}
/>
<QRCode value={setupData.otpauthUrl} size={200} className={styles.qrcode} />
</div>
<label className="mb-3 d-block">
<span className="form-label small text-muted">
+5 -5
View File
@@ -1,8 +1,9 @@
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { createColumnHelper, useTable } from "@tanstack/react-table";
import { useMemo } from "react";
import type { AccessList } from "src/api/backend";
import { EmptyData, GravatarFormatter, HasPermission, ValueWithDateFormatter } from "src/components";
import { type Features, features } from "src/components/Table/features";
import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale";
import { ACCESS_LISTS, MANAGE } from "src/modules/Permissions";
@@ -16,7 +17,7 @@ interface Props {
onNew?: () => void;
}
export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, onNew }: Props) {
const columnHelper = createColumnHelper<AccessList>();
const columnHelper = createColumnHelper<Features, AccessList>();
const columns = useMemo(
() => [
columnHelper.accessor((row: any) => row.owner, {
@@ -114,11 +115,10 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
[columnHelper, onEdit, onDelete],
);
const tableInstance = useReactTable<AccessList>({
const tableInstance = useTable({
features,
columns,
data,
getCoreRowModel: getCoreRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
+7 -7
View File
@@ -1,7 +1,8 @@
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { createColumnHelper, useTable } from "@tanstack/react-table";
import { useMemo } from "react";
import type { AuditLog } from "src/api/backend";
import { EventFormatter, GravatarFormatter } from "src/components";
import { type Features, features } from "src/components/Table/features";
import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale";
@@ -11,10 +12,10 @@ interface Props {
onSelectItem?: (id: number) => void;
}
export default function Table({ data, isFetching, onSelectItem }: Props) {
const columnHelper = createColumnHelper<AuditLog>();
const columnHelper = createColumnHelper<Features, AuditLog>();
const columns = useMemo(
() => [
columnHelper.accessor((row: AuditLog) => row.user, {
columnHelper.accessor((row: any) => row.user, {
id: "user.avatar",
cell: (info: any) => {
const value = info.getValue();
@@ -24,7 +25,7 @@ export default function Table({ data, isFetching, onSelectItem }: Props) {
className: "w-1",
},
}),
columnHelper.accessor((row: AuditLog) => row, {
columnHelper.accessor((row: any) => row, {
id: "objectType",
header: intl.formatMessage({ id: "column.event" }),
cell: (info: any) => {
@@ -55,11 +56,10 @@ export default function Table({ data, isFetching, onSelectItem }: Props) {
[columnHelper, onSelectItem],
);
const tableInstance = useReactTable<AuditLog>({
const tableInstance = useTable({
features,
columns,
data,
getCoreRowModel: getCoreRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
+5 -5
View File
@@ -1,5 +1,5 @@
import { IconDotsVertical, IconDownload, IconRefresh, IconTrash } from "@tabler/icons-react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { createColumnHelper, useTable } from "@tanstack/react-table";
import { useMemo } from "react";
import type { Certificate } from "src/api/backend";
import {
@@ -10,6 +10,7 @@ import {
GravatarFormatter,
HasPermission,
} from "src/components";
import { type Features, features } from "src/components/Table/features";
import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale";
import { showCustomCertificateModal, showDNSCertificateModal, showHTTPCertificateModal } from "src/modals";
@@ -24,7 +25,7 @@ interface Props {
onDownload?: (id: number) => void;
}
export default function Table({ data, isFetching, onDelete, onRenew, onDownload, isFiltered }: Props) {
const columnHelper = createColumnHelper<Certificate>();
const columnHelper = createColumnHelper<Features, Certificate>();
const columns = useMemo(
() => [
columnHelper.accessor((row: any) => row.owner, {
@@ -164,11 +165,10 @@ export default function Table({ data, isFetching, onDelete, onRenew, onDownload,
[columnHelper, onDelete, onRenew, onDownload],
);
const tableInstance = useReactTable<Certificate>({
const tableInstance = useTable({
features,
columns,
data,
getCoreRowModel: getCoreRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
+1 -3
View File
@@ -193,9 +193,7 @@ export default function Login() {
</div>
</div>
<div className="card card-md">
<div className="card-body">
{twoFactorChallenge ? <TwoFactorForm /> : <LoginForm />}
</div>
<div className="card-body">{twoFactorChallenge ? <TwoFactorForm /> : <LoginForm />}</div>
</div>
<div className="text-center text-secondary mt-3">{getVersion()}</div>
</div>
+6 -13
View File
@@ -1,11 +1,5 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import {
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { createColumnHelper, type SortingState, useTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import type { DeadHost } from "src/api/backend";
import {
@@ -16,6 +10,7 @@ import {
HasPermission,
TrueFalseFormatter,
} from "src/components";
import { type Features, features } from "src/components/Table/features";
import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale";
import { DEAD_HOSTS, MANAGE } from "src/modules/Permissions";
@@ -30,7 +25,7 @@ interface Props {
onNew?: () => void;
}
export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) {
const columnHelper = createColumnHelper<DeadHost>();
const columnHelper = createColumnHelper<Features, DeadHost>();
const columns = useMemo(
() => [
columnHelper.accessor((row: any) => row.owner, {
@@ -47,7 +42,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
columnHelper.accessor((row: any) => row, {
id: "domainNames",
header: intl.formatMessage({ id: "column.source" }),
sortingFn: (a, b) => {
sortFn: (a, b) => {
const aVal = a.original.domainNames?.[0] ?? "";
const bVal = b.original.domainNames?.[0] ?? "";
return aVal.localeCompare(bVal);
@@ -143,14 +138,12 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
const [sorting, setSorting] = useState<SortingState>([]);
const tableInstance = useReactTable<DeadHost>({
const tableInstance = useTable({
features,
columns,
data,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
+7 -14
View File
@@ -1,11 +1,5 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import {
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { createColumnHelper, type SortingState, useTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import type { ProxyHost } from "src/api/backend";
import {
@@ -17,6 +11,7 @@ import {
HasPermission,
TrueFalseFormatter,
} from "src/components";
import { type Features, features } from "src/components/Table/features";
import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale";
import { MANAGE, PROXY_HOSTS } from "src/modules/Permissions";
@@ -31,7 +26,7 @@ interface Props {
onNew?: () => void;
}
export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) {
const columnHelper = createColumnHelper<ProxyHost>();
const columnHelper = createColumnHelper<Features, ProxyHost>();
const columns = useMemo(
() => [
columnHelper.accessor((row: any) => row.owner, {
@@ -48,7 +43,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
columnHelper.accessor((row: any) => row, {
id: "domainNames",
header: intl.formatMessage({ id: "column.source" }),
sortingFn: (a, b) => {
sortFn: (a, b) => {
const aVal = a.original.domainNames?.[0] ?? "";
const bVal = b.original.domainNames?.[0] ?? "";
return aVal.localeCompare(bVal);
@@ -61,7 +56,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
columnHelper.accessor((row: any) => row, {
id: "forwardHost",
header: intl.formatMessage({ id: "column.destination" }),
sortingFn: (a, b) => {
sortFn: (a, b) => {
const aVal = `${a.original.forwardHost}:${a.original.forwardPort}`;
const bVal = `${b.original.forwardHost}:${b.original.forwardPort}`;
return aVal.localeCompare(bVal);
@@ -165,14 +160,12 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
const [sorting, setSorting] = useState<SortingState>([]);
const tableInstance = useReactTable<ProxyHost>({
const tableInstance = useTable({
features,
columns,
data,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
@@ -1,11 +1,5 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import {
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { createColumnHelper, type SortingState, useTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import type { RedirectionHost } from "src/api/backend";
import {
@@ -16,6 +10,7 @@ import {
HasPermission,
TrueFalseFormatter,
} from "src/components";
import { type Features, features } from "src/components/Table/features";
import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale";
import { MANAGE, REDIRECTION_HOSTS } from "src/modules/Permissions";
@@ -30,7 +25,7 @@ interface Props {
onNew?: () => void;
}
export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) {
const columnHelper = createColumnHelper<RedirectionHost>();
const columnHelper = createColumnHelper<Features, RedirectionHost>();
const columns = useMemo(
() => [
columnHelper.accessor((row: any) => row.owner, {
@@ -47,7 +42,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
columnHelper.accessor((row: any) => row, {
id: "domainNames",
header: intl.formatMessage({ id: "column.source" }),
sortingFn: (a, b) => {
sortFn: (a, b) => {
const aVal = a.original.domainNames?.[0] ?? "";
const bVal = b.original.domainNames?.[0] ?? "";
return aVal.localeCompare(bVal);
@@ -164,14 +159,12 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
const [sorting, setSorting] = useState<SortingState>([]);
const tableInstance = useReactTable<RedirectionHost>({
const tableInstance = useTable({
features,
columns,
data,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
+7 -14
View File
@@ -1,11 +1,5 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import {
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import { createColumnHelper, type SortingState, useTable } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import type { Stream } from "src/api/backend";
import {
@@ -16,6 +10,7 @@ import {
TrueFalseFormatter,
ValueWithDateFormatter,
} from "src/components";
import { type Features, features } from "src/components/Table/features";
import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale";
import { MANAGE, STREAMS } from "src/modules/Permissions";
@@ -30,7 +25,7 @@ interface Props {
onNew?: () => void;
}
export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, onDisableToggle, onNew }: Props) {
const columnHelper = createColumnHelper<Stream>();
const columnHelper = createColumnHelper<Features, Stream>();
const columns = useMemo(
() => [
columnHelper.accessor((row: any) => row.owner, {
@@ -47,7 +42,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
columnHelper.accessor((row: any) => row, {
id: "incomingPort",
header: intl.formatMessage({ id: "column.incoming-port" }),
sortingFn: (a, b) => (a.original.incomingPort ?? 0) - (b.original.incomingPort ?? 0),
sortFn: (a, b) => (a.original.incomingPort ?? 0) - (b.original.incomingPort ?? 0),
cell: (info: any) => {
const value = info.getValue();
return <ValueWithDateFormatter value={value.incomingPort} createdOn={value.createdOn} />;
@@ -56,7 +51,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
columnHelper.accessor((row: any) => row, {
id: "forwardHttpCode",
header: intl.formatMessage({ id: "column.destination" }),
sortingFn: (a, b) => {
sortFn: (a, b) => {
const aVal = `${a.original.forwardingHost}:${a.original.forwardingPort}`;
const bVal = `${b.original.forwardingHost}:${b.original.forwardingPort}`;
return aVal.localeCompare(bVal);
@@ -174,14 +169,12 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
const [sorting, setSorting] = useState<SortingState>([]);
const tableInstance = useReactTable<Stream>({
const tableInstance = useTable({
features,
columns,
data,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
+5 -5
View File
@@ -7,7 +7,7 @@ import {
IconShield,
IconTrash,
} from "@tabler/icons-react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { createColumnHelper, useTable } from "@tanstack/react-table";
import { useMemo } from "react";
import type { User } from "src/api/backend";
import {
@@ -18,6 +18,7 @@ import {
TrueFalseFormatter,
ValueWithDateFormatter,
} from "src/components";
import { type Features, features } from "src/components/Table/features";
import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale";
@@ -47,7 +48,7 @@ export default function Table({
onNewUser,
onLoginAs,
}: Props) {
const columnHelper = createColumnHelper<User>();
const columnHelper = createColumnHelper<Features, User>();
const columns = useMemo(
() => [
columnHelper.accessor((row: any) => row, {
@@ -216,11 +217,10 @@ export default function Table({
],
);
const tableInstance = useReactTable<User>({
const tableInstance = useTable({
features,
columns,
data,
getCoreRowModel: getCoreRowModel(),
rowCount: data.length,
meta: {
isFetching,
},
+870 -679
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -4,21 +4,21 @@
"description": "",
"main": "index.js",
"dependencies": {
"@cypress/grep": "^6.0.0",
"@cypress/grep": "^6.0.3",
"@jc21/cypress-swagger-validation": "^0.3.2",
"@quobix/vacuum": "^0.26.4",
"axios": "^1.16.0",
"axios": "^1.19.0",
"chalk": "^5.6.2",
"cypress": "^15.15.0",
"cypress": "^15.21.1",
"cypress-multi-reporters": "^2.0.5",
"cypress-wait-until": "^3.0.2",
"eslint": "^10.3.0",
"eslint": "^10.9.1",
"eslint-plugin-align-assignments": "^1.1.2",
"eslint-plugin-chai-friendly": "^1.2.0",
"eslint-plugin-cypress": "^6.4.1",
"form-data": "^4.0.5",
"eslint-plugin-chai-friendly": "^1.2.1",
"eslint-plugin-cypress": "^7.0.1",
"form-data": "^4.0.6",
"lodash": "^4.18.1",
"mocha": "^11.7.5",
"mocha": "^11.8.0",
"mocha-junit-reporter": "^2.2.1"
},
"scripts": {
+103 -48
View File
@@ -80,10 +80,10 @@
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@cypress/grep@^6.0.0":
version "6.0.1"
resolved "https://registry.yarnpkg.com/@cypress/grep/-/grep-6.0.1.tgz#33fb03fd860f3cc4d3f4940c54ad2f6426d0dfea"
integrity sha512-nxTBNid7mKeDfM5thNPc4YH6q+1BoDKFwDLcJJLvosNbJ9WcsRZAU9F6gUQyS/BWhKmRWn+st3JdVtokk8aMSw==
"@cypress/grep@^6.0.3":
version "6.0.3"
resolved "https://registry.yarnpkg.com/@cypress/grep/-/grep-6.0.3.tgz#c8023bbf996e406fe932e5861674ded0499e8fda"
integrity sha512-9pm3HVlbXHp9K9XU3hrnY7OBXsCGKGeCZ49n7yOrXbBlPv6FdSq3ub1KDJQ0AvtIi6A/3zCzwU0uZzq9skG6Ag==
dependencies:
debug "^4.3.4"
find-test-names "^1.28.18"
@@ -141,10 +141,10 @@
debug "^4.3.1"
minimatch "^10.2.4"
"@eslint/config-helpers@^0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz#ef9a36881d39dfd5dbeac22b0da997fabfb08b03"
integrity sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==
"@eslint/config-helpers@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
dependencies:
"@eslint/core" "^1.2.1"
@@ -386,10 +386,10 @@ ansi-styles@^6.1.0, ansi-styles@^6.2.1, ansi-styles@^6.2.3:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
arch@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11"
integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==
arch@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/arch/-/arch-3.0.0.tgz#a44e7077da4615fc5f1e3da21fbfc201d2c1817c"
integrity sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==
argparse@^2.0.1:
version "2.0.1"
@@ -433,7 +433,17 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.2.tgz#0aa167216965ac9474ccfa83892cfb6b3e1e52ef"
integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==
axios@^1.16.0, axios@^1.7.7:
axios@^1.19.0:
version "1.19.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.19.0.tgz#ddf864d4c8233c0e6873746ab59361537d05ad39"
integrity sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==
dependencies:
follow-redirects "^1.16.0"
form-data "^4.0.6"
https-proxy-agent "^5.0.1"
proxy-from-env "^2.1.0"
axios@^1.7.7:
version "1.16.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.1.tgz#517e29291d19d6e8cf919ff264f4fe157261ba12"
integrity sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==
@@ -489,6 +499,13 @@ brace-expansion@^5.0.5:
dependencies:
balanced-match "^4.0.2"
brace-expansion@^5.0.8:
version "5.0.9"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
dependencies:
balanced-match "^4.0.2"
braces@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
@@ -580,6 +597,14 @@ chownr@^3.0.0:
resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4"
integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==
chrome-remote-interface@0.33.3:
version "0.33.3"
resolved "https://registry.yarnpkg.com/chrome-remote-interface/-/chrome-remote-interface-0.33.3.tgz#d8b4339f487b460a9461af7355bda98054e8e1a4"
integrity sha512-zNnn0prUL86Teru6UCAZ1yU1XeXljHl3gj7OrfPcarEfU62OUU4IujDPdTDW3dAWwRqN3ZMG/Chhkh2gPL/wiw==
dependencies:
commander "2.11.x"
ws "^7.2.0"
ci-info@^4.1.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.4.0.tgz#7d54eff9f54b45b62401c26032696eb59c8bd18c"
@@ -647,6 +672,11 @@ combined-stream@^1.0.8, combined-stream@~1.0.6:
dependencies:
delayed-stream "~1.0.0"
commander@2.11.x:
version "2.11.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563"
integrity sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==
commander@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
@@ -690,22 +720,23 @@ cypress-wait-until@^3.0.2:
resolved "https://registry.yarnpkg.com/cypress-wait-until/-/cypress-wait-until-3.0.2.tgz#c90dddfa4c46a2c422f5b91d486531c560bae46e"
integrity sha512-iemies796dD5CgjG5kV0MnpEmKSH+s7O83ZoJLVzuVbZmm4lheMsZqAVT73hlMx4QlkwhxbyUzhOBUOZwoOe0w==
cypress@^15.15.0:
version "15.16.0"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-15.16.0.tgz#482f77e6f85aee98b94a5ad844d36f69dc212c28"
integrity sha512-fy0M0c9xDLEp4v9y7LLKFeAQhIdDsobxDSKpD3JcZpqQefjy9TSzEyVV3HA0zu7hUi0bGHlSYlI7ASub8wgR9A==
cypress@^15.21.1:
version "15.21.1"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-15.21.1.tgz#51653d58fa0e9994a11406aa3df2bee716264f9a"
integrity sha512-ogHpHMj0XNlZA5MGzjg8SWHf0eMw9lTwVQRKjpcT1GzNTxNUjwnHA8YCG6nOJ8ThU+XZaUxJgpMYZnJ//8aDaw==
dependencies:
"@cypress/request" "^4.0.0"
"@cypress/xvfb" "^1.2.4"
"@types/sinonjs__fake-timers" "8.1.1"
"@types/sizzle" "^2.3.2"
"@types/tmp" "^0.2.3"
arch "^2.2.0"
arch "^3.0.0"
blob-util "^2.0.2"
bluebird "^3.7.2"
buffer "^5.7.1"
cachedir "^2.4.0"
chalk "^4.1.0"
chrome-remote-interface "0.33.3"
ci-info "^4.1.0"
cli-table3 "0.6.1"
commander "^6.2.1"
@@ -731,7 +762,6 @@ cypress@^15.15.0:
systeminformation "^5.31.1"
tmp "~0.2.4"
tree-kill "1.2.2"
tslib "1.14.1"
untildify "^4.0.0"
yauzl "^3.3.1"
@@ -895,17 +925,17 @@ eslint-plugin-align-assignments@^1.1.2:
resolved "https://registry.yarnpkg.com/eslint-plugin-align-assignments/-/eslint-plugin-align-assignments-1.1.2.tgz#83e1a8a826d4adf29e82b52d0bb39c88b301b576"
integrity sha512-I1ZJgk9EjHfGVU9M2Ex8UkVkkjLL5Y9BS6VNnQHq79eHj2H4/Cgxf36lQSUTLgm2ntB03A2NtF+zg9fyi5vChg==
eslint-plugin-chai-friendly@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-chai-friendly/-/eslint-plugin-chai-friendly-1.2.0.tgz#2a54b481bcbc26ab5a581b2b7e7c304465837c15"
integrity sha512-um2pBb4ZXNCoTRPRAWiUaXeIaw1dRaPOEZ+G/qcZqfyTdkCXXwOBctnfnbIRbZiQf4AXl3ImV1grt423SlK+mg==
eslint-plugin-chai-friendly@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-chai-friendly/-/eslint-plugin-chai-friendly-1.2.1.tgz#141392762aa8626ac3d25780a502e625ce74922b"
integrity sha512-mV3EOJLDr8+L+LS8uCkP711fnNHz+4PsmPyz18xwkvjJwfLRlnx0Eu6CFnb5B+dW5ahoav2jVer2KFYsmIuv3A==
eslint-plugin-cypress@^6.4.1:
version "6.4.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-6.4.1.tgz#2aa64e5a2dcc56b8b916fdf8398a81b31a61c462"
integrity sha512-8mnfR3q0Lr41Fu9SYZGeZ5nbSBgtS44+bbtSs7k0KvfoQ2pPmK43IvaTP6FGTfwBTN6S7w027CpqjrLAd65AYA==
eslint-plugin-cypress@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-7.0.1.tgz#eb80500478533204ad5ebd2e49944224b0e47941"
integrity sha512-Jn2sV9rt8iiUYZY+iGY8fvV8iseJ6Tl1jngnLeSRyuHvYgxEbDUUFwNhwj1cr9gI4xCzTDh4mgXJ3YY7+dEMVg==
dependencies:
globals "^17.6.0"
globals "^17.11.0"
eslint-scope@^9.1.2:
version "9.1.2"
@@ -927,15 +957,15 @@ eslint-visitor-keys@^5.0.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@^10.3.0:
version "10.4.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.4.1.tgz#f6640b176e0912246d9ddbf8fcfa5e8b7f02445a"
integrity sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==
eslint@^10.9.1:
version "10.9.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.9.1.tgz#409da5c41a5536d5a849f8555a18ca7ef1eb963b"
integrity sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
"@eslint/config-array" "^0.23.5"
"@eslint/config-helpers" "^0.6.0"
"@eslint/config-helpers" "^0.7.0"
"@eslint/core" "^1.2.1"
"@eslint/plugin-kit" "^0.7.2"
"@humanfs/node" "^0.16.6"
@@ -959,7 +989,7 @@ eslint@^10.3.0:
imurmurhash "^0.1.4"
is-glob "^4.0.0"
json-stable-stringify-without-jsonify "^1.0.1"
minimatch "^10.2.4"
minimatch "^10.2.5"
natural-compare "^1.4.0"
optionator "^0.9.3"
@@ -1185,6 +1215,17 @@ form-data@^4.0.5, form-data@~4.0.4:
hasown "^2.0.2"
mime-types "^2.1.12"
form-data@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827"
integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
es-set-tostringtag "^2.1.0"
hasown "^2.0.4"
mime-types "^2.1.35"
formdata-polyfill@^4.0.10:
version "4.0.10"
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
@@ -1288,10 +1329,10 @@ global-dirs@^3.0.0:
dependencies:
ini "2.0.0"
globals@^17.6.0:
version "17.6.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.6.0.tgz#0f0be018d5cca8690e6375ead1f65c4bb96191fc"
integrity sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==
globals@^17.11.0:
version "17.11.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
globby@^11.0.4:
version "11.1.0"
@@ -1347,6 +1388,13 @@ hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
hasown@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
dependencies:
function-bind "^1.1.2"
he@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
@@ -1663,7 +1711,7 @@ mime-db@1.52.0:
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.12, mime-types@~2.1.19:
mime-types@^2.1.12, mime-types@^2.1.35, mime-types@~2.1.19:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
@@ -1687,6 +1735,13 @@ minimatch@^10.2.4:
dependencies:
brace-expansion "^5.0.5"
minimatch@^10.2.5:
version "10.2.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
dependencies:
brace-expansion "^5.0.8"
minimatch@^9.0.4, minimatch@^9.0.5:
version "9.0.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
@@ -1727,10 +1782,10 @@ mocha-junit-reporter@^2.2.1:
strip-ansi "^6.0.1"
xml "^1.0.1"
mocha@^11.7.5:
version "11.7.6"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.6.tgz#ebbe22989d04cbb9424a36307320476624c41a33"
integrity sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==
mocha@^11.8.0:
version "11.8.0"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.8.0.tgz#9163cc59600ec26470f6b040e30aa51acc973ed2"
integrity sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==
dependencies:
browser-stdout "^1.3.1"
chokidar "^4.0.1"
@@ -2307,11 +2362,6 @@ tree-kill@1.2.2:
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
tslib@1.14.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tunnel-agent@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
@@ -2430,6 +2480,11 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@^7.2.0:
version "7.5.13"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.13.tgz#12aa507eaca76c295c278b1aebf4698ab2c1845f"
integrity sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==
xml@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5"