The script library was gated on applications.*, so anyone holding the software-delivery grant could rewrite the bytes of a script that a separately trusted operator later pushed to a bay. The push was gated on endpoints.runscript; the CONTENT of what got pushed was not. An installer is a vendor artefact, but a script here is arbitrary PowerShell this site wrote that the agent runs as SYSTEM, so the write and the push must require the same trust. All six script routes now require endpoints.runscript, including the body download - reading the bytes is not a lesser act than listing them. Core names the permission as a string rather than importing it from the endpoints plugin so the module stays importable with the plugin absent. This tightens access and there is deliberately no backfill: the operator scope reaches applications.create/edit, so operator-scope holders lose the library. Granting them endpoints.runscript on upgrade would undo the change and hand every operator remote SYSTEM code execution, since the same grant gates the push. A test pins that no prebuilt scope confers it. Adds the System Scripts settings page and card. The route requires the permission rather than admin, because the grant is meant for non-admin operators and the sibling admin gate would lock them out of the library the grant exists for. The settings icon map gains terminal and key; the agent-key card had been asking for key since it shipped and silently drawing a puzzle piece. The library had no test coverage before this.
110 lines
3.8 KiB
JavaScript
110 lines
3.8 KiB
JavaScript
// Settings catalog: the static core groups (settingsNav.js) merged with the
|
|
// plugin-contributed cards from the ADR-010 get_settings_cards hook, served by
|
|
// GET /api/pluginui/settings-cards. Both SettingsLayout (rail) and SettingsIndex
|
|
// (landing) read the merged catalog so plugins add settings cards with no core
|
|
// edit. Fetched once into a module-level ref and shared across callers.
|
|
import { ref, computed } from 'vue'
|
|
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets, Terminal, KeyRound } from 'lucide-vue-next'
|
|
import api from '../api'
|
|
import router from '../router'
|
|
import { settingsGroups } from '../views/settings/settingsNav'
|
|
|
|
// Icon string keys (as returned by the hook) mapped to Lucide components,
|
|
// same idea as the sidebar iconMap. Unknown keys fall back to a puzzle piece.
|
|
const iconMap = {
|
|
ruler: Ruler,
|
|
wrench: Wrench,
|
|
cog: Cog,
|
|
package: Package,
|
|
settings: Settings,
|
|
puzzle: Puzzle,
|
|
sliders: SlidersHorizontal,
|
|
palette: Palette,
|
|
bell: Bell,
|
|
network: Network,
|
|
printer: Printer,
|
|
droplets: Droplets,
|
|
terminal: Terminal,
|
|
// The endpoints agent-key card has asked for 'key' since it shipped and has
|
|
// been silently drawing a puzzle piece the whole time.
|
|
key: KeyRound,
|
|
}
|
|
|
|
function resolveIcon(key) {
|
|
return iconMap[key] || Puzzle
|
|
}
|
|
|
|
// Merge plugin cards into a fresh copy of the core groups. A card joins the
|
|
// group whose title matches its `group`; a new group is appended at the end.
|
|
function mergeCards(baseGroups, cards) {
|
|
const merged = baseGroups.map(group => ({
|
|
title: group.title,
|
|
cards: [...group.cards],
|
|
}))
|
|
const byTitle = new Map(merged.map(group => [group.title, group]))
|
|
|
|
for (const card of cards) {
|
|
const entry = {
|
|
to: card.to,
|
|
icon: resolveIcon(card.icon),
|
|
title: card.title,
|
|
description: card.description || '',
|
|
position: card.position ?? 99,
|
|
}
|
|
let group = byTitle.get(card.group)
|
|
if (!group) {
|
|
group = { title: card.group, cards: [] }
|
|
byTitle.set(card.group, group)
|
|
merged.push(group)
|
|
}
|
|
group.cards.push(entry)
|
|
}
|
|
|
|
// Order plugin cards within a group by position; core cards keep their order.
|
|
for (const group of merged) {
|
|
group.cards.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
|
}
|
|
return merged
|
|
}
|
|
|
|
// Drop cards whose target route is not registered in THIS build. settingsNav
|
|
// hardcodes plugin settings (PC Access Protocols, Machine Types, VLANs, ...),
|
|
// but a lean per-site build only stages the chosen plugins' settings routes;
|
|
// showing a card whose route was never staged lands the admin on a blank page.
|
|
// Filtering by the router's own route table gates every menu the same way with
|
|
// no per-plugin logic. Called inside a computed so the (circular) router import
|
|
// is resolved by the time it runs, and so full builds keep every card.
|
|
function keepRegistered(baseGroups) {
|
|
const registered = new Set(router.getRoutes().map(r => r.path))
|
|
return baseGroups
|
|
.map(group => ({
|
|
title: group.title,
|
|
cards: group.cards.filter(card => registered.has(card.to)),
|
|
}))
|
|
.filter(group => group.cards.length)
|
|
}
|
|
|
|
// Shared across component instances: seeded with the core groups, replaced with
|
|
// the merged catalog once the hook endpoint answers.
|
|
const rawGroups = ref(settingsGroups)
|
|
const groups = computed(() => keepRegistered(rawGroups.value))
|
|
let loaded = false
|
|
|
|
async function loadCatalog() {
|
|
try {
|
|
const response = await api.get('/pluginui/settings-cards')
|
|
rawGroups.value = mergeCards(settingsGroups, response.data.data || [])
|
|
} catch (error) {
|
|
// Degrade to the core-only catalog; the rail still works without plugins.
|
|
rawGroups.value = settingsGroups
|
|
}
|
|
}
|
|
|
|
export function useSettingsCatalog() {
|
|
if (!loaded) {
|
|
loaded = true
|
|
loadCatalog()
|
|
}
|
|
return { groups }
|
|
}
|