From bc8bbf3562ca6d48b9d663edf4a99db4df86f7a1 Mon Sep 17 00:00:00 2001 From: Abdulhafiz Davlatov Date: Tue, 29 Sep 2026 00:43:33 +0500 Subject: [PATCH] feat: rate limit auth endpoints and scope user proxy host permissions - Add in-memory sliding window rate limiter for auth token endpoints with CI/test bypass and successful request exclusion - Add user_permission meta column migration and Objection model configuration - Support scoping allowed proxy hosts per user when in 'Created Items Only' visibility mode - Restrict advanced nginx configuration editing on hosts to admin users only - Fix user permissions route ID parsing and omit id from permission update payloads - Add allowed proxy hosts selector in permissions modal and display permissions summary badges in users table --- backend/internal/dead-host.js | 14 +++ backend/internal/proxy-host.js | 70 +++++++++++++-- backend/internal/redirection-host.js | 18 +++- backend/internal/user.js | 7 +- backend/lib/access.js | 16 +++- backend/lib/express/rate-limit.js | 89 ++++++++++++++++++ .../20260906220000_user_permission_meta.js | 41 +++++++++ backend/models/user_permission.js | 4 + backend/routes/tokens.js | 12 ++- backend/routes/users.js | 2 +- .../schema/components/permission-object.json | 19 ++++ .../paths/users/userID/permissions/put.json | 5 +- frontend/src/api/backend/models.ts | 5 ++ .../src/components/Form/LocationsFields.tsx | 43 ++++----- frontend/src/locale/src/en.json | 9 ++ frontend/src/locale/src/et.json | 9 ++ frontend/src/locale/src/vi.json | 12 +++ frontend/src/modals/DeadHostModal.tsx | 46 ++++++---- frontend/src/modals/PermissionsModal.tsx | 90 +++++++++++++++++-- frontend/src/modals/ProxyHostModal.tsx | 74 ++++++++++----- frontend/src/modals/RedirectionHostModal.tsx | 46 ++++++---- frontend/src/pages/Users/Table.tsx | 43 +++++++++ test/cypress/config/ci.mjs | 1 + 23 files changed, 571 insertions(+), 104 deletions(-) create mode 100644 backend/lib/express/rate-limit.js create mode 100644 backend/migrations/20260906220000_user_permission_meta.js diff --git a/backend/internal/dead-host.js b/backend/internal/dead-host.js index 6523e6289..c8e0e17cd 100644 --- a/backend/internal/dead-host.js +++ b/backend/internal/dead-host.js @@ -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); diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index f079dab02..176604563 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -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); }, }; diff --git a/backend/internal/redirection-host.js b/backend/internal/redirection-host.js index 542439fd3..5e0f3a260 100644 --- a/backend/internal/redirection-host.js +++ b/backend/internal/redirection-host.js @@ -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 }) diff --git a/backend/internal/user.js b/backend/internal/user.js index 8dd86f461..e27ac2111 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -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 diff --git a/backend/lib/access.js b/backend/lib/access.js index d119a4f92..3e27ff388 100644 --- a/backend/lib/access.js +++ b/backend/lib/access.js @@ -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, }, }; diff --git a/backend/lib/express/rate-limit.js b/backend/lib/express/rate-limit.js new file mode 100644 index 000000000..baa34493d --- /dev/null +++ b/backend/lib/express/rate-limit.js @@ -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(); + }; +} diff --git a/backend/migrations/20260906220000_user_permission_meta.js b/backend/migrations/20260906220000_user_permission_meta.js new file mode 100644 index 000000000..1573c77d8 --- /dev/null +++ b/backend/migrations/20260906220000_user_permission_meta.js @@ -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 }; diff --git a/backend/models/user_permission.js b/backend/models/user_permission.js index cc6eac2cc..0ad444c9c 100644 --- a/backend/models/user_permission.js +++ b/backend/models/user_permission.js @@ -24,6 +24,10 @@ class UserPermission extends Model { static get tableName() { return "user_permission"; } + + static get jsonAttributes() { + return ["meta"]; + } } export default UserPermission; diff --git a/backend/routes/tokens.js b/backend/routes/tokens.js index 8a6a1bc0f..b5f25890c 100644 --- a/backend/routes/tokens.js +++ b/backend/routes/tokens.js @@ -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); diff --git a/backend/routes/users.js b/backend/routes/users.js index 3f972223b..2cdb4101c 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -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) { diff --git a/backend/schema/components/permission-object.json b/backend/schema/components/permission-object.json index 9bb5bfffa..c49e6cba9 100644 --- a/backend/schema/components/permission-object.json +++ b/backend/schema/components/permission-object.json @@ -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 + } + } + } } } } diff --git a/backend/schema/paths/users/userID/permissions/put.json b/backend/schema/paths/users/userID/permissions/put.json index 764e7466c..0bbef6dfb 100644 --- a/backend/schema/paths/users/userID/permissions/put.json +++ b/backend/schema/paths/users/userID/permissions/put.json @@ -35,7 +35,10 @@ "dead_hosts": "hidden", "proxy_hosts": "manage", "redirection_hosts": "hidden", - "streams": "hidden" + "streams": "hidden", + "meta": { + "proxy_host_ids": [1] + } } } } diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 96c1b8a48..347c62744 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -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 { diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx index 0f8d83033..8cd826702 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -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(() => (initialValues || []).map((value, id) => ({ id, value }))); const { setFieldValue } = useFormikContext(); const [expanded, setExpanded] = useState([]); @@ -217,7 +218,7 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) { {isOpen && (
-
+
Location
-
- -
+ {allowAdvanced && ( +
+ +
+ )}
-
+
-
+
-
+
diff --git a/frontend/src/modals/PermissionsModal.tsx b/frontend/src/modals/PermissionsModal.tsx index 4f6bc0fab..37a00dfaf 100644 --- a/frontend/src/modals/PermissionsModal.tsx +++ b/frontend/src/modals/PermissionsModal.tsx @@ -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(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) => (
@@ -227,6 +234,71 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { {({ field, form }: any) => getPermissionButtons(field, form)}
+ {values.visibility === "user" && values.proxyHosts !== "hidden" && ( +
+ +
+ +
+
+ {proxyHosts && proxyHosts.length > 0 ? ( + proxyHosts.map((host) => { + const selectedIds: number[] = + values.meta?.proxy_host_ids || []; + const isChecked = selectedIds.includes(host.id); + return ( +
+ { + const newIds = e.target.checked + ? [...selectedIds, host.id] + : selectedIds.filter( + (hId) => hId !== host.id, + ); + setFieldValue( + "meta.proxy_host_ids", + newIds, + ); + }} + /> + +
+ ); + }) + ) : ( +
+ +
+ )} +
+
+ )}
@@ -174,12 +191,12 @@ const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
-
+
{({ field, form }: any) => (
-
+
{({ field, form }: any) => (
-
-
+
{({ field, form }: any) => (
-
- +
{ />
-
- -
+ {isAdmin && ( +
+ +
+ )}
diff --git a/frontend/src/modals/RedirectionHostModal.tsx b/frontend/src/modals/RedirectionHostModal.tsx index f9af6ccf9..78134c454 100644 --- a/frontend/src/modals/RedirectionHostModal.tsx +++ b/frontend/src/modals/RedirectionHostModal.tsx @@ -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(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(), onSuccess: () => { @@ -134,19 +142,21 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = -
  • - - - -
  • + {isAdmin && ( +
  • + + + +
  • + )}
    @@ -326,9 +336,11 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = />
    -
    - -
    + {isAdmin && ( +
    + +
    + )}
    diff --git a/frontend/src/pages/Users/Table.tsx b/frontend/src/pages/Users/Table.tsx index a5a61057b..49c64e6b8 100644 --- a/frontend/src/pages/Users/Table.tsx +++ b/frontend/src/pages/Users/Table.tsx @@ -90,6 +90,49 @@ export default function Table({ return ; }, }), + 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 ( + + + + ); + } + if (!permissions) { + return -; + } + 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 ( +
    + {items.map((it) => { + if (it.val === "hidden") return null; + const isManage = it.val === "manage"; + return ( + + : {it.val} + + ); + })} +
    + ); + }, + }), columnHelper.accessor((row: any) => row.isDisabled, { id: "isDisabled", header: intl.formatMessage({ id: "column.status" }), diff --git a/test/cypress/config/ci.mjs b/test/cypress/config/ci.mjs index 86c0a80df..b1e208190 100644 --- a/test/cypress/config/ci.mjs +++ b/test/cypress/config/ci.mjs @@ -7,6 +7,7 @@ export default defineConfig({ allowCypressEnv: false, requestTimeout: 30000, defaultCommandTimeout: 20000, + taskTimeout: 90000, reporter: "cypress-multi-reporters", reporterOptions: { configFile: "multi-reporter.json"