From 600eed64d7445e4c5bc65dd62f230f3026e81dba Mon Sep 17 00:00:00 2001 From: Magik <30931287+unseenmagik@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:35:31 +0000 Subject: [PATCH 001/122] feat(auth): support parent-based areaRestrictions in config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Commit description (detailed) - Added a new optional `parent: string[]` field to `authentication.areaRestrictions` so admins can grant access by parent area name (and automatically include all child areas). - Updated areaPerms.js to: - accept `parent` in the restriction rule, - build a parent→child key map from loaded geojson, - and grant access for both the parent area and its children. - Updated GraphQL area filtering (`scanAreas` / `scanAreasMenu`) to allow permissions to match by `feature.properties.key` (in addition to `name`/`parent`) so the filtering behavior stays consistent with the new parent-based key logic. - Updated type definitions (config.d.ts) to reflect the new optional `parent` array. - Updated the example config (local.example.json) with `parent: []` entries for documentation and schema clarity. --- config/local.example.json | 6 ++-- packages/config/lib/mutations.js | 3 +- packages/types/lib/config.d.ts | 2 +- server/src/graphql/resolvers.js | 5 +++- server/src/utils/areaPerms.js | 50 +++++++++++++++++++++++++------- 5 files changed, 51 insertions(+), 15 deletions(-) diff --git a/config/local.example.json b/config/local.example.json index 5f93a1674..b44e5a377 100644 --- a/config/local.example.json +++ b/config/local.example.json @@ -90,11 +90,13 @@ "areaRestrictions": [ { "roles": [], - "areas": [] + "areas": [], + "parent": [] }, { "roles": [], - "areas": [] + "areas": [], + "parent": [] } ], "aliases": [ diff --git a/packages/config/lib/mutations.js b/packages/config/lib/mutations.js index aad7c2c08..f18f87a04 100644 --- a/packages/config/lib/mutations.js +++ b/packages/config/lib/mutations.js @@ -307,9 +307,10 @@ const applyMutations = (config) => { }) config.authentication.areaRestrictions = - config.authentication.areaRestrictions.map(({ roles, areas }) => ({ + config.authentication.areaRestrictions.map(({ roles, areas, parent }) => ({ roles: roles.flatMap(replaceAliases), areas, + parent, })) config.authentication.strategies = config.authentication.strategies.map( diff --git a/packages/types/lib/config.d.ts b/packages/types/lib/config.d.ts index 4c098138e..e4747dc4d 100644 --- a/packages/types/lib/config.d.ts +++ b/packages/types/lib/config.d.ts @@ -53,7 +53,7 @@ export type Config = DeepMerge< } areas: ConfigAreas authentication: { - areaRestrictions: { roles: string[]; areas: string[] }[] + areaRestrictions: { roles: string[]; areas: string[]; parent?: string[] }[] // Unfortunately these types are not convenient for looping the `perms` object... // excludeFromTutorial: (keyof BaseConfig['authentication']['perms'])[] // alwaysEnabledPerms: (keyof BaseConfig['authentication']['perms'])[] diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index cf27d5fd8..6f43b8907 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -362,6 +362,7 @@ const resolvers = { (feature) => !feature.properties.hidden && (!perms.areaRestrictions.length || + perms.areaRestrictions.includes(feature.properties.key) || perms.areaRestrictions.includes(feature.properties.name) || perms.areaRestrictions.includes(feature.properties.parent)), ), @@ -380,7 +381,9 @@ const resolvers = { children: perms.areaRestrictions.includes(parent.name) ? parent.children : parent.children.filter((child) => - perms.areaRestrictions.includes(child.properties.name), + perms.areaRestrictions.includes(child.properties.key) || + perms.areaRestrictions.includes(child.properties.name) || + perms.areaRestrictions.includes(child.properties.parent), ), })) .filter((parent) => parent.children.length) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 596f239fe..7ff31fb9d 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -9,20 +9,50 @@ function areaPerms(roles) { const areaRestrictions = config.getSafe('authentication.areaRestrictions') const areas = config.getSafe('areas') + // Map parent names to child keys for easy lookup when parent-based restrictions are used. + const parentKeyMap = Object.values(areas.scanAreasObj).reduce( + (acc, feature) => { + const parent = feature.properties.parent + if (!parent) return acc + if (!acc[parent]) acc[parent] = [] + acc[parent].push(feature.properties.key) + return acc + }, + /** @type {Record} */ ({}), + ) + const perms = [] for (let i = 0; i < roles.length; i += 1) { for (let j = 0; j < areaRestrictions.length; j += 1) { - if (areaRestrictions[j].roles.includes(roles[i])) { - if (areaRestrictions[j].areas.length) { - for (let k = 0; k < areaRestrictions[j].areas.length; k += 1) { - if (areas.names.has(areaRestrictions[j].areas[k])) { - perms.push(areaRestrictions[j].areas[k]) - } else if (areas.withoutParents[areaRestrictions[j].areas[k]]) { - perms.push(...areas.withoutParents[areaRestrictions[j].areas[k]]) - } + if (!areaRestrictions[j].roles.includes(roles[i])) continue + + const hasAreas = Array.isArray(areaRestrictions[j].areas) + ? areaRestrictions[j].areas.length > 0 + : false + const hasParents = Array.isArray(areaRestrictions[j].parent) + ? areaRestrictions[j].parent.length > 0 + : false + + // No areas/parents means unrestricted access + if (!hasAreas && !hasParents) return [] + + if (hasAreas) { + for (let k = 0; k < areaRestrictions[j].areas.length; k += 1) { + const target = areaRestrictions[j].areas[k] + if (areas.names.has(target)) { + perms.push(target) + } else if (areas.withoutParents[target]) { + perms.push(...areas.withoutParents[target]) } - } else { - return [] + } + } + + if (hasParents) { + for (let k = 0; k < areaRestrictions[j].parent.length; k += 1) { + const parent = areaRestrictions[j].parent[k] + // If the parent itself exists as a top-level area, allow it too. + if (areas.names.has(parent)) perms.push(parent) + if (parentKeyMap[parent]) perms.push(...parentKeyMap[parent]) } } } From 37cd8ad813f9d302548a420f43bf4f1301b8d763 Mon Sep 17 00:00:00 2001 From: Magik <30931287+unseenmagik@users.noreply.github.com> Date: Fri, 20 Mar 2026 00:00:38 +0000 Subject: [PATCH 002/122] fix(areas): include parent keys in grouped child selection Adjust area permission handling and grouped child-area selection. Include the parent area key in grouped toggles when present, while preserving existing single-child behavior. --- server/src/graphql/resolvers.js | 9 +++++---- server/src/utils/areaPerms.js | 2 +- src/features/drawer/areas/Child.jsx | 20 +++++++++++--------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index 6f43b8907..ac4690796 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -380,10 +380,11 @@ const resolvers = { ...parent, children: perms.areaRestrictions.includes(parent.name) ? parent.children - : parent.children.filter((child) => - perms.areaRestrictions.includes(child.properties.key) || - perms.areaRestrictions.includes(child.properties.name) || - perms.areaRestrictions.includes(child.properties.parent), + : parent.children.filter( + (child) => + perms.areaRestrictions.includes(child.properties.key) || + perms.areaRestrictions.includes(child.properties.name) || + perms.areaRestrictions.includes(child.properties.parent), ), })) .filter((parent) => parent.children.length) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 7ff31fb9d..fc3c2f5c4 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -12,7 +12,7 @@ function areaPerms(roles) { // Map parent names to child keys for easy lookup when parent-based restrictions are used. const parentKeyMap = Object.values(areas.scanAreasObj).reduce( (acc, feature) => { - const parent = feature.properties.parent + const { parent } = feature.properties if (!parent) return acc if (!acc[parent]) acc[parent] = [] acc[parent].push(feature.properties.key) diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index a22f72aa8..fd561b3c4 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -103,15 +103,17 @@ export function AreaChild({ indeterminate={name ? hasSome && !hasAll : false} checked={name ? hasAll : scanAreas.includes(feature.properties.key)} onClick={(e) => e.stopPropagation()} - onChange={() => - setAreas( - name - ? childAreas.map((c) => c.properties.key) - : feature.properties.key, - allAreas, - name ? hasSome : false, - ) - } + onChange={() => { + const areaKeys = name + ? [ + ...(feature?.properties?.key + ? [feature.properties.key] + : []), + ...childAreas.map((c) => c.properties.key), + ] + : feature.properties.key + setAreas(areaKeys, allAreas, name ? hasSome : false) + }} sx={{ p: 1, color, From 8a136a0335af9624ae5074e5a90e25995f4e4a35 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 25 Mar 2026 23:08:56 -0400 Subject: [PATCH 003/122] fix(areas): normalize parent restrictions to area keys --- server/src/utils/areaPerms.js | 88 ++++++++++++++++++------- src/features/drawer/areas/AreaTable.jsx | 19 ++++-- src/features/drawer/areas/Child.jsx | 35 +++++----- 3 files changed, 98 insertions(+), 44 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index fc3c2f5c4..5540d08cc 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -1,6 +1,63 @@ // @ts-check const config = require('@rm/config') +/** + * @param {Record} scanAreasObj + * @returns {Record} + */ +function getParentKeyMap(scanAreasObj) { + return Object.values(scanAreasObj).reduce((acc, feature) => { + const { key, parent } = feature.properties + if (!parent || !key) return acc + if (!acc[parent]) acc[parent] = [] + acc[parent].push(key) + return acc + }, /** @type {Record} */ ({})) +} + +/** + * Resolves config entries into canonical area keys. + * `parent` rules expand to both the parent's own area key and all child keys. + * + * @param {string[]} perms + * @param {string | undefined} target + * @param {{ + * names: Set, + * scanAreasObj: Record, + * withoutParents: Record, + * }} areas + * @param {Record} parentKeyMap + * @param {boolean} [includeChildren] + */ +function pushAreaKeys( + perms, + target, + areas, + parentKeyMap, + includeChildren = false, +) { + if (!target) return + + if (areas.names.has(target)) { + perms.push(target) + + if (includeChildren) { + const parentName = areas.scanAreasObj[target]?.properties?.name + if (parentName && parentKeyMap[parentName]) { + perms.push(...parentKeyMap[parentName]) + } + } + } + + if (areas.withoutParents[target]) { + perms.push(...areas.withoutParents[target]) + } + + if (includeChildren && parentKeyMap[target]) { + perms.push(...parentKeyMap[target]) + } +} + /** * @param {string[]} roles * @returns {string[]} @@ -8,18 +65,7 @@ const config = require('@rm/config') function areaPerms(roles) { const areaRestrictions = config.getSafe('authentication.areaRestrictions') const areas = config.getSafe('areas') - - // Map parent names to child keys for easy lookup when parent-based restrictions are used. - const parentKeyMap = Object.values(areas.scanAreasObj).reduce( - (acc, feature) => { - const { parent } = feature.properties - if (!parent) return acc - if (!acc[parent]) acc[parent] = [] - acc[parent].push(feature.properties.key) - return acc - }, - /** @type {Record} */ ({}), - ) + const parentKeyMap = getParentKeyMap(areas.scanAreasObj) const perms = [] for (let i = 0; i < roles.length; i += 1) { @@ -38,21 +84,19 @@ function areaPerms(roles) { if (hasAreas) { for (let k = 0; k < areaRestrictions[j].areas.length; k += 1) { - const target = areaRestrictions[j].areas[k] - if (areas.names.has(target)) { - perms.push(target) - } else if (areas.withoutParents[target]) { - perms.push(...areas.withoutParents[target]) - } + pushAreaKeys(perms, areaRestrictions[j].areas[k], areas, parentKeyMap) } } if (hasParents) { for (let k = 0; k < areaRestrictions[j].parent.length; k += 1) { - const parent = areaRestrictions[j].parent[k] - // If the parent itself exists as a top-level area, allow it too. - if (areas.names.has(parent)) perms.push(parent) - if (parentKeyMap[parent]) perms.push(...parentKeyMap[parent]) + pushAreaKeys( + perms, + areaRestrictions[j].parent[k], + areas, + parentKeyMap, + true, + ) } } } diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index ffdfcf83d..78fbaa3e5 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -49,12 +49,19 @@ export function ScanAreasTable() { /** @type {string[]} */ const allAreas = React.useMemo( - () => - data?.scanAreasMenu.flatMap((parent) => - parent.children - .filter((child) => !child.properties.manual) - .map((child) => child.properties.key), - ) || [], + () => [ + ...new Set( + data?.scanAreasMenu.flatMap((parent) => [ + ...(parent.details?.properties?.key && + !parent.details.properties.manual + ? [parent.details.properties.key] + : []), + ...parent.children + .filter((child) => !child.properties.manual) + .map((child) => child.properties.key), + ]) || [], + ), + ], [data], ) diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index fd561b3c4..1871712ea 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -40,17 +40,27 @@ export function AreaChild({ if (!scanAreas) return null + const groupedAreaKeys = [ + ...(feature?.properties?.key && !feature.properties.manual + ? [feature.properties.key] + : []), + ...(childAreas || []) + .filter((child) => !child.properties.manual) + .map((child) => child.properties.key), + ] const hasAll = - childAreas && - childAreas.every( - (c) => c.properties.manual || scanAreas.includes(c.properties.key), - ) + name && groupedAreaKeys.length + ? groupedAreaKeys.every((key) => scanAreas.includes(key)) + : false const hasSome = - childAreas && childAreas.some((c) => scanAreas.includes(c.properties.key)) + name && groupedAreaKeys.length + ? groupedAreaKeys.some((key) => scanAreas.includes(key)) + : false const hasManual = - feature?.properties?.manual || childAreas.every((c) => c.properties.manual) + feature?.properties?.manual || + (childAreas || []).every((child) => child.properties.manual) const color = - hasManual || (name ? !childAreas.length : !feature.properties.name) + hasManual || (name ? !groupedAreaKeys.length : !feature.properties.name) ? 'transparent' : 'none' @@ -104,14 +114,7 @@ export function AreaChild({ checked={name ? hasAll : scanAreas.includes(feature.properties.key)} onClick={(e) => e.stopPropagation()} onChange={() => { - const areaKeys = name - ? [ - ...(feature?.properties?.key - ? [feature.properties.key] - : []), - ...childAreas.map((c) => c.properties.key), - ] - : feature.properties.key + const areaKeys = name ? groupedAreaKeys : feature.properties.key setAreas(areaKeys, allAreas, name ? hasSome : false) }} sx={{ @@ -125,7 +128,7 @@ export function AreaChild({ }, }} disabled={ - (name ? !childAreas.length : !feature.properties.name) || + (name ? !groupedAreaKeys.length : !feature.properties.name) || hasManual } /> From f3794da19a0903b07b2ba0445c7c9a102d26f6a1 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 25 Mar 2026 23:22:48 -0400 Subject: [PATCH 004/122] fix(areas): keep parent rows aligned with visible permissions --- server/src/graphql/resolvers.js | 46 ++++++++++++++------- src/features/drawer/areas/AreaTable.jsx | 55 +++++++++++++++---------- src/features/drawer/areas/Child.jsx | 2 +- 3 files changed, 64 insertions(+), 39 deletions(-) diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index ac4690796..ad7c09f71 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -355,16 +355,18 @@ const resolvers = { scanAreas: (_, _args, { req, perms }) => { if (perms?.scanAreas) { const scanAreas = config.getAreas(req, 'scanAreas') + const canAccessArea = (properties) => + !perms.areaRestrictions.length || + perms.areaRestrictions.includes(properties.key) || + perms.areaRestrictions.includes(properties.name) || + perms.areaRestrictions.includes(properties.parent) + return [ { ...scanAreas, features: scanAreas.features.filter( (feature) => - !feature.properties.hidden && - (!perms.areaRestrictions.length || - perms.areaRestrictions.includes(feature.properties.key) || - perms.areaRestrictions.includes(feature.properties.name) || - perms.areaRestrictions.includes(feature.properties.parent)), + !feature.properties.hidden && canAccessArea(feature.properties), ), }, ] @@ -374,20 +376,32 @@ const resolvers = { scanAreasMenu: (_, _args, { req, perms }) => { if (perms?.scanAreas) { const scanAreas = config.getAreas(req, 'scanAreasMenu') + const baseMenu = scanAreas.map((parent) => ({ + ...parent, + details: + parent.details && !parent.details.properties.hidden + ? parent.details + : null, + })) + if (perms.areaRestrictions.length) { - const filtered = scanAreas + const canAccessArea = (properties) => + perms.areaRestrictions.includes(properties.key) || + perms.areaRestrictions.includes(properties.name) || + perms.areaRestrictions.includes(properties.parent) + + const filtered = baseMenu .map((parent) => ({ ...parent, - children: perms.areaRestrictions.includes(parent.name) - ? parent.children - : parent.children.filter( - (child) => - perms.areaRestrictions.includes(child.properties.key) || - perms.areaRestrictions.includes(child.properties.name) || - perms.areaRestrictions.includes(child.properties.parent), - ), + details: + parent.details && canAccessArea(parent.details.properties) + ? parent.details + : null, + children: parent.children.filter((child) => + canAccessArea(child.properties), + ), })) - .filter((parent) => parent.children.length) + .filter((parent) => parent.details || parent.children.length) // // Adds new blanks to account for area restrictions trimming some // filtered.forEach(({ children }) => { @@ -403,7 +417,7 @@ const resolvers = { // }) return filtered } - return scanAreas.filter((parent) => parent.children.length) + return baseMenu.filter((parent) => parent.children.length) } return [] }, diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 78fbaa3e5..65367973f 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -70,6 +70,11 @@ export function ScanAreasTable() { (data?.scanAreasMenu || []) .map((area) => ({ ...area, + details: + search === '' || + area.details?.properties?.key?.toLowerCase()?.includes(search) + ? area.details + : null, children: area.children.filter( (feature) => search === '' || @@ -97,7 +102,11 @@ export function ScanAreasTable() { ) const totalMatches = React.useMemo( - () => allRows.reduce((sum, area) => sum + area.children.length, 0), + () => + allRows.reduce( + (sum, area) => sum + area.children.length + (area.details ? 1 : 0), + 0, + ), [allRows], ) @@ -184,7 +193,7 @@ export function ScanAreasTable() { > {allRows.map(({ name, details, children, rows }) => { - if (!children.length) return null + if (!children.length && !details) return null return ( {name && ( @@ -198,26 +207,28 @@ export function ScanAreasTable() { /> )} - - - {rows.map((row, i) => ( - - {row.map((feature, j) => ( - - ))} - - ))} - - + {!!rows.length && ( + + + {rows.map((row, i) => ( + + {row.map((feature, j) => ( + + ))} + + ))} + + + )} ) })} diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 1871712ea..04725b97e 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -66,7 +66,7 @@ export function AreaChild({ const nameProp = name || feature?.properties?.formattedName || feature?.properties?.name - const hasExpand = name && !expandAllScanAreas + const hasExpand = name && !expandAllScanAreas && !!childAreas?.length return ( Date: Thu, 26 Mar 2026 00:41:10 -0400 Subject: [PATCH 005/122] fix(areas): handle header-only parents and domain scoping --- server/src/utils/areaPerms.js | 76 +++++++++++++++++++---------- src/features/drawer/areas/Child.jsx | 6 +-- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 5540d08cc..935b80170 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -2,17 +2,45 @@ const config = require('@rm/config') /** - * @param {Record} scanAreasObj - * @returns {Record} + * @param {Record} scanAreas + * @returns {{ + * keyDomainMap: Record, + * parentKeyMap: Record, + * scopedParentKeyMap: Record, + * }} */ -function getParentKeyMap(scanAreasObj) { - return Object.values(scanAreasObj).reduce((acc, feature) => { - const { key, parent } = feature.properties - if (!parent || !key) return acc - if (!acc[parent]) acc[parent] = [] - acc[parent].push(key) - return acc - }, /** @type {Record} */ ({})) +function getAreaMaps(scanAreas) { + return Object.entries(scanAreas).reduce( + (acc, [domain, featureCollection]) => { + featureCollection.features.forEach((feature) => { + const { key, parent } = feature.properties + if (!key) return + + acc.keyDomainMap[key] = domain + + if (!parent) return + + if (!acc.parentKeyMap[parent]) acc.parentKeyMap[parent] = [] + acc.parentKeyMap[parent].push(key) + + const scopedKey = `${domain}:${parent}` + if (!acc.scopedParentKeyMap[scopedKey]) { + acc.scopedParentKeyMap[scopedKey] = [] + } + acc.scopedParentKeyMap[scopedKey].push(key) + }) + return acc + }, + /** @type {{ + * keyDomainMap: Record, + * parentKeyMap: Record, + * scopedParentKeyMap: Record, + * }} */ ({ + keyDomainMap: {}, + parentKeyMap: {}, + scopedParentKeyMap: {}, + }), + ) } /** @@ -26,16 +54,10 @@ function getParentKeyMap(scanAreasObj) { * scanAreasObj: Record, * withoutParents: Record, * }} areas - * @param {Record} parentKeyMap + * @param {ReturnType} areaMaps * @param {boolean} [includeChildren] */ -function pushAreaKeys( - perms, - target, - areas, - parentKeyMap, - includeChildren = false, -) { +function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (!target) return if (areas.names.has(target)) { @@ -43,8 +65,12 @@ function pushAreaKeys( if (includeChildren) { const parentName = areas.scanAreasObj[target]?.properties?.name - if (parentName && parentKeyMap[parentName]) { - perms.push(...parentKeyMap[parentName]) + const domain = areaMaps.keyDomainMap[target] + const scopedKey = + parentName && domain ? `${domain}:${parentName}` : undefined + + if (scopedKey && areaMaps.scopedParentKeyMap[scopedKey]) { + perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) } } } @@ -53,8 +79,8 @@ function pushAreaKeys( perms.push(...areas.withoutParents[target]) } - if (includeChildren && parentKeyMap[target]) { - perms.push(...parentKeyMap[target]) + if (includeChildren && areaMaps.parentKeyMap[target]) { + perms.push(...areaMaps.parentKeyMap[target]) } } @@ -65,7 +91,7 @@ function pushAreaKeys( function areaPerms(roles) { const areaRestrictions = config.getSafe('authentication.areaRestrictions') const areas = config.getSafe('areas') - const parentKeyMap = getParentKeyMap(areas.scanAreasObj) + const areaMaps = getAreaMaps(areas.scanAreas) const perms = [] for (let i = 0; i < roles.length; i += 1) { @@ -84,7 +110,7 @@ function areaPerms(roles) { if (hasAreas) { for (let k = 0; k < areaRestrictions[j].areas.length; k += 1) { - pushAreaKeys(perms, areaRestrictions[j].areas[k], areas, parentKeyMap) + pushAreaKeys(perms, areaRestrictions[j].areas[k], areas, areaMaps) } } @@ -94,7 +120,7 @@ function areaPerms(roles) { perms, areaRestrictions[j].parent[k], areas, - parentKeyMap, + areaMaps, true, ) } diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 04725b97e..f68ac463d 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -56,9 +56,9 @@ export function AreaChild({ name && groupedAreaKeys.length ? groupedAreaKeys.some((key) => scanAreas.includes(key)) : false - const hasManual = - feature?.properties?.manual || - (childAreas || []).every((child) => child.properties.manual) + const allChildrenManual = + !!childAreas?.length && childAreas.every((child) => child.properties.manual) + const hasManual = feature?.properties?.manual || allChildrenManual const color = hasManual || (name ? !groupedAreaKeys.length : !feature.properties.name) ? 'transparent' From 1ffbdfdd30e1e012a20fef35826cdcce28984784 Mon Sep 17 00:00:00 2001 From: Mygod Date: Thu, 26 Mar 2026 16:17:46 -0400 Subject: [PATCH 006/122] fix(areas): tighten parent restriction expansion --- server/src/utils/areaPerms.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 935b80170..87ebd1065 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -5,7 +5,7 @@ const config = require('@rm/config') * @param {Record} scanAreas * @returns {{ * keyDomainMap: Record, - * parentKeyMap: Record, + * parentDomainsMap: Record, * scopedParentKeyMap: Record, * }} */ @@ -13,17 +13,19 @@ function getAreaMaps(scanAreas) { return Object.entries(scanAreas).reduce( (acc, [domain, featureCollection]) => { featureCollection.features.forEach((feature) => { - const { key, parent } = feature.properties + const { hidden, key, parent } = feature.properties if (!key) return acc.keyDomainMap[key] = domain - if (!parent) return - - if (!acc.parentKeyMap[parent]) acc.parentKeyMap[parent] = [] - acc.parentKeyMap[parent].push(key) + // Hidden children should not widen backend access through parent expansion. + if (!parent || hidden) return const scopedKey = `${domain}:${parent}` + if (!acc.parentDomainsMap[parent]) acc.parentDomainsMap[parent] = [] + if (!acc.parentDomainsMap[parent].includes(domain)) { + acc.parentDomainsMap[parent].push(domain) + } if (!acc.scopedParentKeyMap[scopedKey]) { acc.scopedParentKeyMap[scopedKey] = [] } @@ -33,11 +35,11 @@ function getAreaMaps(scanAreas) { }, /** @type {{ * keyDomainMap: Record, - * parentKeyMap: Record, + * parentDomainsMap: Record, * scopedParentKeyMap: Record, * }} */ ({ keyDomainMap: {}, - parentKeyMap: {}, + parentDomainsMap: {}, scopedParentKeyMap: {}, }), ) @@ -79,8 +81,13 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { perms.push(...areas.withoutParents[target]) } - if (includeChildren && areaMaps.parentKeyMap[target]) { - perms.push(...areaMaps.parentKeyMap[target]) + // Bare parent names are ambiguous across multi-domain configs, so only + // expand children when the parent label resolves to exactly one domain. + if (includeChildren && areaMaps.parentDomainsMap[target]?.length === 1) { + const scopedKey = `${areaMaps.parentDomainsMap[target][0]}:${target}` + if (areaMaps.scopedParentKeyMap[scopedKey]) { + perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) + } } } From f7562fa5e0285696b2bc9e6f68f2481be53e1252 Mon Sep 17 00:00:00 2001 From: Mygod Date: Thu, 26 Mar 2026 16:55:55 -0400 Subject: [PATCH 007/122] fix(areas): preserve parent group access --- server/src/utils/areaPerms.js | 32 +++++++++++++++++++++---- src/features/drawer/areas/AreaTable.jsx | 4 +++- src/features/drawer/areas/Child.jsx | 8 +++++-- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 87ebd1065..24b377dd1 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -7,19 +7,31 @@ const config = require('@rm/config') * keyDomainMap: Record, * parentDomainsMap: Record, * scopedParentKeyMap: Record, + * scopedParentAreaKeyMap: Record, * }} */ function getAreaMaps(scanAreas) { return Object.entries(scanAreas).reduce( (acc, [domain, featureCollection]) => { + /** @type {Record} */ + const areaKeysByName = {} + featureCollection.features.forEach((feature) => { - const { hidden, key, parent } = feature.properties + const { key, name, parent } = feature.properties if (!key) return acc.keyDomainMap[key] = domain + if (name && !parent) { + if (!areaKeysByName[name]) areaKeysByName[name] = [] + areaKeysByName[name].push(key) + } + }) + + featureCollection.features.forEach((feature) => { + const { hidden, key, parent } = feature.properties // Hidden children should not widen backend access through parent expansion. - if (!parent || hidden) return + if (!key || !parent || hidden) return const scopedKey = `${domain}:${parent}` if (!acc.parentDomainsMap[parent]) acc.parentDomainsMap[parent] = [] @@ -30,6 +42,11 @@ function getAreaMaps(scanAreas) { acc.scopedParentKeyMap[scopedKey] = [] } acc.scopedParentKeyMap[scopedKey].push(key) + + if (areaKeysByName[parent]?.length === 1) { + const [parentAreaKey] = areaKeysByName[parent] + acc.scopedParentAreaKeyMap[scopedKey] = parentAreaKey + } }) return acc }, @@ -37,10 +54,12 @@ function getAreaMaps(scanAreas) { * keyDomainMap: Record, * parentDomainsMap: Record, * scopedParentKeyMap: Record, + * scopedParentAreaKeyMap: Record, * }} */ ({ keyDomainMap: {}, parentDomainsMap: {}, scopedParentKeyMap: {}, + scopedParentAreaKeyMap: {}, }), ) } @@ -62,11 +81,13 @@ function getAreaMaps(scanAreas) { function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (!target) return - if (areas.names.has(target)) { + const parentFeature = includeChildren ? areas.scanAreasObj[target] : null + + if (areas.names.has(target) || parentFeature?.properties?.key === target) { perms.push(target) if (includeChildren) { - const parentName = areas.scanAreasObj[target]?.properties?.name + const parentName = parentFeature?.properties?.name const domain = areaMaps.keyDomainMap[target] const scopedKey = parentName && domain ? `${domain}:${parentName}` : undefined @@ -85,6 +106,9 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { // expand children when the parent label resolves to exactly one domain. if (includeChildren && areaMaps.parentDomainsMap[target]?.length === 1) { const scopedKey = `${areaMaps.parentDomainsMap[target][0]}:${target}` + if (areaMaps.scopedParentAreaKeyMap[scopedKey]) { + perms.push(areaMaps.scopedParentAreaKeyMap[scopedKey]) + } if (areaMaps.scopedParentKeyMap[scopedKey]) { perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) } diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 65367973f..267c24b19 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -70,6 +70,7 @@ export function ScanAreasTable() { (data?.scanAreasMenu || []) .map((area) => ({ ...area, + groupedChildren: area.children, details: search === '' || area.details?.properties?.key?.toLowerCase()?.includes(search) @@ -192,7 +193,7 @@ export function ScanAreasTable() { })} > - {allRows.map(({ name, details, children, rows }) => { + {allRows.map(({ name, details, children, groupedChildren, rows }) => { if (!children.length && !details) return null return ( @@ -203,6 +204,7 @@ export function ScanAreasTable() { feature={details} allAreas={allAreas} childAreas={children} + groupedAreas={groupedChildren} colSpan={2} /> diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index f68ac463d..fbfe2dcf0 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -18,6 +18,7 @@ import { useMemory } from '@store/useMemory' * feature?: Pick * allAreas?: string[] * childAreas?: Pick[] + * groupedAreas?: Pick[] * borderRight?: boolean * colSpan?: number * }} props @@ -26,6 +27,7 @@ export function AreaChild({ name, feature, childAreas, + groupedAreas, allAreas, borderRight, colSpan = 1, @@ -40,11 +42,12 @@ export function AreaChild({ if (!scanAreas) return null + const groupedChildren = groupedAreas || childAreas || [] const groupedAreaKeys = [ ...(feature?.properties?.key && !feature.properties.manual ? [feature.properties.key] : []), - ...(childAreas || []) + ...groupedChildren .filter((child) => !child.properties.manual) .map((child) => child.properties.key), ] @@ -57,7 +60,8 @@ export function AreaChild({ ? groupedAreaKeys.some((key) => scanAreas.includes(key)) : false const allChildrenManual = - !!childAreas?.length && childAreas.every((child) => child.properties.manual) + !!groupedChildren.length && + groupedChildren.every((child) => child.properties.manual) const hasManual = feature?.properties?.manual || allChildrenManual const color = hasManual || (name ? !groupedAreaKeys.length : !feature.properties.name) From 889b8528f83624b06dcaa52970a2e2a90a2cb76c Mon Sep 17 00:00:00 2001 From: Mygod Date: Thu, 26 Mar 2026 18:49:04 -0400 Subject: [PATCH 008/122] fix(areas): tighten parent filter keys --- server/src/utils/areaPerms.js | 27 +++++++++++++++++++++++---- src/features/drawer/areas/Child.jsx | 11 +++-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 24b377dd1..9c491bc01 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -17,11 +17,11 @@ function getAreaMaps(scanAreas) { const areaKeysByName = {} featureCollection.features.forEach((feature) => { - const { key, name, parent } = feature.properties + const { hidden, key, name, parent } = feature.properties if (!key) return acc.keyDomainMap[key] = domain - if (name && !parent) { + if (name && !parent && !hidden) { if (!areaKeysByName[name]) areaKeysByName[name] = [] areaKeysByName[name].push(key) } @@ -82,8 +82,13 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (!target) return const parentFeature = includeChildren ? areas.scanAreasObj[target] : null + const canIncludeOwnKey = + !includeChildren || !parentFeature?.properties?.hidden - if (areas.names.has(target) || parentFeature?.properties?.key === target) { + if ( + canIncludeOwnKey && + (areas.names.has(target) || parentFeature?.properties?.key === target) + ) { perms.push(target) if (includeChildren) { @@ -96,10 +101,24 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) } } + } else if (includeChildren && parentFeature?.properties?.key === target) { + const parentName = parentFeature?.properties?.name + const domain = areaMaps.keyDomainMap[target] + const scopedKey = + parentName && domain ? `${domain}:${parentName}` : undefined + + if (scopedKey && areaMaps.scopedParentKeyMap[scopedKey]) { + perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) + } } if (areas.withoutParents[target]) { - perms.push(...areas.withoutParents[target]) + perms.push( + ...areas.withoutParents[target].filter( + (key) => + !includeChildren || !areas.scanAreasObj[key]?.properties?.hidden, + ), + ) } // Bare parent names are ambiguous across multi-domain configs, so only diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index fbfe2dcf0..4f2316f9c 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -43,14 +43,9 @@ export function AreaChild({ if (!scanAreas) return null const groupedChildren = groupedAreas || childAreas || [] - const groupedAreaKeys = [ - ...(feature?.properties?.key && !feature.properties.manual - ? [feature.properties.key] - : []), - ...groupedChildren - .filter((child) => !child.properties.manual) - .map((child) => child.properties.key), - ] + const groupedAreaKeys = groupedChildren + .filter((child) => !child.properties.manual) + .map((child) => child.properties.key) const hasAll = name && groupedAreaKeys.length ? groupedAreaKeys.every((key) => scanAreas.includes(key)) From c557395ef21fb06b788593b37dbd789eab878b2a Mon Sep 17 00:00:00 2001 From: Mygod Date: Thu, 26 Mar 2026 21:32:11 -0400 Subject: [PATCH 009/122] fix(auth): close parent area auth gaps --- server/src/graphql/resolvers.js | 6 ++---- server/src/utils/areaPerms.js | 10 +++++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index ad7c09f71..e2cf05931 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -358,8 +358,7 @@ const resolvers = { const canAccessArea = (properties) => !perms.areaRestrictions.length || perms.areaRestrictions.includes(properties.key) || - perms.areaRestrictions.includes(properties.name) || - perms.areaRestrictions.includes(properties.parent) + perms.areaRestrictions.includes(properties.name) return [ { @@ -387,8 +386,7 @@ const resolvers = { if (perms.areaRestrictions.length) { const canAccessArea = (properties) => perms.areaRestrictions.includes(properties.key) || - perms.areaRestrictions.includes(properties.name) || - perms.areaRestrictions.includes(properties.parent) + perms.areaRestrictions.includes(properties.name) const filtered = baseMenu .map((parent) => ({ diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 9c491bc01..e5aa7e506 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -1,6 +1,8 @@ // @ts-check const config = require('@rm/config') +const NO_ACCESS_SENTINEL = '__rm_no_access__' + /** * @param {Record} scanAreas * @returns {{ @@ -144,6 +146,7 @@ function areaPerms(roles) { const areaMaps = getAreaMaps(areas.scanAreas) const perms = [] + let matchedRestrictedRule = false for (let i = 0; i < roles.length; i += 1) { for (let j = 0; j < areaRestrictions.length; j += 1) { if (!areaRestrictions[j].roles.includes(roles[i])) continue @@ -158,6 +161,8 @@ function areaPerms(roles) { // No areas/parents means unrestricted access if (!hasAreas && !hasParents) return [] + matchedRestrictedRule = true + if (hasAreas) { for (let k = 0; k < areaRestrictions[j].areas.length; k += 1) { pushAreaKeys(perms, areaRestrictions[j].areas[k], areas, areaMaps) @@ -177,7 +182,10 @@ function areaPerms(roles) { } } } - return [...new Set(perms)] + const uniquePerms = [...new Set(perms)] + return matchedRestrictedRule && !uniquePerms.length + ? [NO_ACCESS_SENTINEL] + : uniquePerms } module.exports = { areaPerms } From 60386340188cfe68922550e7f32903ebe86aff01 Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 27 Mar 2026 13:36:35 -0400 Subject: [PATCH 010/122] fix(areas): harden mem filters and parent rows --- server/src/utils/filterRTree.js | 2 +- src/features/drawer/areas/Child.jsx | 34 ++++++++++++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/server/src/utils/filterRTree.js b/server/src/utils/filterRTree.js index cbca8cdea..f87f9c51c 100644 --- a/server/src/utils/filterRTree.js +++ b/server/src/utils/filterRTree.js @@ -18,7 +18,7 @@ function filterRTree(item, areaRestrictions = [], onlyAreas = []) { const consolidatedAreas = consolidateAreas(areaRestrictions, onlyAreas) - if (!consolidatedAreas.size) return true + if (!consolidatedAreas.size) return false /** @type {import("@rm/types").RMGeoJSON['features']} */ const foundFeatures = config diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 4f2316f9c..c9d2810c3 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -46,20 +46,36 @@ export function AreaChild({ const groupedAreaKeys = groupedChildren .filter((child) => !child.properties.manual) .map((child) => child.properties.key) + const fallbackAreaKeys = + name && + !groupedAreaKeys.length && + feature?.properties?.key && + !feature.properties.manual + ? [feature.properties.key] + : [] + const selectableAreaKeys = name + ? groupedAreaKeys.length + ? groupedAreaKeys + : fallbackAreaKeys + : [] const hasAll = - name && groupedAreaKeys.length - ? groupedAreaKeys.every((key) => scanAreas.includes(key)) + name && selectableAreaKeys.length + ? selectableAreaKeys.every((key) => scanAreas.includes(key)) : false const hasSome = - name && groupedAreaKeys.length - ? groupedAreaKeys.some((key) => scanAreas.includes(key)) + name && selectableAreaKeys.length + ? selectableAreaKeys.some((key) => scanAreas.includes(key)) : false const allChildrenManual = + name && !!groupedChildren.length && groupedChildren.every((child) => child.properties.manual) - const hasManual = feature?.properties?.manual || allChildrenManual + const hasManual = name + ? !selectableAreaKeys.length && + (feature?.properties?.manual || allChildrenManual) + : feature?.properties?.manual const color = - hasManual || (name ? !groupedAreaKeys.length : !feature.properties.name) + hasManual || (name ? !selectableAreaKeys.length : !feature.properties.name) ? 'transparent' : 'none' @@ -113,7 +129,9 @@ export function AreaChild({ checked={name ? hasAll : scanAreas.includes(feature.properties.key)} onClick={(e) => e.stopPropagation()} onChange={() => { - const areaKeys = name ? groupedAreaKeys : feature.properties.key + const areaKeys = name + ? selectableAreaKeys + : feature.properties.key setAreas(areaKeys, allAreas, name ? hasSome : false) }} sx={{ @@ -127,7 +145,7 @@ export function AreaChild({ }, }} disabled={ - (name ? !groupedAreaKeys.length : !feature.properties.name) || + (name ? !selectableAreaKeys.length : !feature.properties.name) || hasManual } /> From 1185cbcc91358859d82e6d69f4faa3dc9e7deb94 Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 27 Mar 2026 13:52:01 -0400 Subject: [PATCH 011/122] fix(areas): restore legacy parent behavior --- server/src/utils/areaPerms.js | 22 ++++++++++++++++++++-- server/src/utils/consolidateAreas.js | 5 +---- server/src/utils/getServerSettings.js | 21 +++++++++++++++++---- src/features/drawer/areas/Child.jsx | 12 +++++++++--- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index e5aa7e506..fa3b0fd16 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -3,6 +3,14 @@ const config = require('@rm/config') const NO_ACCESS_SENTINEL = '__rm_no_access__' +/** + * @param {string[]} [areaRestrictions] + * @returns {string[]} + */ +function getPublicAreaRestrictions(areaRestrictions = []) { + return areaRestrictions.filter((area) => area !== NO_ACCESS_SENTINEL) +} + /** * @param {Record} scanAreas * @returns {{ @@ -165,7 +173,13 @@ function areaPerms(roles) { if (hasAreas) { for (let k = 0; k < areaRestrictions[j].areas.length; k += 1) { - pushAreaKeys(perms, areaRestrictions[j].areas[k], areas, areaMaps) + pushAreaKeys( + perms, + areaRestrictions[j].areas[k], + areas, + areaMaps, + true, + ) } } @@ -188,4 +202,8 @@ function areaPerms(roles) { : uniquePerms } -module.exports = { areaPerms } +module.exports = { + areaPerms, + getPublicAreaRestrictions, + NO_ACCESS_SENTINEL, +} diff --git a/server/src/utils/consolidateAreas.js b/server/src/utils/consolidateAreas.js index 3eeae1775..9c408966d 100644 --- a/server/src/utils/consolidateAreas.js +++ b/server/src/utils/consolidateAreas.js @@ -15,10 +15,7 @@ function consolidateAreas(areaRestrictions = [], onlyAreas = []) { const validUserAreas = onlyAreas.filter((a) => areas.names.has(a)) const cleanedValidUserAreas = validUserAreas.filter((area) => - areaRestrictions.length - ? areaRestrictions.includes(area) || - areaRestrictions.includes(areas.scanAreasObj[area].properties.parent) - : true, + areaRestrictions.length ? areaRestrictions.includes(area) : true, ) return new Set( cleanedValidUserAreas.length diff --git a/server/src/utils/getServerSettings.js b/server/src/utils/getServerSettings.js index 790845db0..87ac4d342 100644 --- a/server/src/utils/getServerSettings.js +++ b/server/src/utils/getServerSettings.js @@ -4,6 +4,7 @@ const config = require('@rm/config') const { clientOptions } = require('../ui/clientOptions') const { advMenus } = require('../ui/advMenus') const { drawer } = require('../ui/drawer') +const { getPublicAreaRestrictions } = require('./areaPerms') /** * @@ -17,7 +18,19 @@ function getServerSettings(req) { cooldown: req.session?.cooldown || 0, }) - const { clientValues, clientMenus } = clientOptions(user.perms) + const safeUser = { + ...user, + perms: user.perms + ? { + ...user.perms, + areaRestrictions: getPublicAreaRestrictions( + user.perms.areaRestrictions || [], + ), + } + : user.perms, + } + + const { clientValues, clientMenus } = clientOptions(safeUser.perms) const mapConfig = config.getMapConfig(req) const api = config.getSafe('api') @@ -29,7 +42,7 @@ function getServerSettings(req) { polling: api.polling, gymValidDataLimit: Date.now() / 1000 - api.gymValidDataLimit * 86400, }, - user, + user: safeUser, authReferences: { areaRestrictions: authentication.areaRestrictions.length, webhooks: config.getSafe('webhooks').filter((w) => w.enabled).length, @@ -61,10 +74,10 @@ function getServerSettings(req) { }, tileServers: config.getSafe('tileServers'), navigation: config.getSafe('navigation'), - menus: advMenus(user.perms), + menus: advMenus(safeUser.perms), userSettings: clientValues, clientMenus, - ui: drawer(req, user.perms), + ui: drawer(req, safeUser.perms), } return serverSettings diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index c9d2810c3..9f67fc373 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -58,13 +58,17 @@ export function AreaChild({ ? groupedAreaKeys : fallbackAreaKeys : [] + const removableAreaKeys = + name && feature?.properties?.key && !feature.properties.manual + ? [...new Set([...selectableAreaKeys, feature.properties.key])] + : selectableAreaKeys const hasAll = name && selectableAreaKeys.length ? selectableAreaKeys.every((key) => scanAreas.includes(key)) : false const hasSome = - name && selectableAreaKeys.length - ? selectableAreaKeys.some((key) => scanAreas.includes(key)) + name && removableAreaKeys.length + ? removableAreaKeys.some((key) => scanAreas.includes(key)) : false const allChildrenManual = name && @@ -130,7 +134,9 @@ export function AreaChild({ onClick={(e) => e.stopPropagation()} onChange={() => { const areaKeys = name - ? selectableAreaKeys + ? hasSome + ? removableAreaKeys + : selectableAreaKeys : feature.properties.key setAreas(areaKeys, allAreas, name ? hasSome : false) }} From 17b0beb1c9633ca2646af9003e1da6ddb49f513a Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 27 Mar 2026 14:08:47 -0400 Subject: [PATCH 012/122] fix(areas): preserve parent rollout access --- server/src/graphql/resolvers.js | 32 ++++++++++++++++++++++++++++++-- server/src/utils/areaPerms.js | 27 +++++---------------------- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index e2cf05931..ad2239113 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -355,10 +355,28 @@ const resolvers = { scanAreas: (_, _args, { req, perms }) => { if (perms?.scanAreas) { const scanAreas = config.getAreas(req, 'scanAreas') + const parentKeyByName = Object.fromEntries( + scanAreas.features + .filter( + (feature) => + !feature.properties.parent && + feature.properties.name && + feature.properties.key, + ) + .map((feature) => [ + feature.properties.name, + feature.properties.key, + ]), + ) const canAccessArea = (properties) => !perms.areaRestrictions.length || perms.areaRestrictions.includes(properties.key) || - perms.areaRestrictions.includes(properties.name) + perms.areaRestrictions.includes(properties.name) || + (!!properties.parent && + (perms.areaRestrictions.includes( + parentKeyByName[properties.parent], + ) || + perms.areaRestrictions.includes(properties.parent))) return [ { @@ -375,6 +393,11 @@ const resolvers = { scanAreasMenu: (_, _args, { req, perms }) => { if (perms?.scanAreas) { const scanAreas = config.getAreas(req, 'scanAreasMenu') + const parentKeyByName = Object.fromEntries( + scanAreas + .filter((parent) => parent.name && parent.details?.properties?.key) + .map((parent) => [parent.name, parent.details.properties.key]), + ) const baseMenu = scanAreas.map((parent) => ({ ...parent, details: @@ -386,7 +409,12 @@ const resolvers = { if (perms.areaRestrictions.length) { const canAccessArea = (properties) => perms.areaRestrictions.includes(properties.key) || - perms.areaRestrictions.includes(properties.name) + perms.areaRestrictions.includes(properties.name) || + (!!properties.parent && + (perms.areaRestrictions.includes( + parentKeyByName[properties.parent], + ) || + perms.areaRestrictions.includes(properties.parent))) const filtered = baseMenu .map((parent) => ({ diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index fa3b0fd16..5751e9a59 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -92,16 +92,13 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (!target) return const parentFeature = includeChildren ? areas.scanAreasObj[target] : null - const canIncludeOwnKey = - !includeChildren || !parentFeature?.properties?.hidden + const isCanonicalTarget = + areas.names.has(target) || parentFeature?.properties?.key === target - if ( - canIncludeOwnKey && - (areas.names.has(target) || parentFeature?.properties?.key === target) - ) { + if (isCanonicalTarget) { perms.push(target) - if (includeChildren) { + if (includeChildren && !parentFeature?.properties?.parent) { const parentName = parentFeature?.properties?.name const domain = areaMaps.keyDomainMap[target] const scopedKey = @@ -111,24 +108,10 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) } } - } else if (includeChildren && parentFeature?.properties?.key === target) { - const parentName = parentFeature?.properties?.name - const domain = areaMaps.keyDomainMap[target] - const scopedKey = - parentName && domain ? `${domain}:${parentName}` : undefined - - if (scopedKey && areaMaps.scopedParentKeyMap[scopedKey]) { - perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) - } } if (areas.withoutParents[target]) { - perms.push( - ...areas.withoutParents[target].filter( - (key) => - !includeChildren || !areas.scanAreasObj[key]?.properties?.hidden, - ), - ) + perms.push(...areas.withoutParents[target]) } // Bare parent names are ambiguous across multi-domain configs, so only From 400ab951609f0127ac9ccb82d9d38ac07b61a5d7 Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 27 Mar 2026 15:50:47 -0400 Subject: [PATCH 013/122] fix(areas): restore unrestricted scan area access --- server/src/graphql/resolvers.js | 4 +++- server/src/services/DiscordClient.js | 22 ++++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index ad2239113..85d0fb6b0 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -443,7 +443,9 @@ const resolvers = { // }) return filtered } - return baseMenu.filter((parent) => parent.children.length) + return baseMenu.filter( + (parent) => parent.details || parent.children.length, + ) } return [] }, diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js index 6a191e9ae..28ac3c0af 100644 --- a/server/src/services/DiscordClient.js +++ b/server/src/services/DiscordClient.js @@ -6,7 +6,7 @@ const passport = require('passport') const config = require('@rm/config') const { logUserAuth } = require('./logUserAuth') -const { areaPerms } = require('../utils/areaPerms') +const { areaPerms, NO_ACCESS_SENTINEL } = require('../utils/areaPerms') const { webhookPerms } = require('../utils/webhookPerms') const { scannerPerms, scannerCooldownBypass } = require('../utils/scannerPerms') const { mergePerms } = require('../utils/mergePerms') @@ -144,6 +144,7 @@ class DiscordClient extends AuthClient { scannerCooldownBypass: new Set(), blockedGuildNames: new Set(), } + let hasUnrestrictedAreaGrant = false const scanner = config.getSafe('scanner') try { const guilds = user.guilds?.map((guild) => guild.id) || [] @@ -205,9 +206,14 @@ class DiscordClient extends AuthClient { } } }) - areaPerms(userRoles).forEach((x) => - permSets.areaRestrictions.add(x), - ) + const guildAreaRestrictions = areaPerms(userRoles) + if (guildAreaRestrictions.length) { + guildAreaRestrictions.forEach((x) => + permSets.areaRestrictions.add(x), + ) + } else { + hasUnrestrictedAreaGrant = true + } webhookPerms(userRoles, 'discordRoles', trialActive).forEach( (x) => permSets.webhooks.add(x), ) @@ -224,6 +230,14 @@ class DiscordClient extends AuthClient { } catch (e) { this.log.warn('Failed to get perms for user', user.id, e) } + if ( + hasUnrestrictedAreaGrant && + ![...permSets.areaRestrictions].some( + (area) => area !== NO_ACCESS_SENTINEL, + ) + ) { + permSets.areaRestrictions.clear() + } Object.entries(permSets).forEach(([key, value]) => { perms[key] = [...value] }) From 1bbcaf51b4e8601c93e0fccf96e595067498cc7f Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 27 Mar 2026 15:55:39 -0400 Subject: [PATCH 014/122] fix(auth): distinguish unrestricted area grants --- server/src/services/DiscordClient.js | 13 ++++++------- server/src/utils/areaPerms.js | 28 ++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js index 28ac3c0af..605c727b0 100644 --- a/server/src/services/DiscordClient.js +++ b/server/src/services/DiscordClient.js @@ -6,7 +6,7 @@ const passport = require('passport') const config = require('@rm/config') const { logUserAuth } = require('./logUserAuth') -const { areaPerms, NO_ACCESS_SENTINEL } = require('../utils/areaPerms') +const { NO_ACCESS_SENTINEL, resolveAreaPerms } = require('../utils/areaPerms') const { webhookPerms } = require('../utils/webhookPerms') const { scannerPerms, scannerCooldownBypass } = require('../utils/scannerPerms') const { mergePerms } = require('../utils/mergePerms') @@ -206,12 +206,11 @@ class DiscordClient extends AuthClient { } } }) - const guildAreaRestrictions = areaPerms(userRoles) - if (guildAreaRestrictions.length) { - guildAreaRestrictions.forEach((x) => - permSets.areaRestrictions.add(x), - ) - } else { + const guildAreaPerms = resolveAreaPerms(userRoles) + guildAreaPerms.areaRestrictions.forEach((x) => + permSets.areaRestrictions.add(x), + ) + if (guildAreaPerms.hasUnrestrictedGrant) { hasUnrestrictedAreaGrant = true } webhookPerms(userRoles, 'discordRoles', trialActive).forEach( diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 5751e9a59..184a4cd38 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -129,9 +129,9 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { /** * @param {string[]} roles - * @returns {string[]} + * @returns {{ areaRestrictions: string[], hasUnrestrictedGrant: boolean }} */ -function areaPerms(roles) { +function resolveAreaPerms(roles) { const areaRestrictions = config.getSafe('authentication.areaRestrictions') const areas = config.getSafe('areas') const areaMaps = getAreaMaps(areas.scanAreas) @@ -150,7 +150,9 @@ function areaPerms(roles) { : false // No areas/parents means unrestricted access - if (!hasAreas && !hasParents) return [] + if (!hasAreas && !hasParents) { + return { areaRestrictions: [], hasUnrestrictedGrant: true } + } matchedRestrictedRule = true @@ -179,14 +181,28 @@ function areaPerms(roles) { } } } + const uniquePerms = [...new Set(perms)] - return matchedRestrictedRule && !uniquePerms.length - ? [NO_ACCESS_SENTINEL] - : uniquePerms + return { + areaRestrictions: + matchedRestrictedRule && !uniquePerms.length + ? [NO_ACCESS_SENTINEL] + : uniquePerms, + hasUnrestrictedGrant: false, + } +} + +/** + * @param {string[]} roles + * @returns {string[]} + */ +function areaPerms(roles) { + return resolveAreaPerms(roles).areaRestrictions } module.exports = { areaPerms, getPublicAreaRestrictions, NO_ACCESS_SENTINEL, + resolveAreaPerms, } From 1afbb316308bfa6e0f82e913a5243c814a43831d Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 27 Mar 2026 16:08:54 -0400 Subject: [PATCH 015/122] fix(weather): honor no-access area restrictions --- server/src/models/Weather.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/server/src/models/Weather.js b/server/src/models/Weather.js index 4c882f001..51fe918c1 100644 --- a/server/src/models/Weather.js +++ b/server/src/models/Weather.js @@ -8,6 +8,7 @@ const config = require('@rm/config') const { getPolyVector } = require('../utils/getPolyVector') const { getPolygonBbox } = require('../utils/getBbox') +const { consolidateAreas } = require('../utils/consolidateAreas') class Weather extends Model { static get tableName() { @@ -42,14 +43,15 @@ class Weather extends Model { const results = await query const areas = config.getSafe('areas') - const cleanUserAreas = (args.filters.onlyAreas || []).filter((area) => - areas.names.has(area), - ) - const merged = perms.areaRestrictions.length - ? perms.areaRestrictions.filter( - (area) => !cleanUserAreas.length || cleanUserAreas.includes(area), - ) - : cleanUserAreas + const hasAreaFilter = + perms.areaRestrictions.length || (args.filters.onlyAreas || []).length + const merged = hasAreaFilter + ? [...consolidateAreas(perms.areaRestrictions, args.filters.onlyAreas)] + : [] + + if (hasAreaFilter && !merged.length) { + return [] + } const boundPolygon = getPolygonBbox(args) return results @@ -61,7 +63,7 @@ class Weather extends Model { (pointInPolygon(center, boundPolygon) || booleanOverlap(geojson, boundPolygon) || booleanContains(geojson, boundPolygon)) && - (!merged.length || + (!hasAreaFilter || merged.some( (area) => areas.scanAreasObj[area] && From b5fb48e5abe8b6d1097a62d2b571e68152c4d49f Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 27 Mar 2026 16:20:43 -0400 Subject: [PATCH 016/122] fix(areas): preserve persisted child filters --- server/src/utils/consolidateAreas.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/src/utils/consolidateAreas.js b/server/src/utils/consolidateAreas.js index 9c408966d..78c22d08f 100644 --- a/server/src/utils/consolidateAreas.js +++ b/server/src/utils/consolidateAreas.js @@ -15,7 +15,10 @@ function consolidateAreas(areaRestrictions = [], onlyAreas = []) { const validUserAreas = onlyAreas.filter((a) => areas.names.has(a)) const cleanedValidUserAreas = validUserAreas.filter((area) => - areaRestrictions.length ? areaRestrictions.includes(area) : true, + areaRestrictions.length + ? areaRestrictions.includes(area) || + areaRestrictions.includes(areas.scanAreasObj[area]?.properties?.parent) + : true, ) return new Set( cleanedValidUserAreas.length From 28fcd463246f824182bb172eb187bfa132fef1f4 Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 27 Mar 2026 16:28:20 -0400 Subject: [PATCH 017/122] fix(auth): normalize persisted area restrictions --- server/src/middleware/passport.js | 7 +++++++ server/src/utils/areaPerms.js | 22 ++++++++++++++++++++++ server/src/utils/consolidateAreas.js | 5 +---- server/src/utils/mergePerms.js | 15 ++++++++++++++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/server/src/middleware/passport.js b/server/src/middleware/passport.js index 8e3287608..97a7bbb7d 100644 --- a/server/src/middleware/passport.js +++ b/server/src/middleware/passport.js @@ -1,6 +1,7 @@ // @ts-check const passport = require('passport') +const { normalizeAreaRestrictions } = require('../utils/areaPerms') /** * @@ -16,6 +17,12 @@ passport.serializeUser(async (user, done) => { }) passport.deserializeUser(async (user, done) => { + if (Array.isArray(user?.perms?.areaRestrictions)) { + user.perms.areaRestrictions = normalizeAreaRestrictions( + user.perms.areaRestrictions, + ) + } + if (user.perms.map) { done(null, user) } else { diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 184a4cd38..315533c2a 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -192,6 +192,27 @@ function resolveAreaPerms(roles) { } } +/** + * @param {string[]} [areaRestrictions] + * @returns {string[]} + */ +function normalizeAreaRestrictions(areaRestrictions = []) { + const areas = config.getSafe('areas') + const areaMaps = getAreaMaps(areas.scanAreas) + const normalized = [] + + areaRestrictions.forEach((area) => { + pushAreaKeys(normalized, area, areas, areaMaps, true) + }) + + const uniquePerms = [...new Set(normalized)] + return uniquePerms.length + ? uniquePerms + : areaRestrictions.includes(NO_ACCESS_SENTINEL) + ? [NO_ACCESS_SENTINEL] + : uniquePerms +} + /** * @param {string[]} roles * @returns {string[]} @@ -204,5 +225,6 @@ module.exports = { areaPerms, getPublicAreaRestrictions, NO_ACCESS_SENTINEL, + normalizeAreaRestrictions, resolveAreaPerms, } diff --git a/server/src/utils/consolidateAreas.js b/server/src/utils/consolidateAreas.js index 78c22d08f..9c408966d 100644 --- a/server/src/utils/consolidateAreas.js +++ b/server/src/utils/consolidateAreas.js @@ -15,10 +15,7 @@ function consolidateAreas(areaRestrictions = [], onlyAreas = []) { const validUserAreas = onlyAreas.filter((a) => areas.names.has(a)) const cleanedValidUserAreas = validUserAreas.filter((area) => - areaRestrictions.length - ? areaRestrictions.includes(area) || - areaRestrictions.includes(areas.scanAreasObj[area]?.properties?.parent) - : true, + areaRestrictions.length ? areaRestrictions.includes(area) : true, ) return new Set( cleanedValidUserAreas.length diff --git a/server/src/utils/mergePerms.js b/server/src/utils/mergePerms.js index 3e13abf31..0924b47dc 100644 --- a/server/src/utils/mergePerms.js +++ b/server/src/utils/mergePerms.js @@ -1,4 +1,5 @@ // @ts-check +const { normalizeAreaRestrictions } = require('./areaPerms') /** * @@ -20,7 +21,19 @@ function mergePerms(existingPerms, incomingPerms) { return [ key, Array.isArray(existingValue) || Array.isArray(incomingValue) - ? [...new Set([...(existingValue || []), ...(incomingValue || [])])] + ? key === 'areaRestrictions' + ? normalizeAreaRestrictions([ + ...new Set([ + ...(existingValue || []), + ...(incomingValue || []), + ]), + ]) + : [ + ...new Set([ + ...(existingValue || []), + ...(incomingValue || []), + ]), + ] : existingValue || incomingValue, ] }), From 9dab6da84698dadec09aa33a3ed27a71176610e7 Mon Sep 17 00:00:00 2001 From: Mygod Date: Fri, 27 Mar 2026 16:43:53 -0400 Subject: [PATCH 018/122] fix(areas): scope filtered parent scan toggles --- src/features/drawer/areas/AreaTable.jsx | 4 +--- src/features/drawer/areas/Child.jsx | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 267c24b19..65367973f 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -70,7 +70,6 @@ export function ScanAreasTable() { (data?.scanAreasMenu || []) .map((area) => ({ ...area, - groupedChildren: area.children, details: search === '' || area.details?.properties?.key?.toLowerCase()?.includes(search) @@ -193,7 +192,7 @@ export function ScanAreasTable() { })} > - {allRows.map(({ name, details, children, groupedChildren, rows }) => { + {allRows.map(({ name, details, children, rows }) => { if (!children.length && !details) return null return ( @@ -204,7 +203,6 @@ export function ScanAreasTable() { feature={details} allAreas={allAreas} childAreas={children} - groupedAreas={groupedChildren} colSpan={2} /> diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 9f67fc373..5edefacec 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -18,7 +18,6 @@ import { useMemory } from '@store/useMemory' * feature?: Pick * allAreas?: string[] * childAreas?: Pick[] - * groupedAreas?: Pick[] * borderRight?: boolean * colSpan?: number * }} props @@ -27,7 +26,6 @@ export function AreaChild({ name, feature, childAreas, - groupedAreas, allAreas, borderRight, colSpan = 1, @@ -42,7 +40,7 @@ export function AreaChild({ if (!scanAreas) return null - const groupedChildren = groupedAreas || childAreas || [] + const groupedChildren = childAreas || [] const groupedAreaKeys = groupedChildren .filter((child) => !child.properties.manual) .map((child) => child.properties.key) From e65f4df88cad9eab04a89b4355b903f79ee27f81 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 19:05:53 -0400 Subject: [PATCH 019/122] fix(auth): preserve unrestricted area merges --- server/src/utils/mergePerms.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/server/src/utils/mergePerms.js b/server/src/utils/mergePerms.js index 0924b47dc..3fc0728ab 100644 --- a/server/src/utils/mergePerms.js +++ b/server/src/utils/mergePerms.js @@ -17,17 +17,23 @@ function mergePerms(existingPerms, incomingPerms) { [...keys].map((key) => { const existingValue = existingPerms[key] const incomingValue = incomingPerms[key] + const hasUnrestrictedAreaGrant = + key === 'areaRestrictions' && + ((Array.isArray(existingValue) && existingValue.length === 0) || + (Array.isArray(incomingValue) && incomingValue.length === 0)) return [ key, Array.isArray(existingValue) || Array.isArray(incomingValue) ? key === 'areaRestrictions' - ? normalizeAreaRestrictions([ - ...new Set([ - ...(existingValue || []), - ...(incomingValue || []), - ]), - ]) + ? hasUnrestrictedAreaGrant + ? [] + : normalizeAreaRestrictions([ + ...new Set([ + ...(existingValue || []), + ...(incomingValue || []), + ]), + ]) : [ ...new Set([ ...(existingValue || []), From 2803e05a655faf0d2dba66889aed2d7ffdc097e6 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 19:28:23 -0400 Subject: [PATCH 020/122] fix(auth): preserve no-access normalization --- server/src/utils/areaPerms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 315533c2a..62f0f4160 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -208,7 +208,7 @@ function normalizeAreaRestrictions(areaRestrictions = []) { const uniquePerms = [...new Set(normalized)] return uniquePerms.length ? uniquePerms - : areaRestrictions.includes(NO_ACCESS_SENTINEL) + : areaRestrictions.length ? [NO_ACCESS_SENTINEL] : uniquePerms } From f081a88f63dd9a66b4af5006596168c16f03c060 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 19:46:02 -0400 Subject: [PATCH 021/122] fix(auth): preserve scoped area restrictions --- server/src/utils/areaPerms.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 62f0f4160..9276663eb 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -163,7 +163,7 @@ function resolveAreaPerms(roles) { areaRestrictions[j].areas[k], areas, areaMaps, - true, + false, ) } } @@ -202,7 +202,7 @@ function normalizeAreaRestrictions(areaRestrictions = []) { const normalized = [] areaRestrictions.forEach((area) => { - pushAreaKeys(normalized, area, areas, areaMaps, true) + pushAreaKeys(normalized, area, areas, areaMaps, false) }) const uniquePerms = [...new Set(normalized)] From 155040be2abe96530d1918ae94d3234c875e7899 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 20:03:48 -0400 Subject: [PATCH 022/122] fix(auth): preserve legacy parent area grants --- server/src/utils/areaPerms.js | 11 +++++++++++ server/src/utils/consolidateAreas.js | 29 +++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 9276663eb..15812d74a 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -114,6 +114,17 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { perms.push(...areas.withoutParents[target]) } + if ( + !includeChildren && + !areas.withoutParents[target] && + areaMaps.parentDomainsMap[target]?.length === 1 + ) { + const scopedKey = `${areaMaps.parentDomainsMap[target][0]}:${target}` + if (!areaMaps.scopedParentAreaKeyMap[scopedKey]) { + perms.push(...(areaMaps.scopedParentKeyMap[scopedKey] || [])) + } + } + // Bare parent names are ambiguous across multi-domain configs, so only // expand children when the parent label resolves to exactly one domain. if (includeChildren && areaMaps.parentDomainsMap[target]?.length === 1) { diff --git a/server/src/utils/consolidateAreas.js b/server/src/utils/consolidateAreas.js index 9c408966d..6fb2fc514 100644 --- a/server/src/utils/consolidateAreas.js +++ b/server/src/utils/consolidateAreas.js @@ -9,13 +9,40 @@ const config = require('@rm/config') */ function consolidateAreas(areaRestrictions = [], onlyAreas = []) { const areas = config.getSafe('areas') + const parentKeyByChildKey = Object.values(areas.scanAreas).reduce( + (acc, featureCollection) => { + const parentKeysByName = Object.fromEntries( + featureCollection.features + .filter( + (feature) => + !feature.properties.parent && + feature.properties.name && + feature.properties.key, + ) + .map((feature) => [feature.properties.name, feature.properties.key]), + ) + + featureCollection.features.forEach((feature) => { + if (feature.properties.key && feature.properties.parent) { + acc[feature.properties.key] = + parentKeysByName[feature.properties.parent] || '' + } + }) + return acc + }, + /** @type {Record} */ ({}), + ) const validAreaRestrictions = areaRestrictions.filter((a) => areas.names.has(a), ) const validUserAreas = onlyAreas.filter((a) => areas.names.has(a)) const cleanedValidUserAreas = validUserAreas.filter((area) => - areaRestrictions.length ? areaRestrictions.includes(area) : true, + areaRestrictions.length + ? areaRestrictions.includes(area) || + areaRestrictions.includes(parentKeyByChildKey[area]) || + areaRestrictions.includes(areas.scanAreasObj[area]?.properties.parent) + : true, ) return new Set( cleanedValidUserAreas.length From d210742de861198e9d1e7a7e4619f85156388b6d Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 20:20:02 -0400 Subject: [PATCH 023/122] fix(auth): scope legacy parent fallback --- server/src/utils/areaPerms.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 15812d74a..e0dd917b2 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -114,11 +114,7 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { perms.push(...areas.withoutParents[target]) } - if ( - !includeChildren && - !areas.withoutParents[target] && - areaMaps.parentDomainsMap[target]?.length === 1 - ) { + if (!includeChildren && areaMaps.parentDomainsMap[target]?.length === 1) { const scopedKey = `${areaMaps.parentDomainsMap[target][0]}:${target}` if (!areaMaps.scopedParentAreaKeyMap[scopedKey]) { perms.push(...(areaMaps.scopedParentKeyMap[scopedKey] || [])) From ac88d2dcb3ff5da148ea08de28a3383be743dae7 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 20:35:41 -0400 Subject: [PATCH 024/122] fix(auth): prefer concrete area names --- server/src/utils/areaPerms.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index e0dd917b2..5ffa9f8bd 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -16,6 +16,7 @@ function getPublicAreaRestrictions(areaRestrictions = []) { * @returns {{ * keyDomainMap: Record, * parentDomainsMap: Record, + * queryableAreaNames: Set, * scopedParentKeyMap: Record, * scopedParentAreaKeyMap: Record, * }} @@ -27,21 +28,24 @@ function getAreaMaps(scanAreas) { const areaKeysByName = {} featureCollection.features.forEach((feature) => { - const { hidden, key, name, parent } = feature.properties + const { hidden, key, manual, name, parent } = feature.properties if (!key) return - acc.keyDomainMap[key] = domain - if (name && !parent && !hidden) { + if (!manual) { + acc.keyDomainMap[key] = domain + } + if (name && !parent && !hidden && !manual) { + acc.queryableAreaNames.add(name) if (!areaKeysByName[name]) areaKeysByName[name] = [] areaKeysByName[name].push(key) } }) featureCollection.features.forEach((feature) => { - const { hidden, key, parent } = feature.properties + const { hidden, key, manual, parent } = feature.properties // Hidden children should not widen backend access through parent expansion. - if (!key || !parent || hidden) return + if (!key || !parent || hidden || manual) return const scopedKey = `${domain}:${parent}` if (!acc.parentDomainsMap[parent]) acc.parentDomainsMap[parent] = [] @@ -63,11 +67,13 @@ function getAreaMaps(scanAreas) { /** @type {{ * keyDomainMap: Record, * parentDomainsMap: Record, + * queryableAreaNames: Set, * scopedParentKeyMap: Record, * scopedParentAreaKeyMap: Record, * }} */ ({ keyDomainMap: {}, parentDomainsMap: {}, + queryableAreaNames: new Set(), scopedParentKeyMap: {}, scopedParentAreaKeyMap: {}, }), @@ -93,7 +99,10 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { const parentFeature = includeChildren ? areas.scanAreasObj[target] : null const isCanonicalTarget = - areas.names.has(target) || parentFeature?.properties?.key === target + areas.names.has(target) || + (!!parentFeature && + !parentFeature.properties.manual && + parentFeature.properties.key === target) if (isCanonicalTarget) { perms.push(target) @@ -114,7 +123,11 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { perms.push(...areas.withoutParents[target]) } - if (!includeChildren && areaMaps.parentDomainsMap[target]?.length === 1) { + if ( + !includeChildren && + !areaMaps.queryableAreaNames.has(target) && + areaMaps.parentDomainsMap[target]?.length === 1 + ) { const scopedKey = `${areaMaps.parentDomainsMap[target][0]}:${target}` if (!areaMaps.scopedParentAreaKeyMap[scopedKey]) { perms.push(...(areaMaps.scopedParentKeyMap[scopedKey] || [])) From f136ea6188ffa483181af97968f0ae7229d8b584 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 20:52:46 -0400 Subject: [PATCH 025/122] fix(auth): resolve legacy area targets safely --- server/src/utils/areaPerms.js | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 5ffa9f8bd..082e652b1 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -16,7 +16,6 @@ function getPublicAreaRestrictions(areaRestrictions = []) { * @returns {{ * keyDomainMap: Record, * parentDomainsMap: Record, - * queryableAreaNames: Set, * scopedParentKeyMap: Record, * scopedParentAreaKeyMap: Record, * }} @@ -35,7 +34,6 @@ function getAreaMaps(scanAreas) { acc.keyDomainMap[key] = domain } if (name && !parent && !hidden && !manual) { - acc.queryableAreaNames.add(name) if (!areaKeysByName[name]) areaKeysByName[name] = [] areaKeysByName[name].push(key) } @@ -67,13 +65,11 @@ function getAreaMaps(scanAreas) { /** @type {{ * keyDomainMap: Record, * parentDomainsMap: Record, - * queryableAreaNames: Set, * scopedParentKeyMap: Record, * scopedParentAreaKeyMap: Record, * }} */ ({ keyDomainMap: {}, parentDomainsMap: {}, - queryableAreaNames: new Set(), scopedParentKeyMap: {}, scopedParentAreaKeyMap: {}, }), @@ -97,18 +93,14 @@ function getAreaMaps(scanAreas) { function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (!target) return - const parentFeature = includeChildren ? areas.scanAreasObj[target] : null - const isCanonicalTarget = - areas.names.has(target) || - (!!parentFeature && - !parentFeature.properties.manual && - parentFeature.properties.key === target) + const targetFeature = areas.scanAreasObj[target] + const isCanonicalTarget = targetFeature?.properties?.key === target if (isCanonicalTarget) { perms.push(target) - if (includeChildren && !parentFeature?.properties?.parent) { - const parentName = parentFeature?.properties?.name + if (includeChildren && !targetFeature?.properties?.parent) { + const parentName = targetFeature?.properties?.name const domain = areaMaps.keyDomainMap[target] const scopedKey = parentName && domain ? `${domain}:${parentName}` : undefined @@ -119,13 +111,16 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { } } - if (areas.withoutParents[target]) { - perms.push(...areas.withoutParents[target]) + const visibleNameMatches = (areas.withoutParents[target] || []).filter( + (key) => !areas.scanAreasObj[key]?.properties?.hidden, + ) + if (!isCanonicalTarget && visibleNameMatches.length) { + perms.push(...visibleNameMatches) } if ( !includeChildren && - !areaMaps.queryableAreaNames.has(target) && + !visibleNameMatches.length && areaMaps.parentDomainsMap[target]?.length === 1 ) { const scopedKey = `${areaMaps.parentDomainsMap[target][0]}:${target}` From 591f718e23566ec3a6bcf02255871e01670f4065 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 21:27:02 -0400 Subject: [PATCH 026/122] fix(auth): avoid ambiguous parent expansion --- server/src/utils/areaPerms.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 082e652b1..426b671d0 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -14,7 +14,7 @@ function getPublicAreaRestrictions(areaRestrictions = []) { /** * @param {Record} scanAreas * @returns {{ - * keyDomainMap: Record, + * keyDomainsMap: Record, * parentDomainsMap: Record, * scopedParentKeyMap: Record, * scopedParentAreaKeyMap: Record, @@ -31,7 +31,10 @@ function getAreaMaps(scanAreas) { if (!key) return if (!manual) { - acc.keyDomainMap[key] = domain + if (!acc.keyDomainsMap[key]) acc.keyDomainsMap[key] = [] + if (!acc.keyDomainsMap[key].includes(domain)) { + acc.keyDomainsMap[key].push(domain) + } } if (name && !parent && !hidden && !manual) { if (!areaKeysByName[name]) areaKeysByName[name] = [] @@ -63,12 +66,12 @@ function getAreaMaps(scanAreas) { return acc }, /** @type {{ - * keyDomainMap: Record, + * keyDomainsMap: Record, * parentDomainsMap: Record, * scopedParentKeyMap: Record, * scopedParentAreaKeyMap: Record, * }} */ ({ - keyDomainMap: {}, + keyDomainsMap: {}, parentDomainsMap: {}, scopedParentKeyMap: {}, scopedParentAreaKeyMap: {}, @@ -101,7 +104,10 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (includeChildren && !targetFeature?.properties?.parent) { const parentName = targetFeature?.properties?.name - const domain = areaMaps.keyDomainMap[target] + const domain = + areaMaps.keyDomainsMap[target]?.length === 1 + ? areaMaps.keyDomainsMap[target][0] + : undefined const scopedKey = parentName && domain ? `${domain}:${parentName}` : undefined From 5b8497308b07c7a7793a66182328e6432cb4a946 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 21:27:21 -0400 Subject: [PATCH 027/122] fix(drawer): include grouped parent area key --- src/features/drawer/areas/Child.jsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 5edefacec..1aa34a090 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -44,17 +44,12 @@ export function AreaChild({ const groupedAreaKeys = groupedChildren .filter((child) => !child.properties.manual) .map((child) => child.properties.key) - const fallbackAreaKeys = - name && - !groupedAreaKeys.length && - feature?.properties?.key && - !feature.properties.manual + const parentAreaKeys = + name && feature?.properties?.key && !feature.properties.manual ? [feature.properties.key] : [] const selectableAreaKeys = name - ? groupedAreaKeys.length - ? groupedAreaKeys - : fallbackAreaKeys + ? [...new Set([...parentAreaKeys, ...groupedAreaKeys])] : [] const removableAreaKeys = name && feature?.properties?.key && !feature.properties.manual From 0df5be0e8ec685b7218cbae03c7cca54bd91a458 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 21:34:58 -0400 Subject: [PATCH 028/122] fix(drawer): keep grouped area toggles scoped --- src/features/drawer/areas/AreaTable.jsx | 4 +++- src/features/drawer/areas/Child.jsx | 14 ++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 65367973f..8ec046cb3 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -70,6 +70,7 @@ export function ScanAreasTable() { (data?.scanAreasMenu || []) .map((area) => ({ ...area, + allChildren: area.children, details: search === '' || area.details?.properties?.key?.toLowerCase()?.includes(search) @@ -192,7 +193,7 @@ export function ScanAreasTable() { })} > - {allRows.map(({ name, details, children, rows }) => { + {allRows.map(({ name, details, children, rows, allChildren }) => { if (!children.length && !details) return null return ( @@ -203,6 +204,7 @@ export function ScanAreasTable() { feature={details} allAreas={allAreas} childAreas={children} + allChildAreas={allChildren} colSpan={2} /> diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 1aa34a090..d7e75144e 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -18,6 +18,7 @@ import { useMemory } from '@store/useMemory' * feature?: Pick * allAreas?: string[] * childAreas?: Pick[] + * allChildAreas?: Pick[] * borderRight?: boolean * colSpan?: number * }} props @@ -26,6 +27,7 @@ export function AreaChild({ name, feature, childAreas, + allChildAreas, allAreas, borderRight, colSpan = 1, @@ -40,16 +42,20 @@ export function AreaChild({ if (!scanAreas) return null - const groupedChildren = childAreas || [] + const groupedChildren = allChildAreas || childAreas || [] + const visibleChildren = childAreas || [] const groupedAreaKeys = groupedChildren .filter((child) => !child.properties.manual) .map((child) => child.properties.key) const parentAreaKeys = - name && feature?.properties?.key && !feature.properties.manual + name && + feature?.properties?.key && + !feature.properties.manual && + !groupedAreaKeys.length ? [feature.properties.key] : [] const selectableAreaKeys = name - ? [...new Set([...parentAreaKeys, ...groupedAreaKeys])] + ? [...new Set([...groupedAreaKeys, ...parentAreaKeys])] : [] const removableAreaKeys = name && feature?.properties?.key && !feature.properties.manual @@ -78,7 +84,7 @@ export function AreaChild({ const nameProp = name || feature?.properties?.formattedName || feature?.properties?.name - const hasExpand = name && !expandAllScanAreas && !!childAreas?.length + const hasExpand = name && !expandAllScanAreas && !!visibleChildren.length return ( Date: Mon, 30 Mar 2026 21:44:16 -0400 Subject: [PATCH 029/122] fix(auth): expand reused parent keys --- server/src/utils/areaPerms.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 426b671d0..deb2368f8 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -104,16 +104,13 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (includeChildren && !targetFeature?.properties?.parent) { const parentName = targetFeature?.properties?.name - const domain = - areaMaps.keyDomainsMap[target]?.length === 1 - ? areaMaps.keyDomainsMap[target][0] - : undefined - const scopedKey = - parentName && domain ? `${domain}:${parentName}` : undefined - - if (scopedKey && areaMaps.scopedParentKeyMap[scopedKey]) { - perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) - } + ;(areaMaps.keyDomainsMap[target] || []).forEach((domain) => { + const scopedKey = parentName ? `${domain}:${parentName}` : undefined + + if (scopedKey && areaMaps.scopedParentKeyMap[scopedKey]) { + perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) + } + }) } } From 5325f12d49ef5487d98838e88da3d53770c2702a Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 21:44:30 -0400 Subject: [PATCH 030/122] fix(drawer): preserve grouped area exclusions --- src/features/drawer/areas/AreaTable.jsx | 14 ++++++-- src/features/drawer/areas/Child.jsx | 45 ++++++++++++++++++------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 8ec046cb3..c2c5a41f4 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -70,7 +70,6 @@ export function ScanAreasTable() { (data?.scanAreasMenu || []) .map((area) => ({ ...area, - allChildren: area.children, details: search === '' || area.details?.properties?.key?.toLowerCase()?.includes(search) @@ -193,7 +192,7 @@ export function ScanAreasTable() { })} > - {allRows.map(({ name, details, children, rows, allChildren }) => { + {allRows.map(({ name, details, children, rows }) => { if (!children.length && !details) return null return ( @@ -204,7 +203,11 @@ export function ScanAreasTable() { feature={details} allAreas={allAreas} childAreas={children} - allChildAreas={allChildren} + groupKey={ + details?.properties?.manual + ? undefined + : details?.properties?.key + } colSpan={2} /> @@ -222,6 +225,11 @@ export function ScanAreasTable() { feature={feature} allAreas={allAreas} childAreas={children} + groupKey={ + details?.properties?.manual + ? undefined + : details?.properties?.key + } borderRight={row.length === 2 && j === 0} colSpan={row.length === 1 ? 2 : 1} /> diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index d7e75144e..496cc4dac 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -18,7 +18,7 @@ import { useMemory } from '@store/useMemory' * feature?: Pick * allAreas?: string[] * childAreas?: Pick[] - * allChildAreas?: Pick[] + * groupKey?: string * borderRight?: boolean * colSpan?: number * }} props @@ -27,7 +27,7 @@ export function AreaChild({ name, feature, childAreas, - allChildAreas, + groupKey, allAreas, borderRight, colSpan = 1, @@ -42,20 +42,16 @@ export function AreaChild({ if (!scanAreas) return null - const groupedChildren = allChildAreas || childAreas || [] - const visibleChildren = childAreas || [] + const groupedChildren = childAreas || [] const groupedAreaKeys = groupedChildren .filter((child) => !child.properties.manual) .map((child) => child.properties.key) const parentAreaKeys = - name && - feature?.properties?.key && - !feature.properties.manual && - !groupedAreaKeys.length + name && feature?.properties?.key && !feature.properties.manual ? [feature.properties.key] : [] const selectableAreaKeys = name - ? [...new Set([...groupedAreaKeys, ...parentAreaKeys])] + ? [...new Set([...parentAreaKeys, ...groupedAreaKeys])] : [] const removableAreaKeys = name && feature?.properties?.key && !feature.properties.manual @@ -81,10 +77,14 @@ export function AreaChild({ hasManual || (name ? !selectableAreaKeys.length : !feature.properties.name) ? 'transparent' : 'none' + const coveredByGroup = + !name && !feature?.properties?.manual && groupKey + ? scanAreas.includes(groupKey) + : false const nameProp = name || feature?.properties?.formattedName || feature?.properties?.name - const hasExpand = name && !expandAllScanAreas && !!visibleChildren.length + const hasExpand = name && !expandAllScanAreas && !!childAreas?.length return ( e.stopPropagation()} onChange={() => { + const siblingAreaKeys = (childAreas || []) + .filter( + (child) => + !child.properties.manual && + child.properties.key !== feature.properties.key, + ) + .map((child) => child.properties.key) const areaKeys = name ? hasSome ? removableAreaKeys : selectableAreaKeys - : feature.properties.key + : coveredByGroup + ? [ + groupKey, + ...(scanAreas.includes(feature.properties.key) + ? [feature.properties.key] + : []), + ...siblingAreaKeys.filter( + (key) => !scanAreas.includes(key), + ), + ] + : feature.properties.key setAreas(areaKeys, allAreas, name ? hasSome : false) }} sx={{ From 7e3b1d57f963b28db5aabfa746430159343b4df2 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 21:56:06 -0400 Subject: [PATCH 031/122] fix(auth): scope parent grants per request --- server/src/middleware/apollo.js | 12 ++- server/src/utils/areaPerms.js | 140 ++++++++++++++++++++++---- server/src/utils/getServerSettings.js | 7 +- 3 files changed, 134 insertions(+), 25 deletions(-) diff --git a/server/src/middleware/apollo.js b/server/src/middleware/apollo.js index 26c5436b1..d5596b362 100644 --- a/server/src/middleware/apollo.js +++ b/server/src/middleware/apollo.js @@ -7,6 +7,7 @@ const { parse } = require('graphql') const { state } = require('../services/state') const { version } = require('../../../package.json') const { DataLimitCheck } = require('../services/DataLimitCheck') +const { normalizeAreaRestrictions } = require('../utils/areaPerms') /** * @@ -16,7 +17,16 @@ const { DataLimitCheck } = require('../services/DataLimitCheck') function apolloMiddleware(server) { return expressMiddleware(server, { context: async ({ req, res }) => { - const perms = req.user ? req.user.perms : req.session.perms + const rawPerms = req.user ? req.user.perms : req.session.perms + const perms = rawPerms + ? { + ...rawPerms, + areaRestrictions: normalizeAreaRestrictions( + rawPerms.areaRestrictions || [], + req, + ), + } + : rawPerms const username = req?.user?.username || '' const id = req?.user?.id || 0 diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index deb2368f8..ca3457f84 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -2,13 +2,40 @@ const config = require('@rm/config') const NO_ACCESS_SENTINEL = '__rm_no_access__' +const PARENT_ACCESS_PREFIX = '__rm_parent__:' + +/** + * @param {string} area + * @returns {boolean} + */ +function isParentAreaGrant(area) { + return area.startsWith(PARENT_ACCESS_PREFIX) +} + +/** + * @param {string} area + * @returns {string} + */ +function encodeParentAreaGrant(area) { + return `${PARENT_ACCESS_PREFIX}${area}` +} + +/** + * @param {string} area + * @returns {string} + */ +function decodeParentAreaGrant(area) { + return area.slice(PARENT_ACCESS_PREFIX.length) +} /** * @param {string[]} [areaRestrictions] * @returns {string[]} */ function getPublicAreaRestrictions(areaRestrictions = []) { - return areaRestrictions.filter((area) => area !== NO_ACCESS_SENTINEL) + return areaRestrictions.filter( + (area) => area !== NO_ACCESS_SENTINEL && !isParentAreaGrant(area), + ) } /** @@ -79,6 +106,49 @@ function getAreaMaps(scanAreas) { ) } +/** + * @param {import('express').Request} [req] + */ +function getRestrictionAreas(req) { + if (!req) return config.getSafe('areas') + + const scanAreas = config.getAreas(req, 'scanAreas') + const scanAreasObj = Object.fromEntries( + scanAreas.features + .filter((feature) => feature.properties.key) + .map((feature) => [feature.properties.key, feature]), + ) + const names = new Set() + /** @type {Record} */ + const withoutParents = {} + + scanAreas.features.forEach((feature) => { + const { key, manual, name } = feature.properties + + if ( + !key || + !name || + manual || + !feature.geometry?.type?.includes('Polygon') + ) { + return + } + + names.add(key) + if (!withoutParents[name]) { + withoutParents[name] = [] + } + withoutParents[name].push(key) + }) + + return { + names, + scanAreas: { current: scanAreas }, + scanAreasObj, + withoutParents, + } +} + /** * Resolves config entries into canonical area keys. * `parent` rules expand to both the parent's own area key and all child keys. @@ -104,13 +174,16 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (includeChildren && !targetFeature?.properties?.parent) { const parentName = targetFeature?.properties?.name - ;(areaMaps.keyDomainsMap[target] || []).forEach((domain) => { - const scopedKey = parentName ? `${domain}:${parentName}` : undefined + const domain = + areaMaps.keyDomainsMap[target]?.length === 1 + ? areaMaps.keyDomainsMap[target][0] + : undefined + const scopedKey = + parentName && domain ? `${domain}:${parentName}` : undefined - if (scopedKey && areaMaps.scopedParentKeyMap[scopedKey]) { - perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) - } - }) + if (scopedKey && areaMaps.scopedParentKeyMap[scopedKey]) { + perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) + } } } @@ -147,11 +220,12 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { /** * @param {string[]} roles + * @param {import('express').Request} [req] * @returns {{ areaRestrictions: string[], hasUnrestrictedGrant: boolean }} */ -function resolveAreaPerms(roles) { +function resolveAreaPerms(roles, req) { const areaRestrictions = config.getSafe('authentication.areaRestrictions') - const areas = config.getSafe('areas') + const areas = getRestrictionAreas(req) const areaMaps = getAreaMaps(areas.scanAreas) const perms = [] @@ -188,13 +262,17 @@ function resolveAreaPerms(roles) { if (hasParents) { for (let k = 0; k < areaRestrictions[j].parent.length; k += 1) { - pushAreaKeys( - perms, - areaRestrictions[j].parent[k], - areas, - areaMaps, - true, - ) + if (req) { + pushAreaKeys( + perms, + areaRestrictions[j].parent[k], + areas, + areaMaps, + true, + ) + } else { + perms.push(encodeParentAreaGrant(areaRestrictions[j].parent[k])) + } } } } @@ -212,31 +290,49 @@ function resolveAreaPerms(roles) { /** * @param {string[]} [areaRestrictions] + * @param {import('express').Request} [req] * @returns {string[]} */ -function normalizeAreaRestrictions(areaRestrictions = []) { - const areas = config.getSafe('areas') +function normalizeAreaRestrictions(areaRestrictions, req) { + const safeAreaRestrictions = areaRestrictions || [] + const areas = getRestrictionAreas(req) const areaMaps = getAreaMaps(areas.scanAreas) const normalized = [] - areaRestrictions.forEach((area) => { + safeAreaRestrictions.forEach((area) => { + if (isParentAreaGrant(area)) { + if (req) { + pushAreaKeys( + normalized, + decodeParentAreaGrant(area), + areas, + areaMaps, + true, + ) + } else { + normalized.push(area) + } + return + } + pushAreaKeys(normalized, area, areas, areaMaps, false) }) const uniquePerms = [...new Set(normalized)] return uniquePerms.length ? uniquePerms - : areaRestrictions.length + : safeAreaRestrictions.length ? [NO_ACCESS_SENTINEL] : uniquePerms } /** * @param {string[]} roles + * @param {import('express').Request} [req] * @returns {string[]} */ -function areaPerms(roles) { - return resolveAreaPerms(roles).areaRestrictions +function areaPerms(roles, req) { + return resolveAreaPerms(roles, req).areaRestrictions } module.exports = { diff --git a/server/src/utils/getServerSettings.js b/server/src/utils/getServerSettings.js index 87ac4d342..35e94382c 100644 --- a/server/src/utils/getServerSettings.js +++ b/server/src/utils/getServerSettings.js @@ -4,7 +4,10 @@ const config = require('@rm/config') const { clientOptions } = require('../ui/clientOptions') const { advMenus } = require('../ui/advMenus') const { drawer } = require('../ui/drawer') -const { getPublicAreaRestrictions } = require('./areaPerms') +const { + getPublicAreaRestrictions, + normalizeAreaRestrictions, +} = require('./areaPerms') /** * @@ -24,7 +27,7 @@ function getServerSettings(req) { ? { ...user.perms, areaRestrictions: getPublicAreaRestrictions( - user.perms.areaRestrictions || [], + normalizeAreaRestrictions(user.perms.areaRestrictions || [], req), ), } : user.perms, From 90d64e4f8f929fd7e2fec9ced3e17c72b2413a02 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 22:07:15 -0400 Subject: [PATCH 032/122] fix(drawer): preserve grouped search state --- src/features/drawer/areas/AreaTable.jsx | 99 ++++++++++++------------- src/features/drawer/areas/Child.jsx | 41 +++++----- 2 files changed, 71 insertions(+), 69 deletions(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index c2c5a41f4..47f87161a 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -70,6 +70,10 @@ export function ScanAreasTable() { (data?.scanAreasMenu || []) .map((area) => ({ ...area, + allChildren: area.children, + groupKey: area.details?.properties?.manual + ? undefined + : area.details?.properties?.key, details: search === '' || area.details?.properties?.key?.toLowerCase()?.includes(search) @@ -192,56 +196,51 @@ export function ScanAreasTable() { })} > - {allRows.map(({ name, details, children, rows }) => { - if (!children.length && !details) return null - return ( - - {name && ( - - - - )} - {!!rows.length && ( - - - {rows.map((row, i) => ( - - {row.map((feature, j) => ( - - ))} - - ))} - - - )} - - ) - })} + {allRows.map( + ({ name, details, children, rows, allChildren, groupKey }) => { + if (!children.length && !details) return null + return ( + + {name && ( + + + + )} + {!!rows.length && ( + + + {rows.map((row, i) => ( + + {row.map((feature, j) => ( + + ))} + + ))} + + + )} + + ) + }, + )} {showJumpResults && ( diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 496cc4dac..3cc7b236c 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -18,6 +18,7 @@ import { useMemory } from '@store/useMemory' * feature?: Pick * allAreas?: string[] * childAreas?: Pick[] + * allChildAreas?: Pick[] * groupKey?: string * borderRight?: boolean * colSpan?: number @@ -27,6 +28,7 @@ export function AreaChild({ name, feature, childAreas, + allChildAreas, groupKey, allAreas, borderRight, @@ -136,28 +138,29 @@ export function AreaChild({ } onClick={(e) => e.stopPropagation()} onChange={() => { - const siblingAreaKeys = (childAreas || []) - .filter( - (child) => - !child.properties.manual && - child.properties.key !== feature.properties.key, - ) - .map((child) => child.properties.key) - const areaKeys = name + let areaKeys = name ? hasSome ? removableAreaKeys : selectableAreaKeys - : coveredByGroup - ? [ - groupKey, - ...(scanAreas.includes(feature.properties.key) - ? [feature.properties.key] - : []), - ...siblingAreaKeys.filter( - (key) => !scanAreas.includes(key), - ), - ] - : feature.properties.key + : feature.properties.key + + if (!name && coveredByGroup) { + const siblingAreaKeys = (allChildAreas || childAreas || []) + .filter( + (child) => + !child.properties.manual && + child.properties.key !== feature.properties.key, + ) + .map((child) => child.properties.key) + areaKeys = [ + groupKey, + ...(scanAreas.includes(feature.properties.key) + ? [feature.properties.key] + : []), + ...siblingAreaKeys.filter((key) => !scanAreas.includes(key)), + ] + } + setAreas(areaKeys, allAreas, name ? hasSome : false) }} sx={{ From 8af5dfcdd2b1aa3f36ce8a0ade7d8d9913b12e51 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 22:15:37 -0400 Subject: [PATCH 033/122] fix(drawer): keep grouped toggles consistent --- src/features/drawer/areas/AreaTable.jsx | 1 + src/features/drawer/areas/Child.jsx | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 47f87161a..fac32280a 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -208,6 +208,7 @@ export function ScanAreasTable() { feature={details} allAreas={allAreas} childAreas={children} + allChildAreas={allChildren} groupKey={groupKey} colSpan={2} /> diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 3cc7b236c..807978535 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -44,7 +44,9 @@ export function AreaChild({ if (!scanAreas) return null - const groupedChildren = childAreas || [] + const groupedChildren = name + ? allChildAreas || childAreas || [] + : childAreas || [] const groupedAreaKeys = groupedChildren .filter((child) => !child.properties.manual) .map((child) => child.properties.key) @@ -159,6 +161,22 @@ export function AreaChild({ : []), ...siblingAreaKeys.filter((key) => !scanAreas.includes(key)), ] + } else if (!name && groupKey && !scanAreas.includes(groupKey)) { + const siblingAreaKeys = (allChildAreas || childAreas || []) + .filter( + (child) => + !child.properties.manual && + child.properties.key !== feature.properties.key, + ) + .map((child) => child.properties.key) + const completesGroup = + !scanAreas.includes(feature.properties.key) && + siblingAreaKeys.length > 0 && + siblingAreaKeys.every((key) => scanAreas.includes(key)) + + if (completesGroup) { + areaKeys = [feature.properties.key, groupKey] + } } setAreas(areaKeys, allAreas, name ? hasSome : false) From ce0ad441ee876cb0efdaca939eeb60913b1519dc Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 22:26:09 -0400 Subject: [PATCH 034/122] fix(drawer): avoid parent area overreach --- src/features/drawer/areas/AreaTable.jsx | 90 +++++++++++-------------- src/features/drawer/areas/Child.jsx | 60 +++-------------- 2 files changed, 49 insertions(+), 101 deletions(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index fac32280a..8ec046cb3 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -71,9 +71,6 @@ export function ScanAreasTable() { .map((area) => ({ ...area, allChildren: area.children, - groupKey: area.details?.properties?.manual - ? undefined - : area.details?.properties?.key, details: search === '' || area.details?.properties?.key?.toLowerCase()?.includes(search) @@ -196,52 +193,47 @@ export function ScanAreasTable() { })} > - {allRows.map( - ({ name, details, children, rows, allChildren, groupKey }) => { - if (!children.length && !details) return null - return ( - - {name && ( - - - - )} - {!!rows.length && ( - - - {rows.map((row, i) => ( - - {row.map((feature, j) => ( - - ))} - - ))} - - - )} - - ) - }, - )} + {allRows.map(({ name, details, children, rows, allChildren }) => { + if (!children.length && !details) return null + return ( + + {name && ( + + + + )} + {!!rows.length && ( + + + {rows.map((row, i) => ( + + {row.map((feature, j) => ( + + ))} + + ))} + + + )} + + ) + })} {showJumpResults && ( diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 807978535..69d89c026 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -19,7 +19,6 @@ import { useMemory } from '@store/useMemory' * allAreas?: string[] * childAreas?: Pick[] * allChildAreas?: Pick[] - * groupKey?: string * borderRight?: boolean * colSpan?: number * }} props @@ -29,7 +28,6 @@ export function AreaChild({ feature, childAreas, allChildAreas, - groupKey, allAreas, borderRight, colSpan = 1, @@ -51,16 +49,16 @@ export function AreaChild({ .filter((child) => !child.properties.manual) .map((child) => child.properties.key) const parentAreaKeys = - name && feature?.properties?.key && !feature.properties.manual + name && + !groupedAreaKeys.length && + feature?.properties?.key && + !feature.properties.manual ? [feature.properties.key] : [] const selectableAreaKeys = name - ? [...new Set([...parentAreaKeys, ...groupedAreaKeys])] + ? [...new Set([...groupedAreaKeys, ...parentAreaKeys])] : [] - const removableAreaKeys = - name && feature?.properties?.key && !feature.properties.manual - ? [...new Set([...selectableAreaKeys, feature.properties.key])] - : selectableAreaKeys + const removableAreaKeys = selectableAreaKeys const hasAll = name && selectableAreaKeys.length ? selectableAreaKeys.every((key) => scanAreas.includes(key)) @@ -81,10 +79,6 @@ export function AreaChild({ hasManual || (name ? !selectableAreaKeys.length : !feature.properties.name) ? 'transparent' : 'none' - const coveredByGroup = - !name && !feature?.properties?.manual && groupKey - ? scanAreas.includes(groupKey) - : false const nameProp = name || feature?.properties?.formattedName || feature?.properties?.name @@ -133,52 +127,14 @@ export function AreaChild({ size="small" color="secondary" indeterminate={name ? hasSome && !hasAll : false} - checked={ - name - ? hasAll - : coveredByGroup || scanAreas.includes(feature.properties.key) - } + checked={name ? hasAll : scanAreas.includes(feature.properties.key)} onClick={(e) => e.stopPropagation()} onChange={() => { - let areaKeys = name + const areaKeys = name ? hasSome ? removableAreaKeys : selectableAreaKeys : feature.properties.key - - if (!name && coveredByGroup) { - const siblingAreaKeys = (allChildAreas || childAreas || []) - .filter( - (child) => - !child.properties.manual && - child.properties.key !== feature.properties.key, - ) - .map((child) => child.properties.key) - areaKeys = [ - groupKey, - ...(scanAreas.includes(feature.properties.key) - ? [feature.properties.key] - : []), - ...siblingAreaKeys.filter((key) => !scanAreas.includes(key)), - ] - } else if (!name && groupKey && !scanAreas.includes(groupKey)) { - const siblingAreaKeys = (allChildAreas || childAreas || []) - .filter( - (child) => - !child.properties.manual && - child.properties.key !== feature.properties.key, - ) - .map((child) => child.properties.key) - const completesGroup = - !scanAreas.includes(feature.properties.key) && - siblingAreaKeys.length > 0 && - siblingAreaKeys.every((key) => scanAreas.includes(key)) - - if (completesGroup) { - areaKeys = [feature.properties.key, groupKey] - } - } - setAreas(areaKeys, allAreas, name ? hasSome : false) }} sx={{ From 9e51d122f88cb9c3e8825e80863850d0af67a2bd Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 22:37:05 -0400 Subject: [PATCH 035/122] fix(drawer): clear legacy parent filters --- src/features/drawer/areas/Child.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 69d89c026..1d046716e 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -58,7 +58,10 @@ export function AreaChild({ const selectableAreaKeys = name ? [...new Set([...groupedAreaKeys, ...parentAreaKeys])] : [] - const removableAreaKeys = selectableAreaKeys + const removableAreaKeys = + name && feature?.properties?.key && !feature.properties.manual + ? [...new Set([...selectableAreaKeys, feature.properties.key])] + : selectableAreaKeys const hasAll = name && selectableAreaKeys.length ? selectableAreaKeys.every((key) => scanAreas.includes(key)) From 8f4a99f0e4c9547dbe6b29c9fde2a71d0cddc115 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 23:01:16 -0400 Subject: [PATCH 036/122] fix(auth): scope serialized parent grants --- server/src/utils/areaPerms.js | 94 +++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 21 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index ca3457f84..8837fecf5 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -13,19 +13,20 @@ function isParentAreaGrant(area) { } /** + * @param {string} domain * @param {string} area * @returns {string} */ -function encodeParentAreaGrant(area) { - return `${PARENT_ACCESS_PREFIX}${area}` +function encodeParentAreaGrant(domain, area) { + return `${PARENT_ACCESS_PREFIX}${JSON.stringify({ domain, area })}` } /** * @param {string} area - * @returns {string} + * @returns {{ domain: string, area: string }} */ function decodeParentAreaGrant(area) { - return area.slice(PARENT_ACCESS_PREFIX.length) + return JSON.parse(area.slice(PARENT_ACCESS_PREFIX.length)) } /** @@ -149,9 +150,48 @@ function getRestrictionAreas(req) { } } +/** + * @param {import('express').Request} req + * @returns {string} + */ +function getRequestAreaDomain(req) { + const domain = req.headers.host.replaceAll('.', '_') + const location = `areas.scanAreas.${domain}` + return typeof config.has === 'function' && config.has(location) + ? domain + : 'main' +} + +/** + * @param {string | undefined} target + * @param {{ + * scanAreasObj: Record, + * }} areas + * @param {ReturnType} areaMaps + * @returns {string | undefined} + */ +function getParentGrantDomain(target, areas, areaMaps) { + if (!target) return undefined + + const targetFeature = areas.scanAreasObj[target] + if ( + targetFeature?.properties?.key === target && + !targetFeature?.properties?.parent + ) { + return areaMaps.keyDomainsMap[target]?.length === 1 + ? areaMaps.keyDomainsMap[target][0] + : undefined + } + + return areaMaps.parentDomainsMap[target]?.length === 1 + ? areaMaps.parentDomainsMap[target][0] + : undefined +} + /** * Resolves config entries into canonical area keys. - * `parent` rules expand to both the parent's own area key and all child keys. + * `parent` rules expand to visible child keys and only fall back to the + * parent's own area key when no visible children are available. * * @param {string[]} perms * @param {string | undefined} target @@ -170,8 +210,6 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { const isCanonicalTarget = targetFeature?.properties?.key === target if (isCanonicalTarget) { - perms.push(target) - if (includeChildren && !targetFeature?.properties?.parent) { const parentName = targetFeature?.properties?.name const domain = @@ -180,10 +218,17 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { : undefined const scopedKey = parentName && domain ? `${domain}:${parentName}` : undefined + const scopedChildren = scopedKey + ? areaMaps.scopedParentKeyMap[scopedKey] || [] + : [] - if (scopedKey && areaMaps.scopedParentKeyMap[scopedKey]) { - perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) + if (scopedChildren.length) { + perms.push(...scopedChildren) + } else { + perms.push(target) } + } else { + perms.push(target) } } @@ -209,12 +254,12 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { // expand children when the parent label resolves to exactly one domain. if (includeChildren && areaMaps.parentDomainsMap[target]?.length === 1) { const scopedKey = `${areaMaps.parentDomainsMap[target][0]}:${target}` - if (areaMaps.scopedParentAreaKeyMap[scopedKey]) { + const scopedChildren = areaMaps.scopedParentKeyMap[scopedKey] || [] + if (scopedChildren.length) { + perms.push(...scopedChildren) + } else if (areaMaps.scopedParentAreaKeyMap[scopedKey]) { perms.push(areaMaps.scopedParentAreaKeyMap[scopedKey]) } - if (areaMaps.scopedParentKeyMap[scopedKey]) { - perms.push(...areaMaps.scopedParentKeyMap[scopedKey]) - } } } @@ -271,7 +316,17 @@ function resolveAreaPerms(roles, req) { true, ) } else { - perms.push(encodeParentAreaGrant(areaRestrictions[j].parent[k])) + const domain = getParentGrantDomain( + areaRestrictions[j].parent[k], + areas, + areaMaps, + ) + + if (domain) { + perms.push( + encodeParentAreaGrant(domain, areaRestrictions[j].parent[k]), + ) + } } } } @@ -302,13 +357,10 @@ function normalizeAreaRestrictions(areaRestrictions, req) { safeAreaRestrictions.forEach((area) => { if (isParentAreaGrant(area)) { if (req) { - pushAreaKeys( - normalized, - decodeParentAreaGrant(area), - areas, - areaMaps, - true, - ) + const parentGrant = decodeParentAreaGrant(area) + if (parentGrant.domain !== getRequestAreaDomain(req)) return + + pushAreaKeys(normalized, parentGrant.area, areas, areaMaps, true) } else { normalized.push(area) } From 79e3a2d9b0a2dbbaf6290cfff6ccff70918b4bea Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 23:19:46 -0400 Subject: [PATCH 037/122] fix(auth): preserve area restriction semantics --- server/src/services/DiscordClient.js | 21 ++------ server/src/services/LocalClient.js | 4 +- server/src/services/TelegramClient.js | 7 +-- server/src/services/logUserAuth.js | 17 ++++-- server/src/utils/areaPerms.js | 76 +++++++++++---------------- server/src/utils/mergePerms.js | 18 +++---- 6 files changed, 59 insertions(+), 84 deletions(-) diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js index 605c727b0..beb9dfdeb 100644 --- a/server/src/services/DiscordClient.js +++ b/server/src/services/DiscordClient.js @@ -6,7 +6,7 @@ const passport = require('passport') const config = require('@rm/config') const { logUserAuth } = require('./logUserAuth') -const { NO_ACCESS_SENTINEL, resolveAreaPerms } = require('../utils/areaPerms') +const { resolveAreaPerms } = require('../utils/areaPerms') const { webhookPerms } = require('../utils/webhookPerms') const { scannerPerms, scannerCooldownBypass } = require('../utils/scannerPerms') const { mergePerms } = require('../utils/mergePerms') @@ -125,9 +125,10 @@ class DiscordClient extends AuthClient { /** * * @param {import('passport-discord').Profile} user + * @param {import('express').Request} req * @returns {Promise} */ - async getPerms(user) { + async getPerms(user, req) { const trialActive = this.trialManager.active() /** @type {import("@rm/types").Permissions} */ // @ts-ignore @@ -144,7 +145,6 @@ class DiscordClient extends AuthClient { scannerCooldownBypass: new Set(), blockedGuildNames: new Set(), } - let hasUnrestrictedAreaGrant = false const scanner = config.getSafe('scanner') try { const guilds = user.guilds?.map((guild) => guild.id) || [] @@ -206,13 +206,10 @@ class DiscordClient extends AuthClient { } } }) - const guildAreaPerms = resolveAreaPerms(userRoles) + const guildAreaPerms = resolveAreaPerms(userRoles, req) guildAreaPerms.areaRestrictions.forEach((x) => permSets.areaRestrictions.add(x), ) - if (guildAreaPerms.hasUnrestrictedGrant) { - hasUnrestrictedAreaGrant = true - } webhookPerms(userRoles, 'discordRoles', trialActive).forEach( (x) => permSets.webhooks.add(x), ) @@ -229,14 +226,6 @@ class DiscordClient extends AuthClient { } catch (e) { this.log.warn('Failed to get perms for user', user.id, e) } - if ( - hasUnrestrictedAreaGrant && - ![...permSets.areaRestrictions].some( - (area) => area !== NO_ACCESS_SENTINEL, - ) - ) { - permSets.areaRestrictions.clear() - } Object.entries(permSets).forEach(([key, value]) => { perms[key] = [...value] }) @@ -291,7 +280,7 @@ class DiscordClient extends AuthClient { username: profile.username, avatar: profile.avatar || '', locale: profile.locale, - perms: await this.getPerms(profile), + perms: await this.getPerms(profile, req), rmStrategy: this.rmStrategy, valid: false, } diff --git a/server/src/services/LocalClient.js b/server/src/services/LocalClient.js index c8f5615b2..c955b5af0 100644 --- a/server/src/services/LocalClient.js +++ b/server/src/services/LocalClient.js @@ -35,7 +35,7 @@ class LocalClient extends AuthClient { } /** @type {import('passport-local').VerifyFunctionWithRequest} */ - async authHandler(_req, username, password, done) { + async authHandler(req, username, password, done) { const forceTutorial = config.getSafe('map.misc.forceTutorial') const trialActive = this.trialManager.active() const localPerms = Object.keys(this.perms).filter((key) => @@ -44,7 +44,7 @@ class LocalClient extends AuthClient { const user = { perms: /** @type {import('@rm/types').Permissions} */ ({ ...Object.fromEntries(Object.keys(this.perms).map((x) => [x, false])), - areaRestrictions: areaPerms(localPerms), + areaRestrictions: areaPerms(localPerms, req), webhooks: [], scanner: [], scannerCooldownBypass: [], diff --git a/server/src/services/TelegramClient.js b/server/src/services/TelegramClient.js index 55d9eb8cb..0114c2c21 100644 --- a/server/src/services/TelegramClient.js +++ b/server/src/services/TelegramClient.js @@ -62,9 +62,10 @@ class TelegramClient extends AuthClient { * * @param {TGUser} user * @param {string[]} groups + * @param {import('express').Request} req * @returns {TGUser & { perms: import("@rm/types").Permissions }} */ - getUserPerms(user, groups) { + getUserPerms(user, groups, req) { const trialActive = this.trialManager.active() let gainedAccessViaTrial = false @@ -99,7 +100,7 @@ class TelegramClient extends AuthClient { ...perms, trial: gainedAccessViaTrial, admin: false, - areaRestrictions: areaPerms(groups), + areaRestrictions: areaPerms(groups, req), webhooks: webhookPerms(groups, 'telegramGroups', trialActive), scanner: scannerPerms(groups, 'telegramGroups', trialActive), scannerCooldownBypass: scannerCooldownBypass(groups, 'telegramGroups'), @@ -124,7 +125,7 @@ class TelegramClient extends AuthClient { async authHandler(req, profile, done) { const baseUser = { ...profile, rmStrategy: this.rmStrategy } const groups = await this.getUserGroups(baseUser) - const user = this.getUserPerms(baseUser, groups) + const user = this.getUserPerms(baseUser, groups, req) if (!user.perms.map) { this.log.warn(user.username, 'was not given map perms') diff --git a/server/src/services/logUserAuth.js b/server/src/services/logUserAuth.js index 0245c7a15..e5f68e69c 100644 --- a/server/src/services/logUserAuth.js +++ b/server/src/services/logUserAuth.js @@ -1,6 +1,10 @@ // @ts-check const { default: fetch } = require('node-fetch') const { log, TAGS } = require('@rm/logger') +const { + getPublicAreaRestrictions, + normalizeAreaRestrictions, +} = require('../utils/areaPerms') // PII fields inside getAuthInfo embed const PII_FIELDS = [ @@ -155,16 +159,19 @@ async function logUserAuth(req, user, strategy = 'custom', hidePii = false) { ], timestamp: new Date().toISOString(), } - if (user.perms.areaRestrictions.length) { - const trimmed = user.perms.areaRestrictions + const publicAreaRestrictions = getPublicAreaRestrictions( + normalizeAreaRestrictions(user.perms.areaRestrictions || [], req), + ) + if (publicAreaRestrictions.length) { + const trimmed = publicAreaRestrictions .filter((_f, i) => i < 15) .map((f) => capCamel(f)) .join('\n') embed.fields.push({ - name: `(${user.perms.areaRestrictions.length}) Area Restrictions`, + name: `(${publicAreaRestrictions.length}) Area Restrictions`, value: - user.perms.areaRestrictions.length > 15 - ? `${trimmed}\n...${user.perms.areaRestrictions.length - 15} more` + publicAreaRestrictions.length > 15 + ? `${trimmed}\n...${publicAreaRestrictions.length - 15} more` : trimmed, inline: true, }) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 8837fecf5..cf985c127 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -2,6 +2,7 @@ const config = require('@rm/config') const NO_ACCESS_SENTINEL = '__rm_no_access__' +const UNRESTRICTED_ACCESS_SENTINEL = '__rm_unrestricted__' const PARENT_ACCESS_PREFIX = '__rm_parent__:' /** @@ -13,20 +14,20 @@ function isParentAreaGrant(area) { } /** - * @param {string} domain * @param {string} area * @returns {string} */ -function encodeParentAreaGrant(domain, area) { - return `${PARENT_ACCESS_PREFIX}${JSON.stringify({ domain, area })}` +function encodeParentAreaGrant(area) { + return `${PARENT_ACCESS_PREFIX}${JSON.stringify({ area })}` } /** * @param {string} area - * @returns {{ domain: string, area: string }} + * @returns {{ domain?: string, area: string }} */ function decodeParentAreaGrant(area) { - return JSON.parse(area.slice(PARENT_ACCESS_PREFIX.length)) + const value = JSON.parse(area.slice(PARENT_ACCESS_PREFIX.length)) + return typeof value === 'string' ? { area: value } : value } /** @@ -35,7 +36,10 @@ function decodeParentAreaGrant(area) { */ function getPublicAreaRestrictions(areaRestrictions = []) { return areaRestrictions.filter( - (area) => area !== NO_ACCESS_SENTINEL && !isParentAreaGrant(area), + (area) => + area !== NO_ACCESS_SENTINEL && + area !== UNRESTRICTED_ACCESS_SENTINEL && + !isParentAreaGrant(area), ) } @@ -162,32 +166,6 @@ function getRequestAreaDomain(req) { : 'main' } -/** - * @param {string | undefined} target - * @param {{ - * scanAreasObj: Record, - * }} areas - * @param {ReturnType} areaMaps - * @returns {string | undefined} - */ -function getParentGrantDomain(target, areas, areaMaps) { - if (!target) return undefined - - const targetFeature = areas.scanAreasObj[target] - if ( - targetFeature?.properties?.key === target && - !targetFeature?.properties?.parent - ) { - return areaMaps.keyDomainsMap[target]?.length === 1 - ? areaMaps.keyDomainsMap[target][0] - : undefined - } - - return areaMaps.parentDomainsMap[target]?.length === 1 - ? areaMaps.parentDomainsMap[target][0] - : undefined -} - /** * Resolves config entries into canonical area keys. * `parent` rules expand to visible child keys and only fall back to the @@ -235,7 +213,11 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { const visibleNameMatches = (areas.withoutParents[target] || []).filter( (key) => !areas.scanAreasObj[key]?.properties?.hidden, ) - if (!isCanonicalTarget && visibleNameMatches.length) { + if ( + !isCanonicalTarget && + visibleNameMatches.length && + (!includeChildren || !areaMaps.parentDomainsMap[target]?.length) + ) { perms.push(...visibleNameMatches) } @@ -288,7 +270,10 @@ function resolveAreaPerms(roles, req) { // No areas/parents means unrestricted access if (!hasAreas && !hasParents) { - return { areaRestrictions: [], hasUnrestrictedGrant: true } + return { + areaRestrictions: [UNRESTRICTED_ACCESS_SENTINEL], + hasUnrestrictedGrant: true, + } } matchedRestrictedRule = true @@ -316,17 +301,7 @@ function resolveAreaPerms(roles, req) { true, ) } else { - const domain = getParentGrantDomain( - areaRestrictions[j].parent[k], - areas, - areaMaps, - ) - - if (domain) { - perms.push( - encodeParentAreaGrant(domain, areaRestrictions[j].parent[k]), - ) - } + perms.push(encodeParentAreaGrant(areaRestrictions[j].parent[k])) } } } @@ -350,6 +325,10 @@ function resolveAreaPerms(roles, req) { */ function normalizeAreaRestrictions(areaRestrictions, req) { const safeAreaRestrictions = areaRestrictions || [] + if (safeAreaRestrictions.includes(UNRESTRICTED_ACCESS_SENTINEL)) { + return req ? [] : [UNRESTRICTED_ACCESS_SENTINEL] + } + const areas = getRestrictionAreas(req) const areaMaps = getAreaMaps(areas.scanAreas) const normalized = [] @@ -358,7 +337,11 @@ function normalizeAreaRestrictions(areaRestrictions, req) { if (isParentAreaGrant(area)) { if (req) { const parentGrant = decodeParentAreaGrant(area) - if (parentGrant.domain !== getRequestAreaDomain(req)) return + if ( + parentGrant.domain && + parentGrant.domain !== getRequestAreaDomain(req) + ) + return pushAreaKeys(normalized, parentGrant.area, areas, areaMaps, true) } else { @@ -391,6 +374,7 @@ module.exports = { areaPerms, getPublicAreaRestrictions, NO_ACCESS_SENTINEL, + UNRESTRICTED_ACCESS_SENTINEL, normalizeAreaRestrictions, resolveAreaPerms, } diff --git a/server/src/utils/mergePerms.js b/server/src/utils/mergePerms.js index 3fc0728ab..0924b47dc 100644 --- a/server/src/utils/mergePerms.js +++ b/server/src/utils/mergePerms.js @@ -17,23 +17,17 @@ function mergePerms(existingPerms, incomingPerms) { [...keys].map((key) => { const existingValue = existingPerms[key] const incomingValue = incomingPerms[key] - const hasUnrestrictedAreaGrant = - key === 'areaRestrictions' && - ((Array.isArray(existingValue) && existingValue.length === 0) || - (Array.isArray(incomingValue) && incomingValue.length === 0)) return [ key, Array.isArray(existingValue) || Array.isArray(incomingValue) ? key === 'areaRestrictions' - ? hasUnrestrictedAreaGrant - ? [] - : normalizeAreaRestrictions([ - ...new Set([ - ...(existingValue || []), - ...(incomingValue || []), - ]), - ]) + ? normalizeAreaRestrictions([ + ...new Set([ + ...(existingValue || []), + ...(incomingValue || []), + ]), + ]) : [ ...new Set([ ...(existingValue || []), From e9dc694096d9ea82c2b9d1b9c4cf708c7ca1326f Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 23:25:30 -0400 Subject: [PATCH 038/122] fix(auth): keep parent grants portable --- server/src/services/DiscordClient.js | 7 +++---- server/src/services/LocalClient.js | 4 ++-- server/src/services/TelegramClient.js | 7 +++---- server/src/utils/areaPerms.js | 4 ++-- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js index beb9dfdeb..3a09ea7e1 100644 --- a/server/src/services/DiscordClient.js +++ b/server/src/services/DiscordClient.js @@ -125,10 +125,9 @@ class DiscordClient extends AuthClient { /** * * @param {import('passport-discord').Profile} user - * @param {import('express').Request} req * @returns {Promise} */ - async getPerms(user, req) { + async getPerms(user) { const trialActive = this.trialManager.active() /** @type {import("@rm/types").Permissions} */ // @ts-ignore @@ -206,7 +205,7 @@ class DiscordClient extends AuthClient { } } }) - const guildAreaPerms = resolveAreaPerms(userRoles, req) + const guildAreaPerms = resolveAreaPerms(userRoles) guildAreaPerms.areaRestrictions.forEach((x) => permSets.areaRestrictions.add(x), ) @@ -280,7 +279,7 @@ class DiscordClient extends AuthClient { username: profile.username, avatar: profile.avatar || '', locale: profile.locale, - perms: await this.getPerms(profile, req), + perms: await this.getPerms(profile), rmStrategy: this.rmStrategy, valid: false, } diff --git a/server/src/services/LocalClient.js b/server/src/services/LocalClient.js index c955b5af0..c8f5615b2 100644 --- a/server/src/services/LocalClient.js +++ b/server/src/services/LocalClient.js @@ -35,7 +35,7 @@ class LocalClient extends AuthClient { } /** @type {import('passport-local').VerifyFunctionWithRequest} */ - async authHandler(req, username, password, done) { + async authHandler(_req, username, password, done) { const forceTutorial = config.getSafe('map.misc.forceTutorial') const trialActive = this.trialManager.active() const localPerms = Object.keys(this.perms).filter((key) => @@ -44,7 +44,7 @@ class LocalClient extends AuthClient { const user = { perms: /** @type {import('@rm/types').Permissions} */ ({ ...Object.fromEntries(Object.keys(this.perms).map((x) => [x, false])), - areaRestrictions: areaPerms(localPerms, req), + areaRestrictions: areaPerms(localPerms), webhooks: [], scanner: [], scannerCooldownBypass: [], diff --git a/server/src/services/TelegramClient.js b/server/src/services/TelegramClient.js index 0114c2c21..55d9eb8cb 100644 --- a/server/src/services/TelegramClient.js +++ b/server/src/services/TelegramClient.js @@ -62,10 +62,9 @@ class TelegramClient extends AuthClient { * * @param {TGUser} user * @param {string[]} groups - * @param {import('express').Request} req * @returns {TGUser & { perms: import("@rm/types").Permissions }} */ - getUserPerms(user, groups, req) { + getUserPerms(user, groups) { const trialActive = this.trialManager.active() let gainedAccessViaTrial = false @@ -100,7 +99,7 @@ class TelegramClient extends AuthClient { ...perms, trial: gainedAccessViaTrial, admin: false, - areaRestrictions: areaPerms(groups, req), + areaRestrictions: areaPerms(groups), webhooks: webhookPerms(groups, 'telegramGroups', trialActive), scanner: scannerPerms(groups, 'telegramGroups', trialActive), scannerCooldownBypass: scannerCooldownBypass(groups, 'telegramGroups'), @@ -125,7 +124,7 @@ class TelegramClient extends AuthClient { async authHandler(req, profile, done) { const baseUser = { ...profile, rmStrategy: this.rmStrategy } const groups = await this.getUserGroups(baseUser) - const user = this.getUserPerms(baseUser, groups, req) + const user = this.getUserPerms(baseUser, groups) if (!user.perms.map) { this.log.warn(user.username, 'was not given map perms') diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index cf985c127..10f4a3251 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -189,6 +189,8 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (isCanonicalTarget) { if (includeChildren && !targetFeature?.properties?.parent) { + perms.push(target) + const parentName = targetFeature?.properties?.name const domain = areaMaps.keyDomainsMap[target]?.length === 1 @@ -202,8 +204,6 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (scopedChildren.length) { perms.push(...scopedChildren) - } else { - perms.push(target) } } else { perms.push(target) From 8bc11e1e6e6aa6dda1104e41a7adc7720771ad3f Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 23:33:02 -0400 Subject: [PATCH 039/122] fix(auth): avoid parent key overreach --- server/src/utils/areaPerms.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 10f4a3251..cf985c127 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -189,8 +189,6 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (isCanonicalTarget) { if (includeChildren && !targetFeature?.properties?.parent) { - perms.push(target) - const parentName = targetFeature?.properties?.name const domain = areaMaps.keyDomainsMap[target]?.length === 1 @@ -204,6 +202,8 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { if (scopedChildren.length) { perms.push(...scopedChildren) + } else { + perms.push(target) } } else { perms.push(target) From ae79c3ed758672b044d588012713155d028ee95f Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 23:39:54 -0400 Subject: [PATCH 040/122] fix(auth): scope named area rules --- server/src/services/DiscordClient.js | 7 ++++--- server/src/services/LocalClient.js | 4 ++-- server/src/services/TelegramClient.js | 7 ++++--- server/src/utils/areaPerms.js | 12 ++++++++---- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js index 3a09ea7e1..9c4a0a69b 100644 --- a/server/src/services/DiscordClient.js +++ b/server/src/services/DiscordClient.js @@ -125,9 +125,10 @@ class DiscordClient extends AuthClient { /** * * @param {import('passport-discord').Profile} user + * @param {import('express').Request} req * @returns {Promise} */ - async getPerms(user) { + async getPerms(user, req) { const trialActive = this.trialManager.active() /** @type {import("@rm/types").Permissions} */ // @ts-ignore @@ -205,7 +206,7 @@ class DiscordClient extends AuthClient { } } }) - const guildAreaPerms = resolveAreaPerms(userRoles) + const guildAreaPerms = resolveAreaPerms(userRoles, req, true) guildAreaPerms.areaRestrictions.forEach((x) => permSets.areaRestrictions.add(x), ) @@ -279,7 +280,7 @@ class DiscordClient extends AuthClient { username: profile.username, avatar: profile.avatar || '', locale: profile.locale, - perms: await this.getPerms(profile), + perms: await this.getPerms(profile, req), rmStrategy: this.rmStrategy, valid: false, } diff --git a/server/src/services/LocalClient.js b/server/src/services/LocalClient.js index c8f5615b2..decc26a1d 100644 --- a/server/src/services/LocalClient.js +++ b/server/src/services/LocalClient.js @@ -35,7 +35,7 @@ class LocalClient extends AuthClient { } /** @type {import('passport-local').VerifyFunctionWithRequest} */ - async authHandler(_req, username, password, done) { + async authHandler(req, username, password, done) { const forceTutorial = config.getSafe('map.misc.forceTutorial') const trialActive = this.trialManager.active() const localPerms = Object.keys(this.perms).filter((key) => @@ -44,7 +44,7 @@ class LocalClient extends AuthClient { const user = { perms: /** @type {import('@rm/types').Permissions} */ ({ ...Object.fromEntries(Object.keys(this.perms).map((x) => [x, false])), - areaRestrictions: areaPerms(localPerms), + areaRestrictions: areaPerms(localPerms, req, true), webhooks: [], scanner: [], scannerCooldownBypass: [], diff --git a/server/src/services/TelegramClient.js b/server/src/services/TelegramClient.js index 55d9eb8cb..ca17fff3c 100644 --- a/server/src/services/TelegramClient.js +++ b/server/src/services/TelegramClient.js @@ -62,9 +62,10 @@ class TelegramClient extends AuthClient { * * @param {TGUser} user * @param {string[]} groups + * @param {import('express').Request} req * @returns {TGUser & { perms: import("@rm/types").Permissions }} */ - getUserPerms(user, groups) { + getUserPerms(user, groups, req) { const trialActive = this.trialManager.active() let gainedAccessViaTrial = false @@ -99,7 +100,7 @@ class TelegramClient extends AuthClient { ...perms, trial: gainedAccessViaTrial, admin: false, - areaRestrictions: areaPerms(groups), + areaRestrictions: areaPerms(groups, req, true), webhooks: webhookPerms(groups, 'telegramGroups', trialActive), scanner: scannerPerms(groups, 'telegramGroups', trialActive), scannerCooldownBypass: scannerCooldownBypass(groups, 'telegramGroups'), @@ -124,7 +125,7 @@ class TelegramClient extends AuthClient { async authHandler(req, profile, done) { const baseUser = { ...profile, rmStrategy: this.rmStrategy } const groups = await this.getUserGroups(baseUser) - const user = this.getUserPerms(baseUser, groups) + const user = this.getUserPerms(baseUser, groups, req) if (!user.perms.map) { this.log.warn(user.username, 'was not given map perms') diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index cf985c127..3bab3c2b9 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -248,9 +248,10 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { /** * @param {string[]} roles * @param {import('express').Request} [req] + * @param {boolean} [serializeParentGrants] * @returns {{ areaRestrictions: string[], hasUnrestrictedGrant: boolean }} */ -function resolveAreaPerms(roles, req) { +function resolveAreaPerms(roles, req, serializeParentGrants = false) { const areaRestrictions = config.getSafe('authentication.areaRestrictions') const areas = getRestrictionAreas(req) const areaMaps = getAreaMaps(areas.scanAreas) @@ -292,7 +293,9 @@ function resolveAreaPerms(roles, req) { if (hasParents) { for (let k = 0; k < areaRestrictions[j].parent.length; k += 1) { - if (req) { + if (serializeParentGrants) { + perms.push(encodeParentAreaGrant(areaRestrictions[j].parent[k])) + } else if (req) { pushAreaKeys( perms, areaRestrictions[j].parent[k], @@ -364,10 +367,11 @@ function normalizeAreaRestrictions(areaRestrictions, req) { /** * @param {string[]} roles * @param {import('express').Request} [req] + * @param {boolean} [serializeParentGrants] * @returns {string[]} */ -function areaPerms(roles, req) { - return resolveAreaPerms(roles, req).areaRestrictions +function areaPerms(roles, req, serializeParentGrants = false) { + return resolveAreaPerms(roles, req, serializeParentGrants).areaRestrictions } module.exports = { From 3e23d993e6a119074604d0f6a7912fa91cd9ad16 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 30 Mar 2026 23:44:13 -0400 Subject: [PATCH 041/122] fix(auth): scope serialized parent grants --- server/src/utils/areaPerms.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 3bab3c2b9..f3a80a5f3 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -14,11 +14,14 @@ function isParentAreaGrant(area) { } /** - * @param {string} area + * @param {string} areaOrDomain + * @param {string} [area] * @returns {string} */ -function encodeParentAreaGrant(area) { - return `${PARENT_ACCESS_PREFIX}${JSON.stringify({ area })}` +function encodeParentAreaGrant(areaOrDomain, area) { + return `${PARENT_ACCESS_PREFIX}${JSON.stringify( + area ? { domain: areaOrDomain, area } : { area: areaOrDomain }, + )}` } /** @@ -294,7 +297,12 @@ function resolveAreaPerms(roles, req, serializeParentGrants = false) { if (hasParents) { for (let k = 0; k < areaRestrictions[j].parent.length; k += 1) { if (serializeParentGrants) { - perms.push(encodeParentAreaGrant(areaRestrictions[j].parent[k])) + perms.push( + encodeParentAreaGrant( + req ? getRequestAreaDomain(req) : '', + areaRestrictions[j].parent[k], + ), + ) } else if (req) { pushAreaKeys( perms, From 168edf56b7625044a4ec85b946683e91585e20cf Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 00:10:31 -0400 Subject: [PATCH 042/122] fix(auth): preserve unrestricted area grants --- server/src/graphql/resolvers.js | 10 +++++++++- server/src/models/Weather.js | 14 ++++++++++++-- server/src/utils/areaPerms.js | 13 +++++++++++-- server/src/utils/filterRTree.js | 8 +++++++- server/src/utils/getAreaSql.js | 11 +++++++++-- 5 files changed, 48 insertions(+), 8 deletions(-) diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index 85d0fb6b0..d81ef30dd 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -18,6 +18,7 @@ const { getPolyVector } = require('../utils/getPolyVector') const { getPlacementCells } = require('../utils/getPlacementCells') const { getTypeCells } = require('../utils/getTypeCells') const { getValidCoords } = require('../utils/getValidCoords') +const { hasUnrestrictedAreaGrant } = require('../utils/areaPerms') /** @type {import("@apollo/server").ApolloServerOptions['resolvers']} */ const resolvers = { @@ -355,6 +356,9 @@ const resolvers = { scanAreas: (_, _args, { req, perms }) => { if (perms?.scanAreas) { const scanAreas = config.getAreas(req, 'scanAreas') + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant( + perms.areaRestrictions, + ) const parentKeyByName = Object.fromEntries( scanAreas.features .filter( @@ -369,6 +373,7 @@ const resolvers = { ]), ) const canAccessArea = (properties) => + unrestrictedAreaGrant || !perms.areaRestrictions.length || perms.areaRestrictions.includes(properties.key) || perms.areaRestrictions.includes(properties.name) || @@ -393,6 +398,9 @@ const resolvers = { scanAreasMenu: (_, _args, { req, perms }) => { if (perms?.scanAreas) { const scanAreas = config.getAreas(req, 'scanAreasMenu') + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant( + perms.areaRestrictions, + ) const parentKeyByName = Object.fromEntries( scanAreas .filter((parent) => parent.name && parent.details?.properties?.key) @@ -406,7 +414,7 @@ const resolvers = { : null, })) - if (perms.areaRestrictions.length) { + if (perms.areaRestrictions.length && !unrestrictedAreaGrant) { const canAccessArea = (properties) => perms.areaRestrictions.includes(properties.key) || perms.areaRestrictions.includes(properties.name) || diff --git a/server/src/models/Weather.js b/server/src/models/Weather.js index 51fe918c1..f6ea56752 100644 --- a/server/src/models/Weather.js +++ b/server/src/models/Weather.js @@ -9,6 +9,7 @@ const config = require('@rm/config') const { getPolyVector } = require('../utils/getPolyVector') const { getPolygonBbox } = require('../utils/getBbox') const { consolidateAreas } = require('../utils/consolidateAreas') +const { hasUnrestrictedAreaGrant } = require('../utils/areaPerms') class Weather extends Model { static get tableName() { @@ -43,10 +44,19 @@ class Weather extends Model { const results = await query const areas = config.getSafe('areas') + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant( + perms.areaRestrictions, + ) const hasAreaFilter = - perms.areaRestrictions.length || (args.filters.onlyAreas || []).length + (!unrestrictedAreaGrant && perms.areaRestrictions.length) || + (args.filters.onlyAreas || []).length const merged = hasAreaFilter - ? [...consolidateAreas(perms.areaRestrictions, args.filters.onlyAreas)] + ? [ + ...consolidateAreas( + unrestrictedAreaGrant ? [] : perms.areaRestrictions, + args.filters.onlyAreas, + ), + ] : [] if (hasAreaFilter && !merged.length) { diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index f3a80a5f3..9f1872ac6 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -46,6 +46,14 @@ function getPublicAreaRestrictions(areaRestrictions = []) { ) } +/** + * @param {string[]} [areaRestrictions] + * @returns {boolean} + */ +function hasUnrestrictedAreaGrant(areaRestrictions = []) { + return areaRestrictions.includes(UNRESTRICTED_ACCESS_SENTINEL) +} + /** * @param {Record} scanAreas * @returns {{ @@ -336,8 +344,8 @@ function resolveAreaPerms(roles, req, serializeParentGrants = false) { */ function normalizeAreaRestrictions(areaRestrictions, req) { const safeAreaRestrictions = areaRestrictions || [] - if (safeAreaRestrictions.includes(UNRESTRICTED_ACCESS_SENTINEL)) { - return req ? [] : [UNRESTRICTED_ACCESS_SENTINEL] + if (hasUnrestrictedAreaGrant(safeAreaRestrictions)) { + return [UNRESTRICTED_ACCESS_SENTINEL] } const areas = getRestrictionAreas(req) @@ -385,6 +393,7 @@ function areaPerms(roles, req, serializeParentGrants = false) { module.exports = { areaPerms, getPublicAreaRestrictions, + hasUnrestrictedAreaGrant, NO_ACCESS_SENTINEL, UNRESTRICTED_ACCESS_SENTINEL, normalizeAreaRestrictions, diff --git a/server/src/utils/filterRTree.js b/server/src/utils/filterRTree.js index f87f9c51c..9ec085540 100644 --- a/server/src/utils/filterRTree.js +++ b/server/src/utils/filterRTree.js @@ -4,6 +4,7 @@ const { point } = require('@turf/helpers') const config = require('@rm/config') const { consolidateAreas } = require('./consolidateAreas') +const { hasUnrestrictedAreaGrant } = require('./areaPerms') /** * Filters via RTree in place of MySQL query when using in memory data @@ -14,9 +15,14 @@ const { consolidateAreas } = require('./consolidateAreas') * @returns {boolean} */ function filterRTree(item, areaRestrictions = [], onlyAreas = []) { + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant(areaRestrictions) + if (unrestrictedAreaGrant && !onlyAreas.length) return true if (!areaRestrictions.length && !onlyAreas.length) return true - const consolidatedAreas = consolidateAreas(areaRestrictions, onlyAreas) + const consolidatedAreas = consolidateAreas( + unrestrictedAreaGrant ? [] : areaRestrictions, + onlyAreas, + ) if (!consolidatedAreas.size) return false diff --git a/server/src/utils/getAreaSql.js b/server/src/utils/getAreaSql.js index 7174cff15..375578349 100644 --- a/server/src/utils/getAreaSql.js +++ b/server/src/utils/getAreaSql.js @@ -1,6 +1,7 @@ // @ts-check const config = require('@rm/config') const { consolidateAreas } = require('./consolidateAreas') +const { hasUnrestrictedAreaGrant } = require('./areaPerms') /** * @@ -20,16 +21,22 @@ function getAreaSql( ) { const authentication = config.getSafe('authentication') const areas = config.getSafe('areas') + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant(areaRestrictions) if ( authentication.strictAreaRestrictions && authentication.areaRestrictions.length && - !areaRestrictions.length + !areaRestrictions.length && + !unrestrictedAreaGrant ) return false + if (unrestrictedAreaGrant && !onlyAreas?.length) return true if (!areaRestrictions?.length && !onlyAreas?.length) return true - const consolidatedAreas = consolidateAreas(areaRestrictions, onlyAreas) + const consolidatedAreas = consolidateAreas( + unrestrictedAreaGrant ? [] : areaRestrictions, + onlyAreas, + ) if (!consolidatedAreas.size) return false From aea4ec73bf4ecfeb8b31afa5f4604f5897a93395 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 00:10:47 -0400 Subject: [PATCH 043/122] fix(drawer): exclude grouped parent filter keys --- src/features/drawer/areas/AreaTable.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 8ec046cb3..99403665a 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -53,7 +53,8 @@ export function ScanAreasTable() { ...new Set( data?.scanAreasMenu.flatMap((parent) => [ ...(parent.details?.properties?.key && - !parent.details.properties.manual + !parent.details.properties.manual && + !parent.children.length ? [parent.details.properties.key] : []), ...parent.children From f2776a734348bd4b429b7be9b142564c4b370bd2 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 00:27:04 -0400 Subject: [PATCH 044/122] fix(auth): keep legacy area grants global --- server/src/utils/areaPerms.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 9f1872ac6..9eea83e8a 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -264,8 +264,12 @@ function pushAreaKeys(perms, target, areas, areaMaps, includeChildren = false) { */ function resolveAreaPerms(roles, req, serializeParentGrants = false) { const areaRestrictions = config.getSafe('authentication.areaRestrictions') - const areas = getRestrictionAreas(req) - const areaMaps = getAreaMaps(areas.scanAreas) + const globalAreas = getRestrictionAreas() + const globalAreaMaps = getAreaMaps(globalAreas.scanAreas) + const requestAreas = req ? getRestrictionAreas(req) : globalAreas + const requestAreaMaps = req + ? getAreaMaps(requestAreas.scanAreas) + : globalAreaMaps const perms = [] let matchedRestrictedRule = false @@ -295,8 +299,8 @@ function resolveAreaPerms(roles, req, serializeParentGrants = false) { pushAreaKeys( perms, areaRestrictions[j].areas[k], - areas, - areaMaps, + globalAreas, + globalAreaMaps, false, ) } @@ -315,8 +319,8 @@ function resolveAreaPerms(roles, req, serializeParentGrants = false) { pushAreaKeys( perms, areaRestrictions[j].parent[k], - areas, - areaMaps, + requestAreas, + requestAreaMaps, true, ) } else { From b6c863aac8183df8f6bd23113b2c866b71c23dff Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 00:27:21 -0400 Subject: [PATCH 045/122] fix(drawer): allow manual-only parent filters --- src/features/drawer/areas/AreaTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 99403665a..7d805a885 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -54,7 +54,7 @@ export function ScanAreasTable() { data?.scanAreasMenu.flatMap((parent) => [ ...(parent.details?.properties?.key && !parent.details.properties.manual && - !parent.children.length + !parent.children.some((child) => !child.properties.manual) ? [parent.details.properties.key] : []), ...parent.children From 9052b70e5de1ff174ef5ff92343a4f40b5eed5d5 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 00:33:07 -0400 Subject: [PATCH 046/122] fix(auth): scope named area grants --- server/src/utils/areaPerms.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 9eea83e8a..02ca3b8aa 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -296,11 +296,14 @@ function resolveAreaPerms(roles, req, serializeParentGrants = false) { if (hasAreas) { for (let k = 0; k < areaRestrictions[j].areas.length; k += 1) { + const areaTarget = areaRestrictions[j].areas[k] + const usesGlobalAreaLookup = + !req || globalAreas.scanAreasObj[areaTarget] pushAreaKeys( perms, - areaRestrictions[j].areas[k], - globalAreas, - globalAreaMaps, + areaTarget, + usesGlobalAreaLookup ? globalAreas : requestAreas, + usesGlobalAreaLookup ? globalAreaMaps : requestAreaMaps, false, ) } From c8f4dcba3cbd58a7fb685d7ee1b21821da3f0622 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 00:59:20 -0400 Subject: [PATCH 047/122] fix(auth): scope anonymous parent grants --- server/src/routes/rootRouter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/routes/rootRouter.js b/server/src/routes/rootRouter.js index 91d3a83bc..c3ade3433 100644 --- a/server/src/routes/rootRouter.js +++ b/server/src/routes/rootRouter.js @@ -140,7 +140,7 @@ rootRouter.get('/api/settings', async (req, res, next) => { ...Object.fromEntries( Object.keys(authentication.perms).map((p) => [p, false]), ), - areaRestrictions: areaPerms(['none']), + areaRestrictions: areaPerms(['none'], req, true), webhooks: [], scanner: Object.keys(scanner).filter( (key) => From 85fd7736c7bf00d9362f4a8f111db4f7357957fe Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 00:59:38 -0400 Subject: [PATCH 048/122] fix(drawer): keep map parent filters editable --- src/features/drawer/areas/AreaTable.jsx | 93 +++++++++++++------------ src/features/drawer/areas/Child.jsx | 32 ++++++++- 2 files changed, 80 insertions(+), 45 deletions(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index 7d805a885..fac32280a 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -53,8 +53,7 @@ export function ScanAreasTable() { ...new Set( data?.scanAreasMenu.flatMap((parent) => [ ...(parent.details?.properties?.key && - !parent.details.properties.manual && - !parent.children.some((child) => !child.properties.manual) + !parent.details.properties.manual ? [parent.details.properties.key] : []), ...parent.children @@ -72,6 +71,9 @@ export function ScanAreasTable() { .map((area) => ({ ...area, allChildren: area.children, + groupKey: area.details?.properties?.manual + ? undefined + : area.details?.properties?.key, details: search === '' || area.details?.properties?.key?.toLowerCase()?.includes(search) @@ -194,47 +196,52 @@ export function ScanAreasTable() { })} > - {allRows.map(({ name, details, children, rows, allChildren }) => { - if (!children.length && !details) return null - return ( - - {name && ( - - - - )} - {!!rows.length && ( - - - {rows.map((row, i) => ( - - {row.map((feature, j) => ( - - ))} - - ))} - - - )} - - ) - })} + {allRows.map( + ({ name, details, children, rows, allChildren, groupKey }) => { + if (!children.length && !details) return null + return ( + + {name && ( + + + + )} + {!!rows.length && ( + + + {rows.map((row, i) => ( + + {row.map((feature, j) => ( + + ))} + + ))} + + + )} + + ) + }, + )} {showJumpResults && ( diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 1d046716e..fba6ec041 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -19,6 +19,7 @@ import { useMemory } from '@store/useMemory' * allAreas?: string[] * childAreas?: Pick[] * allChildAreas?: Pick[] + * groupKey?: string * borderRight?: boolean * colSpan?: number * }} props @@ -28,6 +29,7 @@ export function AreaChild({ feature, childAreas, allChildAreas, + groupKey, allAreas, borderRight, colSpan = 1, @@ -82,6 +84,10 @@ export function AreaChild({ hasManual || (name ? !selectableAreaKeys.length : !feature.properties.name) ? 'transparent' : 'none' + const coveredByGroup = + !name && !feature?.properties?.manual && groupKey + ? scanAreas.includes(groupKey) + : false const nameProp = name || feature?.properties?.formattedName || feature?.properties?.name @@ -130,14 +136,36 @@ export function AreaChild({ size="small" color="secondary" indeterminate={name ? hasSome && !hasAll : false} - checked={name ? hasAll : scanAreas.includes(feature.properties.key)} + checked={ + name + ? hasAll + : coveredByGroup || scanAreas.includes(feature.properties.key) + } onClick={(e) => e.stopPropagation()} onChange={() => { - const areaKeys = name + let areaKeys = name ? hasSome ? removableAreaKeys : selectableAreaKeys : feature.properties.key + + if (!name && coveredByGroup) { + const siblingAreaKeys = (allChildAreas || childAreas || []) + .filter( + (child) => + !child.properties.manual && + child.properties.key !== feature.properties.key, + ) + .map((child) => child.properties.key) + areaKeys = [ + groupKey, + ...(scanAreas.includes(feature.properties.key) + ? [feature.properties.key] + : []), + ...siblingAreaKeys.filter((key) => !scanAreas.includes(key)), + ] + } + setAreas(areaKeys, allAreas, name ? hasSome : false) }} sx={{ From 1052c23452393cdee276a88beae4dec5335ac62a Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 01:07:54 -0400 Subject: [PATCH 049/122] fix(drawer): keep child selection literal --- src/features/drawer/areas/Child.jsx | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index fba6ec041..4e731cf06 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -136,11 +136,7 @@ export function AreaChild({ size="small" color="secondary" indeterminate={name ? hasSome && !hasAll : false} - checked={ - name - ? hasAll - : coveredByGroup || scanAreas.includes(feature.properties.key) - } + checked={name ? hasAll : scanAreas.includes(feature.properties.key)} onClick={(e) => e.stopPropagation()} onChange={() => { let areaKeys = name @@ -150,20 +146,7 @@ export function AreaChild({ : feature.properties.key if (!name && coveredByGroup) { - const siblingAreaKeys = (allChildAreas || childAreas || []) - .filter( - (child) => - !child.properties.manual && - child.properties.key !== feature.properties.key, - ) - .map((child) => child.properties.key) - areaKeys = [ - groupKey, - ...(scanAreas.includes(feature.properties.key) - ? [feature.properties.key] - : []), - ...siblingAreaKeys.filter((key) => !scanAreas.includes(key)), - ] + areaKeys = [groupKey, feature.properties.key] } setAreas(areaKeys, allAreas, name ? hasSome : false) From d5e84b4fe70ce1cb9c0b29798fbc30470d5df622 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 01:14:02 -0400 Subject: [PATCH 050/122] fix(drawer): normalize grouped map toggles --- src/features/drawer/areas/AreaTable.jsx | 3 +- src/features/scanArea/ScanAreaTile.jsx | 66 ++++++++++++++++++++----- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index fac32280a..12406537b 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -53,7 +53,8 @@ export function ScanAreasTable() { ...new Set( data?.scanAreasMenu.flatMap((parent) => [ ...(parent.details?.properties?.key && - !parent.details.properties.manual + !parent.details.properties.manual && + !parent.children.some((child) => !child.properties.manual) ? [parent.details.properties.key] : []), ...parent.children diff --git a/src/features/scanArea/ScanAreaTile.jsx b/src/features/scanArea/ScanAreaTile.jsx index 1cbfcb03d..e24f51b30 100644 --- a/src/features/scanArea/ScanAreaTile.jsx +++ b/src/features/scanArea/ScanAreaTile.jsx @@ -8,6 +8,39 @@ import { useWebhookStore, handleClick } from '@store/useWebhookStore' import { useStorage } from '@store/useStorage' import { getProperName } from '@utils/strings' +/** + * @param {Pick[]} features + * @param {Pick} feature + * @returns {string[]} + */ +function getAreaKeys(features, feature) { + if (!feature?.properties?.key || feature.properties.manual) return [] + + const childKeys = + !feature.properties.parent && feature.properties.name + ? features + .filter( + (child) => + !child.properties.manual && + child.properties.parent === feature.properties.name && + child.properties.key, + ) + .map((child) => child.properties.key) + : [] + + return childKeys.length ? childKeys : [feature.properties.key] +} + +/** + * @param {Pick[]} features + * @returns {string[]} + */ +function getValidAreaKeys(features) { + return [ + ...new Set(features.flatMap((feature) => getAreaKeys(features, feature))), + ] +} + /** * * @param {import('@rm/types').RMGeoJSON} featureCollection @@ -33,7 +66,7 @@ function ScanArea(featureCollection) { eventHandlers={{ click: ({ propagatedFrom: layer }) => { if (!layer.feature) return - const { name, key, manual = false } = layer.feature.properties + const { name, manual = false } = layer.feature.properties if (webhook && name && handleClick) { handleClick(name)().then((newAreas) => { layer.setStyle({ @@ -45,21 +78,31 @@ function ScanArea(featureCollection) { }) }) } else if (!manual && tapToToggle) { + const areaKeys = getAreaKeys( + featureCollection.features, + layer.feature, + ) + const validAreaKeys = getValidAreaKeys(featureCollection.features) const { filters, setAreas } = useStorage.getState() - const includes = filters?.scanAreas?.filter?.areas?.includes(key) - layer.setStyle({ fillOpacity: includes ? 0.2 : 0.8 }) - setAreas( - key, - featureCollection.features - .filter((f) => !f.properties.manual) - .map((f) => f.properties.key), + const hasSome = areaKeys.some((area) => + filters?.scanAreas?.filter?.areas?.includes(area), ) + layer.setStyle({ fillOpacity: hasSome ? 0.2 : 0.8 }) + setAreas(areaKeys, validAreaKeys, hasSome) } }, }} onEachFeature={(feature, layer) => { if (feature.properties?.name) { - const { name, key } = feature.properties + const { name } = feature.properties + const areaKeys = getAreaKeys(featureCollection.features, feature) + const isSelected = areaKeys.length + ? areaKeys.every((area) => + ( + useStorage.getState().filters?.scanAreas?.filter?.areas || [] + ).includes(area), + ) + : false const popupContent = getProperName(name) if (layer instanceof Polygon) { layer @@ -81,10 +124,7 @@ function ScanArea(featureCollection) { .human?.area?.some( (area) => area.toLowerCase() === name?.toLowerCase(), ) - : ( - useStorage.getState().filters?.scanAreas?.filter - ?.areas || [] - ).includes(webhook ? name : key) + : isSelected ) ? 0.8 : 0.2, From 2723720450e3879e613c265269bc196394ffe167 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 01:20:40 -0400 Subject: [PATCH 051/122] fix(scanArea): migrate legacy grouped filters --- src/features/scanArea/ScanAreaTile.jsx | 58 +++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/features/scanArea/ScanAreaTile.jsx b/src/features/scanArea/ScanAreaTile.jsx index e24f51b30..f56030684 100644 --- a/src/features/scanArea/ScanAreaTile.jsx +++ b/src/features/scanArea/ScanAreaTile.jsx @@ -41,6 +41,33 @@ function getValidAreaKeys(features) { ] } +/** + * @param {Pick[]} features + * @param {string[]} selectedAreas + * @returns {string[] | null} + */ +function migrateLegacyAreaKeys(features, selectedAreas) { + const migrated = new Set(selectedAreas) + let changed = false + + features.forEach((feature) => { + if (!feature.properties?.key || !migrated.has(feature.properties.key)) { + return + } + + const areaKeys = getAreaKeys(features, feature) + if (areaKeys.length === 1 && areaKeys[0] === feature.properties.key) { + return + } + + migrated.delete(feature.properties.key) + areaKeys.forEach((area) => migrated.add(area)) + changed = true + }) + + return changed ? [...migrated] : null +} + /** * * @param {import('@rm/types').RMGeoJSON} featureCollection @@ -48,11 +75,36 @@ function getValidAreaKeys(features) { */ function ScanArea(featureCollection) { const search = useStorage((s) => s.filters.scanAreas?.filter?.search) + const selectedAreas = useStorage( + (s) => s.filters.scanAreas?.filter?.areas || [], + ) const tapToToggle = useStorage((s) => s.userSettings.scanAreas.tapToToggle) const alwaysShowLabels = useStorage( (s) => s.userSettings.scanAreas.alwaysShowLabels, ) const webhook = useWebhookStore((s) => !!s.mode) + const migratedAreas = React.useMemo( + () => migrateLegacyAreaKeys(featureCollection.features, selectedAreas), + [featureCollection.features, selectedAreas], + ) + const effectiveSelectedAreas = migratedAreas || selectedAreas + + React.useEffect(() => { + if (!migratedAreas?.length) return + + useStorage.setState((prev) => ({ + filters: { + ...prev.filters, + scanAreas: { + ...prev.filters.scanAreas, + filter: { + ...prev.filters.scanAreas?.filter, + areas: migratedAreas, + }, + }, + }, + })) + }, [migratedAreas]) return ( - ( - useStorage.getState().filters?.scanAreas?.filter?.areas || [] - ).includes(area), - ) + ? areaKeys.every((area) => effectiveSelectedAreas.includes(area)) : false const popupContent = getProperName(name) if (layer instanceof Polygon) { From d9662852d0b444500ec2ec50896705230ad69c53 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 01:27:51 -0400 Subject: [PATCH 052/122] fix(scanArea): migrate filters outside the layer --- src/features/scanArea/LegacyMigration.jsx | 50 ++++++++++++++ src/features/scanArea/ScanAreaTile.jsx | 84 ++--------------------- src/features/scanArea/index.js | 1 + src/features/scanArea/utils.js | 61 ++++++++++++++++ src/pages/map/components/Container.jsx | 2 + 5 files changed, 120 insertions(+), 78 deletions(-) create mode 100644 src/features/scanArea/LegacyMigration.jsx create mode 100644 src/features/scanArea/utils.js diff --git a/src/features/scanArea/LegacyMigration.jsx b/src/features/scanArea/LegacyMigration.jsx new file mode 100644 index 000000000..b2cbed0f8 --- /dev/null +++ b/src/features/scanArea/LegacyMigration.jsx @@ -0,0 +1,50 @@ +// @ts-check +import * as React from 'react' +import { useQuery } from '@apollo/client' + +import { Query } from '@services/queries' +import { useStorage } from '@store/useStorage' + +import { migrateLegacyAreaKeys } from './utils' + +export function LegacyScanAreaMigration() { + const selectedAreas = useStorage( + (s) => s.filters.scanAreas?.filter?.areas || [], + ) + const { data } = useQuery(Query.scanAreasMenu(), { + skip: !selectedAreas.length, + }) + + const features = React.useMemo( + () => + data?.scanAreasMenu.flatMap((parent) => [ + ...(parent.details ? [parent.details] : []), + ...parent.children, + ]) || [], + [data], + ) + const migratedAreas = React.useMemo( + () => + features.length ? migrateLegacyAreaKeys(features, selectedAreas) : null, + [features, selectedAreas], + ) + + React.useEffect(() => { + if (!migratedAreas?.length) return + + useStorage.setState((prev) => ({ + filters: { + ...prev.filters, + scanAreas: { + ...prev.filters.scanAreas, + filter: { + ...prev.filters.scanAreas?.filter, + areas: migratedAreas, + }, + }, + }, + })) + }, [migratedAreas]) + + return null +} diff --git a/src/features/scanArea/ScanAreaTile.jsx b/src/features/scanArea/ScanAreaTile.jsx index f56030684..535a162ba 100644 --- a/src/features/scanArea/ScanAreaTile.jsx +++ b/src/features/scanArea/ScanAreaTile.jsx @@ -7,66 +7,7 @@ import { Polygon } from 'leaflet' import { useWebhookStore, handleClick } from '@store/useWebhookStore' import { useStorage } from '@store/useStorage' import { getProperName } from '@utils/strings' - -/** - * @param {Pick[]} features - * @param {Pick} feature - * @returns {string[]} - */ -function getAreaKeys(features, feature) { - if (!feature?.properties?.key || feature.properties.manual) return [] - - const childKeys = - !feature.properties.parent && feature.properties.name - ? features - .filter( - (child) => - !child.properties.manual && - child.properties.parent === feature.properties.name && - child.properties.key, - ) - .map((child) => child.properties.key) - : [] - - return childKeys.length ? childKeys : [feature.properties.key] -} - -/** - * @param {Pick[]} features - * @returns {string[]} - */ -function getValidAreaKeys(features) { - return [ - ...new Set(features.flatMap((feature) => getAreaKeys(features, feature))), - ] -} - -/** - * @param {Pick[]} features - * @param {string[]} selectedAreas - * @returns {string[] | null} - */ -function migrateLegacyAreaKeys(features, selectedAreas) { - const migrated = new Set(selectedAreas) - let changed = false - - features.forEach((feature) => { - if (!feature.properties?.key || !migrated.has(feature.properties.key)) { - return - } - - const areaKeys = getAreaKeys(features, feature) - if (areaKeys.length === 1 && areaKeys[0] === feature.properties.key) { - return - } - - migrated.delete(feature.properties.key) - areaKeys.forEach((area) => migrated.add(area)) - changed = true - }) - - return changed ? [...migrated] : null -} +import { getAreaKeys, getValidAreaKeys, migrateLegacyAreaKeys } from './utils' /** * @@ -88,27 +29,14 @@ function ScanArea(featureCollection) { [featureCollection.features, selectedAreas], ) const effectiveSelectedAreas = migratedAreas || selectedAreas - - React.useEffect(() => { - if (!migratedAreas?.length) return - - useStorage.setState((prev) => ({ - filters: { - ...prev.filters, - scanAreas: { - ...prev.filters.scanAreas, - filter: { - ...prev.filters.scanAreas?.filter, - areas: migratedAreas, - }, - }, - }, - })) - }, [migratedAreas]) + const selectionKey = React.useMemo( + () => [...effectiveSelectedAreas].sort().join(','), + [effectiveSelectedAreas], + ) return ( webhook || diff --git a/src/features/scanArea/index.js b/src/features/scanArea/index.js index 5fcd577bf..e98531894 100644 --- a/src/features/scanArea/index.js +++ b/src/features/scanArea/index.js @@ -1,3 +1,4 @@ // @ts-check +export * from './LegacyMigration' export * from './ScanAreaTile' diff --git a/src/features/scanArea/utils.js b/src/features/scanArea/utils.js new file mode 100644 index 000000000..e6882e18e --- /dev/null +++ b/src/features/scanArea/utils.js @@ -0,0 +1,61 @@ +// @ts-check + +/** + * @param {Pick[]} features + * @param {Pick} feature + * @returns {string[]} + */ +export function getAreaKeys(features, feature) { + if (!feature?.properties?.key || feature.properties.manual) return [] + + const childKeys = + !feature.properties.parent && feature.properties.name + ? features + .filter( + (child) => + !child.properties.manual && + child.properties.parent === feature.properties.name && + child.properties.key, + ) + .map((child) => child.properties.key) + : [] + + return childKeys.length ? childKeys : [feature.properties.key] +} + +/** + * @param {Pick[]} features + * @returns {string[]} + */ +export function getValidAreaKeys(features) { + return [ + ...new Set(features.flatMap((feature) => getAreaKeys(features, feature))), + ] +} + +/** + * @param {Pick[]} features + * @param {string[]} selectedAreas + * @returns {string[] | null} + */ +export function migrateLegacyAreaKeys(features, selectedAreas) { + const migrated = new Set(selectedAreas) + let changed = false + + features.forEach((feature) => { + if (!feature.properties?.key || !migrated.has(feature.properties.key)) { + return + } + + const areaKeys = getAreaKeys(features, feature) + if (areaKeys.length === 1 && areaKeys[0] === feature.properties.key) { + return + } + + migrated.delete(feature.properties.key) + areaKeys.forEach((area) => migrated.add(area)) + changed = true + }) + + return changed ? [...migrated] : null +} diff --git a/src/pages/map/components/Container.jsx b/src/pages/map/components/Container.jsx index ffacef9a8..674621994 100644 --- a/src/pages/map/components/Container.jsx +++ b/src/pages/map/components/Container.jsx @@ -6,6 +6,7 @@ import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useMapStore } from '@store/useMapStore' import { ScanOnDemand } from '@features/scanner' +import { LegacyScanAreaMigration } from '@features/scanArea' import { WebhookMarker, WebhookAreaSelection } from '@features/webhooks' import { ActiveWeather } from '@features/weather' import { timeCheck } from '@utils/timeCheck' @@ -57,6 +58,7 @@ export function Container() { preferCanvas > + From 68ed0243b38174cd03b2fe74a5e2c540f3eb542f Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 01:40:56 -0400 Subject: [PATCH 053/122] fix(areas): align legacy grouped selection state --- src/features/drawer/areas/Child.jsx | 21 +++++++++++++++++++-- src/features/scanArea/ScanAreaTile.jsx | 4 ++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index 4e731cf06..fba6ec041 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -136,7 +136,11 @@ export function AreaChild({ size="small" color="secondary" indeterminate={name ? hasSome && !hasAll : false} - checked={name ? hasAll : scanAreas.includes(feature.properties.key)} + checked={ + name + ? hasAll + : coveredByGroup || scanAreas.includes(feature.properties.key) + } onClick={(e) => e.stopPropagation()} onChange={() => { let areaKeys = name @@ -146,7 +150,20 @@ export function AreaChild({ : feature.properties.key if (!name && coveredByGroup) { - areaKeys = [groupKey, feature.properties.key] + const siblingAreaKeys = (allChildAreas || childAreas || []) + .filter( + (child) => + !child.properties.manual && + child.properties.key !== feature.properties.key, + ) + .map((child) => child.properties.key) + areaKeys = [ + groupKey, + ...(scanAreas.includes(feature.properties.key) + ? [feature.properties.key] + : []), + ...siblingAreaKeys.filter((key) => !scanAreas.includes(key)), + ] } setAreas(areaKeys, allAreas, name ? hasSome : false) diff --git a/src/features/scanArea/ScanAreaTile.jsx b/src/features/scanArea/ScanAreaTile.jsx index 535a162ba..cf655700d 100644 --- a/src/features/scanArea/ScanAreaTile.jsx +++ b/src/features/scanArea/ScanAreaTile.jsx @@ -63,9 +63,9 @@ function ScanArea(featureCollection) { layer.feature, ) const validAreaKeys = getValidAreaKeys(featureCollection.features) - const { filters, setAreas } = useStorage.getState() + const { setAreas } = useStorage.getState() const hasSome = areaKeys.some((area) => - filters?.scanAreas?.filter?.areas?.includes(area), + effectiveSelectedAreas.includes(area), ) layer.setStyle({ fillOpacity: hasSome ? 0.2 : 0.8 }) setAreas(areaKeys, validAreaKeys, hasSome) From 2e0e698777771495142fec3a875fea1db87a7202 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 01:56:15 -0400 Subject: [PATCH 054/122] fix(scanArea): preserve legacy child map toggles --- src/features/scanArea/ScanAreaTile.jsx | 37 +++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/features/scanArea/ScanAreaTile.jsx b/src/features/scanArea/ScanAreaTile.jsx index cf655700d..dbb501eec 100644 --- a/src/features/scanArea/ScanAreaTile.jsx +++ b/src/features/scanArea/ScanAreaTile.jsx @@ -67,8 +67,43 @@ function ScanArea(featureCollection) { const hasSome = areaKeys.some((area) => effectiveSelectedAreas.includes(area), ) + const legacyGroupKey = layer.feature.properties.parent + ? featureCollection.features.find( + (feature) => + !feature.properties.manual && + !feature.properties.parent && + feature.properties.name === + layer.feature.properties.parent && + feature.properties.key, + )?.properties.key + : undefined + let nextAreaKeys = areaKeys + let unselectAll = areaKeys.length > 1 && hasSome + + if (legacyGroupKey && selectedAreas.includes(legacyGroupKey)) { + const siblingAreaKeys = featureCollection.features + .filter( + (feature) => + !feature.properties.manual && + feature.properties.parent === + layer.feature.properties.parent && + feature.properties.key !== layer.feature.properties.key, + ) + .map((feature) => feature.properties.key) + nextAreaKeys = [ + legacyGroupKey, + ...(selectedAreas.includes(layer.feature.properties.key) + ? [layer.feature.properties.key] + : []), + ...siblingAreaKeys.filter( + (key) => !selectedAreas.includes(key), + ), + ] + unselectAll = false + } + layer.setStyle({ fillOpacity: hasSome ? 0.2 : 0.8 }) - setAreas(areaKeys, validAreaKeys, hasSome) + setAreas(nextAreaKeys, validAreaKeys, unselectAll) } }, }} From 70d0332ad0480a110890d5becbb5f42e05a21b5e Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 02:06:34 -0400 Subject: [PATCH 055/122] fix(scanArea): migrate legacy keys on hydration --- src/features/scanArea/LegacyMigration.jsx | 50 ----------------------- src/features/scanArea/index.js | 1 - src/features/scanArea/utils.js | 11 +++++ src/pages/map/components/Container.jsx | 2 - src/store/useStorage.js | 49 ++++++++++++++++++++++ 5 files changed, 60 insertions(+), 53 deletions(-) delete mode 100644 src/features/scanArea/LegacyMigration.jsx diff --git a/src/features/scanArea/LegacyMigration.jsx b/src/features/scanArea/LegacyMigration.jsx deleted file mode 100644 index b2cbed0f8..000000000 --- a/src/features/scanArea/LegacyMigration.jsx +++ /dev/null @@ -1,50 +0,0 @@ -// @ts-check -import * as React from 'react' -import { useQuery } from '@apollo/client' - -import { Query } from '@services/queries' -import { useStorage } from '@store/useStorage' - -import { migrateLegacyAreaKeys } from './utils' - -export function LegacyScanAreaMigration() { - const selectedAreas = useStorage( - (s) => s.filters.scanAreas?.filter?.areas || [], - ) - const { data } = useQuery(Query.scanAreasMenu(), { - skip: !selectedAreas.length, - }) - - const features = React.useMemo( - () => - data?.scanAreasMenu.flatMap((parent) => [ - ...(parent.details ? [parent.details] : []), - ...parent.children, - ]) || [], - [data], - ) - const migratedAreas = React.useMemo( - () => - features.length ? migrateLegacyAreaKeys(features, selectedAreas) : null, - [features, selectedAreas], - ) - - React.useEffect(() => { - if (!migratedAreas?.length) return - - useStorage.setState((prev) => ({ - filters: { - ...prev.filters, - scanAreas: { - ...prev.filters.scanAreas, - filter: { - ...prev.filters.scanAreas?.filter, - areas: migratedAreas, - }, - }, - }, - })) - }, [migratedAreas]) - - return null -} diff --git a/src/features/scanArea/index.js b/src/features/scanArea/index.js index e98531894..5fcd577bf 100644 --- a/src/features/scanArea/index.js +++ b/src/features/scanArea/index.js @@ -1,4 +1,3 @@ // @ts-check -export * from './LegacyMigration' export * from './ScanAreaTile' diff --git a/src/features/scanArea/utils.js b/src/features/scanArea/utils.js index e6882e18e..32a413cc1 100644 --- a/src/features/scanArea/utils.js +++ b/src/features/scanArea/utils.js @@ -1,5 +1,16 @@ // @ts-check +/** + * @param {import('@rm/types').Config['areas']['scanAreasMenu']} scanAreasMenu + * @returns {Pick[]} + */ +export function getScanAreaMenuFeatures(scanAreasMenu = []) { + return scanAreasMenu.flatMap((parent) => [ + ...(parent?.details ? [parent.details] : []), + ...(parent?.children || []), + ]) +} + /** * @param {Pick[]} features * @param {Pick} feature diff --git a/src/pages/map/components/Container.jsx b/src/pages/map/components/Container.jsx index 674621994..ffacef9a8 100644 --- a/src/pages/map/components/Container.jsx +++ b/src/pages/map/components/Container.jsx @@ -6,7 +6,6 @@ import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useMapStore } from '@store/useMapStore' import { ScanOnDemand } from '@features/scanner' -import { LegacyScanAreaMigration } from '@features/scanArea' import { WebhookMarker, WebhookAreaSelection } from '@features/webhooks' import { ActiveWeather } from '@features/weather' import { timeCheck } from '@utils/timeCheck' @@ -58,7 +57,6 @@ export function Container() { preferCanvas > - diff --git a/src/store/useStorage.js b/src/store/useStorage.js index 4ce66c4fb..a4976b78b 100644 --- a/src/store/useStorage.js +++ b/src/store/useStorage.js @@ -6,6 +6,54 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import { setDeep } from '@utils/setDeep' +import { + getScanAreaMenuFeatures, + migrateLegacyAreaKeys, +} from '../features/scanArea/utils' + +/** + * @param {Partial | undefined} filters + * @returns {Partial | undefined} + */ +function migrateStoredScanAreaFilters(filters) { + const selectedAreas = filters?.scanAreas?.filter?.areas || [] + if (!selectedAreas.length || typeof CONFIG === 'undefined') { + return filters + } + + const features = getScanAreaMenuFeatures(CONFIG.areas.scanAreasMenu) + const migratedAreas = migrateLegacyAreaKeys(features, selectedAreas) + if (!migratedAreas) { + return filters + } + + return { + ...filters, + scanAreas: { + ...filters.scanAreas, + filter: { + ...filters.scanAreas?.filter, + areas: migratedAreas, + }, + }, + } +} + +function mergePersistedState(persistedState, currentState) { + if (!persistedState || typeof persistedState !== 'object') { + return currentState + } + + return { + ...currentState, + ...persistedState, + filters: migrateStoredScanAreaFilters( + /** @type {{ filters?: Partial }} */ ( + persistedState + ).filters || currentState.filters, + ), + } +} /** * @typedef {{ @@ -174,6 +222,7 @@ export const useStorage = create( { name: 'local-state', storage: createJSONStorage(() => localStorage), + merge: mergePersistedState, }, ), ) From f7c97c1171eba271603665f1c1c3e22b94a89fc1 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 31 Mar 2026 02:15:44 -0400 Subject: [PATCH 056/122] fix(scanArea): gate legacy filter migration --- src/features/scanArea/LegacyMigration.jsx | 52 +++++++++++++++++++ src/features/scanArea/index.js | 1 + src/pages/map/components/Container.jsx | 61 ++++++++++++----------- src/store/useStorage.js | 49 ------------------ 4 files changed, 85 insertions(+), 78 deletions(-) create mode 100644 src/features/scanArea/LegacyMigration.jsx diff --git a/src/features/scanArea/LegacyMigration.jsx b/src/features/scanArea/LegacyMigration.jsx new file mode 100644 index 000000000..0f4ce58da --- /dev/null +++ b/src/features/scanArea/LegacyMigration.jsx @@ -0,0 +1,52 @@ +// @ts-check +import * as React from 'react' +import { useQuery } from '@apollo/client' + +import { Query } from '@services/queries' +import { useStorage } from '@store/useStorage' + +import { getScanAreaMenuFeatures, migrateLegacyAreaKeys } from './utils' + +/** @param {{ children: React.ReactNode }} props */ +export function LegacyScanAreaGate({ children }) { + const selectedAreas = useStorage( + (s) => s.filters.scanAreas?.filter?.areas || [], + ) + const hasSelectedAreas = selectedAreas.length > 0 + const { data, error } = useQuery(Query.scanAreasMenu(), { + skip: !hasSelectedAreas, + }) + + const features = React.useMemo( + () => getScanAreaMenuFeatures(data?.scanAreasMenu || []), + [data], + ) + const migratedAreas = React.useMemo( + () => + features.length ? migrateLegacyAreaKeys(features, selectedAreas) : null, + [features, selectedAreas], + ) + + React.useEffect(() => { + if (!migratedAreas?.length) return + + useStorage.setState((prev) => ({ + filters: { + ...prev.filters, + scanAreas: { + ...prev.filters.scanAreas, + filter: { + ...prev.filters.scanAreas?.filter, + areas: migratedAreas, + }, + }, + }, + })) + }, [migratedAreas]) + + if (hasSelectedAreas && !error && (!data || migratedAreas)) { + return null + } + + return children +} diff --git a/src/features/scanArea/index.js b/src/features/scanArea/index.js index 5fcd577bf..e98531894 100644 --- a/src/features/scanArea/index.js +++ b/src/features/scanArea/index.js @@ -1,3 +1,4 @@ // @ts-check +export * from './LegacyMigration' export * from './ScanAreaTile' diff --git a/src/pages/map/components/Container.jsx b/src/pages/map/components/Container.jsx index ffacef9a8..38c3926a7 100644 --- a/src/pages/map/components/Container.jsx +++ b/src/pages/map/components/Container.jsx @@ -5,6 +5,7 @@ import { MapContainer } from 'react-leaflet' import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { useMapStore } from '@store/useMapStore' +import { LegacyScanAreaGate } from '@features/scanArea' import { ScanOnDemand } from '@features/scanner' import { WebhookMarker, WebhookAreaSelection } from '@features/webhooks' import { ActiveWeather } from '@features/weather' @@ -39,34 +40,36 @@ export function Container() { const { location, zoom } = useStorage.getState() return ( - { - if (ref) { - const { attributionPrefix } = useMemory.getState().config.general - ref.attributionControl.setPrefix(attributionPrefix || '') - ref.on('moveend', setLocationZoom) - ref.on('zoomend', setLocationZoom) - } - useMapStore.setState({ map: ref }) - }} - zoom={zoom} - zoomControl={false} - maxBounds={MAX_BOUNDS} - preferCanvas - > - - - - - - - - - -