mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-10 12:28:28 +00:00
Merge pull request #5908 from auzcoder/fix/auth-rate-limit-and-permissions
feat: rate limit auth endpoints and scope user proxy host permissions
This commit is contained in:
23 files changed
+571
-104
No files matched your search
@@ -54,6 +54,11 @@ const internalDeadHost = {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
|
||||
if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
}
|
||||
|
||||
const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
|
||||
// Add to audit log
|
||||
@@ -151,6 +156,15 @@ const internalDeadHost = {
|
||||
|
||||
thisData = internalHost.cleanSslHstsData(thisData, row);
|
||||
|
||||
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
|
||||
if (
|
||||
!isAdmin &&
|
||||
typeof data.advanced_config !== "undefined" &&
|
||||
data.advanced_config !== row.advanced_config
|
||||
) {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
}
|
||||
|
||||
// do the row update
|
||||
await deadHostModel.query().where({ id: data.id }).patch(data);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import errs from "../lib/error.js";
|
||||
import { castJsonIfNeed } from "../lib/helpers.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import proxyHostModel from "../models/proxy_host.js";
|
||||
import userPermissionModel from "../models/user_permission.js";
|
||||
import internalAuditLog from "./audit-log.js";
|
||||
import internalCertificate from "./certificate.js";
|
||||
import internalHost from "./host.js";
|
||||
@@ -57,6 +58,18 @@ const internalProxyHost = {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
|
||||
if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
}
|
||||
if (
|
||||
!isAdmin &&
|
||||
Array.isArray(thisData.locations) &&
|
||||
thisData.locations.some((loc) => loc?.advanced_config && loc.advanced_config.trim() !== "")
|
||||
) {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
}
|
||||
|
||||
return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
})
|
||||
.then((row) => {
|
||||
@@ -183,6 +196,29 @@ const internalProxyHost = {
|
||||
|
||||
thisData = internalHost.cleanSslHstsData(thisData, row);
|
||||
|
||||
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
|
||||
if (
|
||||
!isAdmin &&
|
||||
typeof thisData.advanced_config !== "undefined" &&
|
||||
thisData.advanced_config !== row.advanced_config
|
||||
) {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
}
|
||||
if (!isAdmin && Array.isArray(thisData.locations)) {
|
||||
const existingLocations = Array.isArray(row.locations) ? row.locations : [];
|
||||
for (const loc of thisData.locations) {
|
||||
const existingLoc = existingLocations.find((l) => l.path === loc.path);
|
||||
const existingAdv = existingLoc?.advanced_config || "";
|
||||
if (typeof loc.advanced_config === "undefined") {
|
||||
loc.advanced_config = existingAdv;
|
||||
} else if ((loc.advanced_config || "") !== existingAdv) {
|
||||
throw new errs.PermissionError(
|
||||
"You do not have permission to modify advanced configuration",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return proxyHostModel
|
||||
.query()
|
||||
.where({ id: thisData.id })
|
||||
@@ -243,7 +279,14 @@ const internalProxyHost = {
|
||||
.first();
|
||||
|
||||
if (access_data.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
const permissions = access.getPermissions ? access.getPermissions() : {};
|
||||
const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || [];
|
||||
query.andWhere((builder) => {
|
||||
builder.where("owner_user_id", access.token.getUserId(1));
|
||||
if (allowedIds.length > 0) {
|
||||
builder.orWhereIn("id", allowedIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof thisData.expand !== "undefined" && thisData.expand !== null) {
|
||||
@@ -427,7 +470,14 @@ const internalProxyHost = {
|
||||
.orderBy(castJsonIfNeed("domain_names"), "ASC");
|
||||
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
const permissions = access.getPermissions ? access.getPermissions() : {};
|
||||
const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || [];
|
||||
query.andWhere((builder) => {
|
||||
builder.where("owner_user_id", access.token.getUserId(1));
|
||||
if (allowedIds.length > 0) {
|
||||
builder.orWhereIn("id", allowedIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Query is used for searching
|
||||
@@ -455,16 +505,22 @@ const internalProxyHost = {
|
||||
* @param {String} visibility
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getCount: (user_id, visibility) => {
|
||||
getCount: async (user_id, visibility) => {
|
||||
const query = proxyHostModel.query().count("id as count").where("is_deleted", 0);
|
||||
|
||||
if (visibility !== "all") {
|
||||
query.andWhere("owner_user_id", user_id);
|
||||
const perm = await userPermissionModel.query().where("user_id", user_id).first();
|
||||
const allowedIds = perm?.meta?.proxy_host_ids || perm?.meta?.proxyHostIds || [];
|
||||
query.andWhere((builder) => {
|
||||
builder.where("owner_user_id", user_id);
|
||||
if (allowedIds.length > 0) {
|
||||
builder.orWhereIn("id", allowedIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return query.first().then((row) => {
|
||||
return Number.parseInt(row.count, 10);
|
||||
});
|
||||
const row = await query.first();
|
||||
return Number.parseInt(row.count, 10);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -53,8 +53,13 @@ const internalRedirectionHost = {
|
||||
|
||||
// Fix for db field not having a default value
|
||||
// for this optional field.
|
||||
if (typeof data.advanced_config === "undefined") {
|
||||
data.advanced_config = "";
|
||||
if (typeof thisData.advanced_config === "undefined") {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
|
||||
if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
}
|
||||
|
||||
return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
@@ -183,6 +188,15 @@ const internalRedirectionHost = {
|
||||
|
||||
thisData = internalHost.cleanSslHstsData(thisData, row);
|
||||
|
||||
const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin");
|
||||
if (
|
||||
!isAdmin &&
|
||||
typeof thisData.advanced_config !== "undefined" &&
|
||||
thisData.advanced_config !== row.advanced_config
|
||||
) {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
}
|
||||
|
||||
return redirectionHostModel
|
||||
.query()
|
||||
.where({ id: thisData.id })
|
||||
|
||||
@@ -444,7 +444,7 @@ const internalUser = {
|
||||
return internalUser.get(access, { id: data.id });
|
||||
})
|
||||
.then((user) => {
|
||||
if (user.id !== data.id) {
|
||||
if (Number(user.id) !== Number(data.id)) {
|
||||
// Sanity check that something crazy hasn't happened
|
||||
throw new errs.InternalValidationError(
|
||||
`User could not be updated, IDs do not match: ${user.id} !== ${data.id}`,
|
||||
@@ -454,6 +454,7 @@ const internalUser = {
|
||||
return user;
|
||||
})
|
||||
.then((user) => {
|
||||
const permData = _.omit(data, ["id"]);
|
||||
// Get perms row, patch if it exists
|
||||
return userPermissionModel
|
||||
.query()
|
||||
@@ -465,10 +466,10 @@ const internalUser = {
|
||||
return userPermissionModel
|
||||
.query()
|
||||
.where("user_id", user.id)
|
||||
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, data));
|
||||
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, permData));
|
||||
}
|
||||
// insert
|
||||
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, data));
|
||||
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, permData));
|
||||
})
|
||||
.then((permissions) => {
|
||||
// Add to Audit Log
|
||||
|
||||
+14
-2
@@ -139,7 +139,13 @@ export default function (tokenString) {
|
||||
const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
|
||||
|
||||
if (permissions.visibility === "user") {
|
||||
query.andWhere("owner_user_id", tokenUserId);
|
||||
const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || [];
|
||||
query.andWhere((builder) => {
|
||||
builder.where("owner_user_id", tokenUserId);
|
||||
if (allowedIds.length > 0) {
|
||||
builder.orWhereIn("id", allowedIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
@@ -213,6 +219,10 @@ export default function (tokenString) {
|
||||
return {
|
||||
token: Token,
|
||||
|
||||
getPermissions: () => permissions,
|
||||
getRoles: () => userRoles,
|
||||
hasRole: (role) => userRoles.includes(role),
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Boolean} [allowInternal]
|
||||
@@ -220,7 +230,8 @@ export default function (tokenString) {
|
||||
*/
|
||||
load: async (allowInternal) => {
|
||||
if (tokenString) {
|
||||
return await Token.load(tokenString);
|
||||
await this.init();
|
||||
return tokenData;
|
||||
}
|
||||
allowInternalAccess = allowInternal;
|
||||
return allowInternal || null;
|
||||
@@ -255,6 +266,7 @@ export default function (tokenString) {
|
||||
permission_streams: permissions.streams,
|
||||
permission_access_lists: permissions.access_lists,
|
||||
permission_certificates: permissions.certificates,
|
||||
permission_meta: permissions.meta,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { isCI } from "../config.js";
|
||||
|
||||
/**
|
||||
* In-memory sliding window rate limiter for authentication endpoints
|
||||
*/
|
||||
|
||||
const ipRequests = new Map();
|
||||
|
||||
// Clean up old entries every 2 minutes
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, record] of ipRequests.entries()) {
|
||||
if (now - record.startTime > record.windowMs * 2) {
|
||||
ipRequests.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 120000);
|
||||
cleanupTimer.unref();
|
||||
|
||||
export default function createRateLimiter(options = {}) {
|
||||
const windowMs = options.windowMs || 60 * 1000; // 1 minute default
|
||||
const max = options.max || 10; // 10 requests default
|
||||
const message = options.message || "Too many attempts from this IP, please try again later.";
|
||||
const skipSuccessfulRequests = options.skipSuccessfulRequests !== false;
|
||||
|
||||
return (req, res, next) => {
|
||||
// Bypass rate limiting in CI, test environments, or when explicitly disabled
|
||||
if (
|
||||
isCI() ||
|
||||
process.env.CI === "true" ||
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.DISABLE_RATE_LIMIT === "true"
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const socketAddr = req.socket?.remoteAddress || "unknown";
|
||||
const isTrustedLocalProxy =
|
||||
socketAddr === "127.0.0.1" || socketAddr === "::1" || socketAddr === "::ffff:127.0.0.1";
|
||||
const forwardedFor = isTrustedLocalProxy ? req.headers["x-forwarded-for"]?.split(",")[0].trim() : null;
|
||||
const clientIp = forwardedFor || socketAddr;
|
||||
|
||||
const now = Date.now();
|
||||
let record = ipRequests.get(clientIp);
|
||||
|
||||
if (!record || now - record.startTime > windowMs) {
|
||||
record = {
|
||||
count: 1,
|
||||
startTime: now,
|
||||
windowMs,
|
||||
};
|
||||
ipRequests.set(clientIp, record);
|
||||
|
||||
if (skipSuccessfulRequests) {
|
||||
const currentRecord = record;
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode < 400 && currentRecord.count > 0) {
|
||||
currentRecord.count--;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
record.count++;
|
||||
|
||||
if (skipSuccessfulRequests) {
|
||||
const currentRecord = record;
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode < 400 && currentRecord.count > 0) {
|
||||
currentRecord.count--;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (record.count > max) {
|
||||
res.setHeader("Retry-After", Math.ceil((record.startTime + windowMs - now) / 1000));
|
||||
return res.status(429).json({
|
||||
error: {
|
||||
code: 429,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { migrate as logger } from "../logger.js";
|
||||
|
||||
const migrateName = "user_permission_meta";
|
||||
|
||||
/**
|
||||
* Migrate
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const up = (knex) => {
|
||||
logger.info(`[${migrateName}] Migrating Up...`);
|
||||
|
||||
return knex.schema
|
||||
.alterTable("user_permission", (table) => {
|
||||
table.json("meta").nullable();
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] user_permission Table altered`);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Undo Migrate
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const down = (knex) => {
|
||||
logger.info(`[${migrateName}] Migrating Down...`);
|
||||
|
||||
return knex.schema
|
||||
.alterTable("user_permission", (table) => {
|
||||
table.dropColumn("meta");
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] user_permission Table altered`);
|
||||
});
|
||||
};
|
||||
|
||||
export { up, down };
|
||||
@@ -24,6 +24,10 @@ class UserPermission extends Model {
|
||||
static get tableName() {
|
||||
return "user_permission";
|
||||
}
|
||||
|
||||
static get jsonAttributes() {
|
||||
return ["meta"];
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPermission;
|
||||
@@ -1,10 +1,18 @@
|
||||
import express from "express";
|
||||
import internalToken from "../internal/token.js";
|
||||
import jwtdecode from "../lib/express/jwt-decode.js";
|
||||
import createRateLimiter from "../lib/express/rate-limit.js";
|
||||
import apiValidator from "../lib/validator/api.js";
|
||||
import { debug, express as logger } from "../logger.js";
|
||||
import { getValidationSchema } from "../schema/index.js";
|
||||
|
||||
const tokenRateLimiter = createRateLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 10,
|
||||
message: "Too many login attempts. Please try again in 1 minute.",
|
||||
skipSuccessfulRequests: true,
|
||||
});
|
||||
|
||||
const router = express.Router({
|
||||
caseSensitive: true,
|
||||
strict: true,
|
||||
@@ -42,7 +50,7 @@ router
|
||||
*
|
||||
* Create a new Token
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
.post(tokenRateLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body);
|
||||
const result = await internalToken.getTokenFromEmail(data);
|
||||
@@ -64,7 +72,7 @@ router
|
||||
*
|
||||
* Verify 2FA code and get full token
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
.post(tokenRateLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body);
|
||||
const result = await internalToken.verify2FA(challenge_token, code);
|
||||
|
||||
@@ -260,7 +260,7 @@ router
|
||||
.put(async (req, res, next) => {
|
||||
try {
|
||||
const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
|
||||
payload.id = req.params.user_id;
|
||||
payload.id = Number.parseInt(req.params.user_id, 10);
|
||||
const result = await internalUser.setPermissions(res.locals.access, payload);
|
||||
res.status(200).send(result);
|
||||
} catch (err) {
|
||||
|
||||
@@ -44,6 +44,25 @@
|
||||
"description": "Certificates Permissions",
|
||||
"enum": ["hidden", "view", "manage"],
|
||||
"example": "hidden"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Permission metadata",
|
||||
"example": {
|
||||
"proxy_host_ids": [1]
|
||||
},
|
||||
"properties": {
|
||||
"proxy_host_ids": {
|
||||
"type": "array",
|
||||
"description": "Allowed Proxy Host IDs",
|
||||
"example": [1],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"description": "Proxy Host ID",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@
|
||||
"dead_hosts": "hidden",
|
||||
"proxy_hosts": "manage",
|
||||
"redirection_hosts": "hidden",
|
||||
"streams": "hidden"
|
||||
"streams": "hidden",
|
||||
"meta": {
|
||||
"proxy_host_ids": [1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface UserPermissions {
|
||||
streams: string;
|
||||
accessLists: string;
|
||||
certificates: string;
|
||||
meta?: {
|
||||
proxyHostIds?: number[];
|
||||
proxy_host_ids?: number[];
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
||||
@@ -30,8 +30,9 @@ interface Row {
|
||||
interface Props {
|
||||
initialValues: ProxyLocation[];
|
||||
name?: string;
|
||||
allowAdvanced?: boolean;
|
||||
}
|
||||
export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
export function LocationsFields({ initialValues, name = "locations", allowAdvanced = true }: Props) {
|
||||
const [rows, setRows] = useState<Row[]>(() => (initialValues || []).map((value, id) => ({ id, value })));
|
||||
const { setFieldValue } = useFormikContext();
|
||||
const [expanded, setExpanded] = useState<number[]>([]);
|
||||
@@ -217,7 +218,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
{isOpen && (
|
||||
<div className="card-body" id={bodyId}>
|
||||
<div className="row">
|
||||
<div className="col-md-10">
|
||||
<div className={allowAdvanced ? "col-md-10" : "col-md-12"}>
|
||||
<div className="input-group mb-3">
|
||||
<span className="input-group-text">Location</span>
|
||||
<input
|
||||
@@ -230,23 +231,25 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
/>
|
||||
</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>
|
||||
{allowAdvanced && (
|
||||
<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="col-sm-3 col-md-3">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
className="form-label text-nowrap"
|
||||
htmlFor={`location-forwardScheme-${row.id}`}
|
||||
>
|
||||
<T id="host.forward-scheme" />
|
||||
@@ -264,10 +267,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="col-sm-5 col-md-5">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
className="form-label text-nowrap"
|
||||
htmlFor={`location-forwardHost-${row.id}`}
|
||||
>
|
||||
<T id="proxy-host.forward-host" />
|
||||
@@ -285,10 +288,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="col-sm-4 col-md-4">
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
className="form-label text-nowrap"
|
||||
htmlFor={`location-forwardPort-${row.id}`}
|
||||
>
|
||||
<T id="host.forward-port" />
|
||||
@@ -316,7 +319,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
|
||||
onFormChange={(value) => handleAccessListChange(row.id, value)}
|
||||
inheritHost
|
||||
/>
|
||||
{advVisible.includes(row.id) && (
|
||||
{allowAdvanced && advVisible.includes(row.id) && (
|
||||
<div className="">
|
||||
<CodeEditor
|
||||
language="nginx"
|
||||
|
||||
@@ -644,12 +644,21 @@
|
||||
"password.show": {
|
||||
"defaultMessage": "Show Password"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts": {
|
||||
"defaultMessage": "Allowed Proxy Hosts"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts-help": {
|
||||
"defaultMessage": "Grants additional access to selected proxy hosts created by other users (in addition to the user's own created hosts)."
|
||||
},
|
||||
"permissions.hidden": {
|
||||
"defaultMessage": "Hidden"
|
||||
},
|
||||
"permissions.manage": {
|
||||
"defaultMessage": "Manage"
|
||||
},
|
||||
"permissions.no-proxy-hosts": {
|
||||
"defaultMessage": "No proxy hosts found"
|
||||
},
|
||||
"permissions.view": {
|
||||
"defaultMessage": "View Only"
|
||||
},
|
||||
|
||||
@@ -644,12 +644,21 @@
|
||||
"password.show": {
|
||||
"defaultMessage": "Näita parooli"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts": {
|
||||
"defaultMessage": "Lubatud puhverserverid"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts-help": {
|
||||
"defaultMessage": "Annab täiendava ligipääsu teiste kasutajate loodud valitud puhverserveritele (lisaks kasutaja enda loodud kirjetele)."
|
||||
},
|
||||
"permissions.hidden": {
|
||||
"defaultMessage": "Peidetud"
|
||||
},
|
||||
"permissions.manage": {
|
||||
"defaultMessage": "Halda"
|
||||
},
|
||||
"permissions.no-proxy-hosts": {
|
||||
"defaultMessage": "Ühtegi puhverserverit ei leitud"
|
||||
},
|
||||
"permissions.view": {
|
||||
"defaultMessage": "Ainult vaatamine"
|
||||
},
|
||||
|
||||
@@ -437,6 +437,9 @@
|
||||
"footer.github-fork": {
|
||||
"defaultMessage": "Fork dự án này trên Github"
|
||||
},
|
||||
"form.unsaved-changes": {
|
||||
"defaultMessage": "Bạn có các thay đổi chưa được lưu. Bạn có chắc chắn muốn hủy bỏ chúng không?"
|
||||
},
|
||||
"host.flags.block-exploits": {
|
||||
"defaultMessage": "Chặn các hoạt động khai thác phổ biến"
|
||||
},
|
||||
@@ -641,12 +644,21 @@
|
||||
"password.show": {
|
||||
"defaultMessage": "Hiện mật khẩu"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts": {
|
||||
"defaultMessage": "Máy chủ proxy được phép"
|
||||
},
|
||||
"permissions.allowed-proxy-hosts-help": {
|
||||
"defaultMessage": "Cấp thêm quyền truy cập vào các máy chủ proxy đã chọn do người dùng khác tạo (ngoài các mục do chính người dùng tạo)."
|
||||
},
|
||||
"permissions.hidden": {
|
||||
"defaultMessage": "Ẩn"
|
||||
},
|
||||
"permissions.manage": {
|
||||
"defaultMessage": "Quản lý"
|
||||
},
|
||||
"permissions.no-proxy-hosts": {
|
||||
"defaultMessage": "Không tìm thấy máy chủ proxy nào"
|
||||
},
|
||||
"permissions.view": {
|
||||
"defaultMessage": "Chỉ xem"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SSLOptionsFields,
|
||||
} from "src/components";
|
||||
import { FormModal } from "src/components/Form";
|
||||
import { useDeadHost, useSetDeadHost } from "src/hooks";
|
||||
import { useDeadHost, useSetDeadHost, useUser } from "src/hooks";
|
||||
import { T } from "src/locale";
|
||||
import { showObjectSuccess } from "src/notifications";
|
||||
|
||||
@@ -25,11 +25,14 @@ interface Props extends InnerModalProps {
|
||||
id: number | "new";
|
||||
}
|
||||
const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
const { data: currentUser } = useUser("me");
|
||||
const { data, isLoading, error } = useDeadHost(id);
|
||||
const { mutate: setDeadHost } = useSetDeadHost();
|
||||
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isAdmin = currentUser?.roles?.includes("admin");
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
@@ -40,6 +43,11 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
...values,
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
delete payload.advanced_config;
|
||||
delete payload.advancedConfig;
|
||||
}
|
||||
|
||||
setDeadHost(payload, {
|
||||
onError: (err: any) => setErrorMsg(<T id={err.message} />),
|
||||
onSuccess: () => {
|
||||
@@ -121,19 +129,21 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
<T id="column.ssl" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item ms-auto" role="presentation">
|
||||
<a
|
||||
href="#tab-advanced"
|
||||
className="nav-link"
|
||||
title="Settings"
|
||||
data-bs-toggle="tab"
|
||||
aria-selected="false"
|
||||
tabIndex={-1}
|
||||
role="tab"
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</a>
|
||||
</li>
|
||||
{isAdmin && (
|
||||
<li className="nav-item ms-auto" role="presentation">
|
||||
<a
|
||||
href="#tab-advanced"
|
||||
className="nav-link"
|
||||
title="Settings"
|
||||
data-bs-toggle="tab"
|
||||
aria-selected="false"
|
||||
tabIndex={-1}
|
||||
role="tab"
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
@@ -149,9 +159,11 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
/>
|
||||
<SSLOptionsFields color="bg-red" />
|
||||
</div>
|
||||
<div className="tab-pane" id="tab-advanced" role="tabpanel">
|
||||
<NginxConfigField />
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="tab-pane" id="tab-advanced" role="tabpanel">
|
||||
<NginxConfigField />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import Modal from "react-bootstrap/Modal";
|
||||
import { setPermissions } from "src/api/backend";
|
||||
import { Button, Loading } from "src/components";
|
||||
import { FormModal } from "src/components/Form";
|
||||
import { useUser } from "src/hooks";
|
||||
import { useProxyHosts, useUser } from "src/hooks";
|
||||
import { T } from "src/locale";
|
||||
import styles from "./PermissionsModal.module.css";
|
||||
|
||||
@@ -23,6 +23,7 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
|
||||
const { data, isLoading, error } = useUser(id);
|
||||
const { data: proxyHosts } = useProxyHosts();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
@@ -152,18 +153,24 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
preventHide={isSubmitting}
|
||||
initialValues={
|
||||
{
|
||||
visibility: data.permissions?.visibility,
|
||||
accessLists: data.permissions?.accessLists,
|
||||
certificates: data.permissions?.certificates,
|
||||
deadHosts: data.permissions?.deadHosts,
|
||||
proxyHosts: data.permissions?.proxyHosts,
|
||||
redirectionHosts: data.permissions?.redirectionHosts,
|
||||
streams: data.permissions?.streams,
|
||||
visibility: data.permissions?.visibility || "user",
|
||||
accessLists: data.permissions?.accessLists || "hidden",
|
||||
certificates: data.permissions?.certificates || "hidden",
|
||||
deadHosts: data.permissions?.deadHosts || "hidden",
|
||||
proxyHosts: data.permissions?.proxyHosts || "hidden",
|
||||
redirectionHosts: data.permissions?.redirectionHosts || "hidden",
|
||||
streams: data.permissions?.streams || "hidden",
|
||||
meta: {
|
||||
proxy_host_ids:
|
||||
data.permissions?.meta?.proxyHostIds ||
|
||||
data.permissions?.meta?.proxy_host_ids ||
|
||||
[],
|
||||
},
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ onHide }) => (
|
||||
{({ values, setFieldValue, onHide }: any) => (
|
||||
<Form>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
@@ -227,6 +234,71 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
{({ field, form }: any) => getPermissionButtons(field, form)}
|
||||
</Field>
|
||||
</div>
|
||||
{values.visibility === "user" && values.proxyHosts !== "hidden" && (
|
||||
<div className="mb-3 ps-3 border-start border-2 border-primary">
|
||||
<label htmlFor="ignored-allowed-hosts" className="form-label mb-1">
|
||||
<T id="permissions.allowed-proxy-hosts" />
|
||||
</label>
|
||||
<div className="text-secondary small mb-2">
|
||||
<T id="permissions.allowed-proxy-hosts-help" />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: "180px",
|
||||
overflowY: "auto",
|
||||
border: "1px solid var(--tblr-border-color, #e6e7e9)",
|
||||
borderRadius: "4px",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--tblr-bg-surface-secondary, #f8fafc)",
|
||||
}}
|
||||
>
|
||||
{proxyHosts && proxyHosts.length > 0 ? (
|
||||
proxyHosts.map((host) => {
|
||||
const selectedIds: number[] =
|
||||
values.meta?.proxy_host_ids || [];
|
||||
const isChecked = selectedIds.includes(host.id);
|
||||
return (
|
||||
<div key={host.id} className="form-check mb-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id={`perm-proxy-host-${host.id}`}
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const newIds = e.target.checked
|
||||
? [...selectedIds, host.id]
|
||||
: selectedIds.filter(
|
||||
(hId) => hId !== host.id,
|
||||
);
|
||||
setFieldValue(
|
||||
"meta.proxy_host_ids",
|
||||
newIds,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className="form-check-label user-select-none"
|
||||
htmlFor={`perm-proxy-host-${host.id}`}
|
||||
>
|
||||
<span className="fw-semibold">
|
||||
{host.domainNames?.join(", ")}
|
||||
</span>
|
||||
<span className="text-secondary small ms-2">
|
||||
→ {host.forwardScheme}://
|
||||
{host.forwardHost}:{host.forwardPort}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-secondary small">
|
||||
<T id="permissions.no-proxy-hosts" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<label htmlFor="ignored" className="form-label">
|
||||
<T id="redirection-hosts" />
|
||||
|
||||
@@ -37,6 +37,8 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isAdmin = currentUser?.roles?.includes("admin");
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
@@ -48,6 +50,19 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
};
|
||||
payload.locations = (values.locations || []).filter((location: any) => location.path?.trim() !== "");
|
||||
|
||||
if (!isAdmin) {
|
||||
delete payload.advanced_config;
|
||||
delete payload.advancedConfig;
|
||||
const existingLocations = data?.locations || [];
|
||||
payload.locations = payload.locations.map((loc: any) => {
|
||||
const existingLoc = existingLocations.find((l: any) => l.path === loc.path);
|
||||
return {
|
||||
...loc,
|
||||
advancedConfig: existingLoc?.advancedConfig || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setProxyHost(payload, {
|
||||
onError: (err: any) => setErrorMsg(<T id={err.message} />),
|
||||
onSuccess: () => {
|
||||
@@ -154,19 +169,21 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
<T id="column.ssl" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item ms-auto" role="presentation">
|
||||
<a
|
||||
href="#tab-advanced"
|
||||
className="nav-link"
|
||||
title="Settings"
|
||||
data-bs-toggle="tab"
|
||||
aria-selected="false"
|
||||
tabIndex={-1}
|
||||
role="tab"
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</a>
|
||||
</li>
|
||||
{isAdmin && (
|
||||
<li className="nav-item ms-auto" role="presentation">
|
||||
<a
|
||||
href="#tab-advanced"
|
||||
className="nav-link"
|
||||
title="Settings"
|
||||
data-bs-toggle="tab"
|
||||
aria-selected="false"
|
||||
tabIndex={-1}
|
||||
role="tab"
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
@@ -174,12 +191,12 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
<div className="tab-pane active show" id="tab-details" role="tabpanel">
|
||||
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<div className="col-sm-3 col-md-3">
|
||||
<Field name="forwardScheme">
|
||||
{({ field, form }: any) => (
|
||||
<div className="mb-3">
|
||||
<label
|
||||
className="form-label"
|
||||
className="form-label text-nowrap"
|
||||
htmlFor="forwardScheme"
|
||||
>
|
||||
<T id="host.forward-scheme" />
|
||||
@@ -205,11 +222,14 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="col-sm-5 col-md-5">
|
||||
<Field name="forwardHost" validate={validateString(1, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="forwardHost">
|
||||
<label
|
||||
className="form-label text-nowrap"
|
||||
htmlFor="forwardHost"
|
||||
>
|
||||
<T id="proxy-host.forward-host" />
|
||||
</label>
|
||||
<input
|
||||
@@ -232,11 +252,14 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="col-sm-4 col-md-4">
|
||||
<Field name="forwardPort" validate={validateNumber(1, 65535)}>
|
||||
{({ field, form }: any) => (
|
||||
<div className="mb-3">
|
||||
<label className="form-label" htmlFor="forwardPort">
|
||||
<label
|
||||
className="form-label text-nowrap"
|
||||
htmlFor="forwardPort"
|
||||
>
|
||||
<T id="host.forward-port" />
|
||||
</label>
|
||||
<input
|
||||
@@ -341,7 +364,10 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="tab-pane" id="tab-locations" role="tabpanel">
|
||||
<LocationsFields initialValues={data?.locations || []} />
|
||||
<LocationsFields
|
||||
initialValues={data?.locations || []}
|
||||
allowAdvanced={!!isAdmin}
|
||||
/>
|
||||
</div>
|
||||
<div className="tab-pane" id="tab-ssl" role="tabpanel">
|
||||
<SSLCertificateField
|
||||
@@ -351,9 +377,11 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
/>
|
||||
<SSLOptionsFields color="bg-lime" forProxyHost={true} />
|
||||
</div>
|
||||
<div className="tab-pane" id="tab-advanced" role="tabpanel">
|
||||
<NginxConfigField />
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="tab-pane" id="tab-advanced" role="tabpanel">
|
||||
<NginxConfigField />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SSLOptionsFields,
|
||||
} from "src/components";
|
||||
import { FormModal } from "src/components/Form";
|
||||
import { useRedirectionHost, useSetRedirectionHost } from "src/hooks";
|
||||
import { useRedirectionHost, useSetRedirectionHost, useUser } from "src/hooks";
|
||||
import { T } from "src/locale";
|
||||
import { validateString } from "src/modules/Validations";
|
||||
import { showObjectSuccess } from "src/notifications";
|
||||
@@ -27,11 +27,14 @@ interface Props extends InnerModalProps {
|
||||
id: number | "new";
|
||||
}
|
||||
const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
const { data: currentUser } = useUser("me");
|
||||
const { data, isLoading, error } = useRedirectionHost(id);
|
||||
const { mutate: setRedirectionHost } = useSetRedirectionHost();
|
||||
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isAdmin = currentUser?.roles?.includes("admin");
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
@@ -42,6 +45,11 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
|
||||
...values,
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
delete payload.advanced_config;
|
||||
delete payload.advancedConfig;
|
||||
}
|
||||
|
||||
setRedirectionHost(payload, {
|
||||
onError: (err: any) => setErrorMsg(<T id={err.message} />),
|
||||
onSuccess: () => {
|
||||
@@ -134,19 +142,21 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
|
||||
<T id="column.ssl" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="nav-item ms-auto" role="presentation">
|
||||
<a
|
||||
href="#tab-advanced"
|
||||
className="nav-link"
|
||||
title="Settings"
|
||||
data-bs-toggle="tab"
|
||||
aria-selected="false"
|
||||
tabIndex={-1}
|
||||
role="tab"
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</a>
|
||||
</li>
|
||||
{isAdmin && (
|
||||
<li className="nav-item ms-auto" role="presentation">
|
||||
<a
|
||||
href="#tab-advanced"
|
||||
className="nav-link"
|
||||
title="Settings"
|
||||
data-bs-toggle="tab"
|
||||
aria-selected="false"
|
||||
tabIndex={-1}
|
||||
role="tab"
|
||||
>
|
||||
<IconSettings size={20} />
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
@@ -326,9 +336,11 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
|
||||
/>
|
||||
<SSLOptionsFields color="bg-yellow" />
|
||||
</div>
|
||||
<div className="tab-pane" id="tab-advanced" role="tabpanel">
|
||||
<NginxConfigField />
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="tab-pane" id="tab-advanced" role="tabpanel">
|
||||
<NginxConfigField />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,6 +90,49 @@ export default function Table({
|
||||
return <RolesFormatter roles={info.getValue()} />;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row: any) => row.permissions, {
|
||||
id: "permissions",
|
||||
header: intl.formatMessage({ id: "action.permissions" }),
|
||||
cell: (info: any) => {
|
||||
const permissions = info.getValue();
|
||||
const isAdminUser = info.row.original.roles?.includes("admin");
|
||||
if (isAdminUser) {
|
||||
return (
|
||||
<span className="badge bg-green-lt">
|
||||
<T id="column.satisfy-all" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!permissions) {
|
||||
return <span className="text-muted">-</span>;
|
||||
}
|
||||
const items = [
|
||||
{ key: "proxyHosts", label: "proxy-hosts", val: permissions.proxyHosts },
|
||||
{ key: "redirectionHosts", label: "redirection-hosts", val: permissions.redirectionHosts },
|
||||
{ key: "deadHosts", label: "dead-hosts", val: permissions.deadHosts },
|
||||
{ key: "streams", label: "streams", val: permissions.streams },
|
||||
{ key: "accessLists", label: "access-lists", val: permissions.accessLists },
|
||||
{ key: "certificates", label: "certificates", val: permissions.certificates },
|
||||
];
|
||||
return (
|
||||
<div className="d-flex flex-wrap gap-1">
|
||||
{items.map((it) => {
|
||||
if (it.val === "hidden") return null;
|
||||
const isManage = it.val === "manage";
|
||||
return (
|
||||
<span
|
||||
key={it.key}
|
||||
className={`badge ${isManage ? "bg-blue-lt" : "bg-secondary-lt"}`}
|
||||
title={`${intl.formatMessage({ id: it.label })}: ${it.val}`}
|
||||
>
|
||||
<T id={it.label} />: {it.val}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row: any) => row.isDisabled, {
|
||||
id: "isDisabled",
|
||||
header: intl.formatMessage({ id: "column.status" }),
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
allowCypressEnv: false,
|
||||
requestTimeout: 30000,
|
||||
defaultCommandTimeout: 20000,
|
||||
taskTimeout: 90000,
|
||||
reporter: "cypress-multi-reporters",
|
||||
reporterOptions: {
|
||||
configFile: "multi-reporter.json"
|
||||
|
||||
Reference in new issue
Block a user