diff --git a/.gitignore b/.gitignore index a83a2110e0..7d6a1b1bdc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ docker/dev/dnsrouter-config.json.tmp docker/dev/resolv.conf .claude +# Runtime data & certificates +data/ +letsencrypt/ +*.sqlite +*.sqlite-journal + diff --git a/backend/internal/dead-host.js b/backend/internal/dead-host.js index 6523e62892..c8e0e17cde 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 2c159d48ad..ddebe654c0 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -57,6 +57,11 @@ 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"); + } + return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); }) .then((row) => { @@ -183,6 +188,15 @@ 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"); + } + return proxyHostModel .query() .where({ id: thisData.id }) @@ -243,7 +257,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) { @@ -430,7 +451,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 diff --git a/backend/internal/redirection-host.js b/backend/internal/redirection-host.js index 542439fd36..5e0f3a2604 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 d4080dd78f..92c12c0cce 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -39,7 +39,7 @@ const internalUser = { let user = await userModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); if (auth) { - user = await authModel.query().insert({ + await authModel.query().insert({ user_id: user.id, type: auth.type, secret: auth.secret, @@ -434,7 +434,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}`, @@ -444,6 +444,7 @@ const internalUser = { return user; }) .then((user) => { + const permData = _.omit(data, ["id"]); // Get perms row, patch if it exists return userPermissionModel .query() @@ -455,10 +456,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 5f96544ae5..c4cb8529e3 100644 --- a/backend/lib/access.js +++ b/backend/lib/access.js @@ -122,7 +122,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; @@ -196,6 +202,10 @@ export default function (tokenString) { return { token: Token, + getPermissions: () => permissions, + getRoles: () => userRoles, + hasRole: (role) => userRoles.includes(role), + /** * * @param {Boolean} [allowInternal] @@ -203,7 +213,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; @@ -238,6 +249,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 0000000000..a8610b0d87 --- /dev/null +++ b/backend/lib/express/rate-limit.js @@ -0,0 +1,94 @@ +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 clientIp = + req.headers["x-forwarded-for"]?.split(",")[0].trim() || + req.socket?.remoteAddress || + req.ip || + "unknown"; + + 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) { + res.on("finish", () => { + if (res.statusCode < 400) { + const cur = ipRequests.get(clientIp); + if (cur && cur.count > 0) { + cur.count--; + } + } + }); + } + + return next(); + } + + record.count++; + + if (skipSuccessfulRequests) { + res.on("finish", () => { + if (res.statusCode < 400) { + const cur = ipRequests.get(clientIp); + if (cur && cur.count > 0) { + cur.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 0000000000..1573c77d8d --- /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 cc6eac2cc7..0ad444c9c2 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/package.json b/backend/package.json index 4400076079..c275f14d25 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,5 +49,11 @@ "signale": { "displayDate": true, "displayTimestamp": true + }, + "resolutions": { + "qs": "^6.16.0" + }, + "overrides": { + "qs": "^6.16.0" } } diff --git a/backend/routes/tokens.js b/backend/routes/tokens.js index 8a6a1bc0fb..b5f25890cd 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 3f972223be..2cdb4101cd 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 cae9d26c02..74520d4b2c 100644 --- a/backend/schema/components/permission-object.json +++ b/backend/schema/components/permission-object.json @@ -43,6 +43,18 @@ "description": "Certificates Permissions", "enum": ["hidden", "view", "manage"], "example": "hidden" + }, + "meta": { + "type": "object", + "description": "Permission metadata", + "properties": { + "proxy_host_ids": { + "type": "array", + "items": { + "type": "integer" + } + } + } } } } diff --git a/backend/yarn.lock b/backend/yarn.lock index 61d5687f7e..c0671f5e84 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1911,10 +1911,10 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -qs@^6.14.0, qs@^6.15.2: - version "6.15.3" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" - integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== +qs@^6.14.0, qs@^6.15.2, qs@^6.16.0: + version "6.16.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd" + integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA== dependencies: es-define-property "^1.0.1" side-channel "^1.1.1" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..f54a8f9e27 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + app: + image: 'jc21/nginx-proxy-manager:latest' + container_name: nginx-proxy-manager + restart: unless-stopped + ports: + # Public HTTP Port: + - '80:80' + # Admin Web Port: + - '81:81' + # Public HTTPS Port: + - '443:443' + environment: + # Default admin user credentials: + INITIAL_ADMIN_EMAIL: "admin@example.com" + INITIAL_ADMIN_PASSWORD: "changeme" + PUID: 1000 + PGID: 1000 + # Uncomment if IPv6 is not supported on your host: + # DISABLE_IPV6: 'true' + volumes: + - ./data:/data + - ./letsencrypt:/etc/letsencrypt + - ./frontend/dist:/app/frontend:ro + - ./backend/internal/user.js:/app/internal/user.js:ro + - ./backend/routes/users.js:/app/routes/users.js:ro + - ./backend/routes/tokens.js:/app/routes/tokens.js:ro + - ./backend/lib/express/rate-limit.js:/app/lib/express/rate-limit.js:ro + - ./backend/lib/access.js:/app/lib/access.js:ro + - ./backend/internal/proxy-host.js:/app/internal/proxy-host.js:ro + - ./backend/internal/dead-host.js:/app/internal/dead-host.js:ro + - ./backend/internal/redirection-host.js:/app/internal/redirection-host.js:ro + - ./backend/models/user_permission.js:/app/models/user_permission.js:ro + - ./backend/schema/components/permission-object.json:/app/schema/components/permission-object.json:ro + - ./backend/migrations:/app/migrations:ro + - ./backend/node_modules/qs:/app/node_modules/qs:ro diff --git a/frontend/check-locales.cjs b/frontend/check-locales.cjs index 1ae7546415..e827546c69 100755 --- a/frontend/check-locales.cjs +++ b/frontend/check-locales.cjs @@ -30,6 +30,7 @@ const allLocales = [ ["hu", "hu-HU"], ["no", "no-NO"], ["uk", "uk-UA"], + ["uz", "uz-UZ"], ]; const ignoreUnused = [/^.*$/]; diff --git a/frontend/package.json b/frontend/package.json index e7c7363c3e..04534a8598 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "prettier": "biome format --write ./src", "locale-extract": "formatjs extract 'src/**/*.tsx'", "locale-compile": "formatjs compile-folder src/locale/src src/locale/lang", - "locale-sort": "./src/locale/scripts/locale-sort.sh", + "locale-sort": "node ./src/locale/scripts/locale-sort.cjs", "test": "vitest" }, "dependencies": { diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..1e6d176b35 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 23198c10c0..8a59fa08a3 100644 --- a/frontend/src/components/Form/LocationsFields.tsx +++ b/frontend/src/components/Form/LocationsFields.tsx @@ -233,10 +233,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
-
+
-
+
-
+
@@ -141,9 +151,11 @@ const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => { />
-
- -
+ {isAdmin && ( +
+ +
+ )}
diff --git a/frontend/src/modals/PermissionsModal.tsx b/frontend/src/modals/PermissionsModal.tsx index d363de9ede..a3e2e152de 100644 --- a/frontend/src/modals/PermissionsModal.tsx +++ b/frontend/src/modals/PermissionsModal.tsx @@ -7,7 +7,7 @@ import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; import { setPermissions } from "src/api/backend"; import { Button, Loading } from "src/components"; -import { useUser } from "src/hooks"; +import { useProxyHosts, useUser } from "src/hooks"; import { T } from "src/locale"; import styles from "./PermissionsModal.module.css"; @@ -22,6 +22,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) => { @@ -144,18 +145,24 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { - {() => ( + {({ values, setFieldValue }: any) => (
@@ -209,7 +216,11 @@ const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => { )}
- {!isAdmin && ( + {isAdmin ? ( + + : + + ) : ( <>
+ {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); + }} + /> + +
+ ); + }) + ) : ( +
+ +
+ )} +
+
+ )}
@@ -165,12 +174,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 9a0d95ce89..e7d3c430af 100644 --- a/frontend/src/modals/RedirectionHostModal.tsx +++ b/frontend/src/modals/RedirectionHostModal.tsx @@ -13,7 +13,7 @@ import { SSLCertificateField, SSLOptionsFields, } from "src/components"; -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"; @@ -26,11 +26,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); @@ -41,6 +44,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: () => { @@ -126,19 +134,21 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) = -
  • - - - -
  • + {isAdmin && ( +
  • + + + +
  • + )}
    @@ -318,9 +328,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 a5a61057bd..1405071148 100644 --- a/frontend/src/pages/Users/Table.tsx +++ b/frontend/src/pages/Users/Table.tsx @@ -90,6 +90,48 @@ 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: "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" }),