Make changing engine-run code superuser-only

Writing node source, installing modules and any flow save that adds,
retypes or repoints a code node (Function, Command), edits a Command
node's settings or changes a node's resources.env now answer 403 for
everyone but a superuser. MCP tools and the assistant reach the same
routes, so they inherit the gate. Node types report runs_code.

The editor shows code read-only with a note, disables a Command node's
settings, leaves code nodes and reusable nodes out of the palette and
paste, and the Modules page offers no install.
This commit is contained in:
2026-10-06 11:36:30 +02:00
parent 2914d860b1
commit 6802cd2203
19 changed files with 391 additions and 26 deletions
+16
View File
@@ -444,3 +444,19 @@ def get_current_active_superuser(current_user: CurrentUser) -> User:
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user
#: Why a non-superuser is refused a change to what the engine executes.
CODE_IS_SUPERUSERS = "Only a superuser can change code the engine runs"
def get_code_author(current_user: CurrentUser) -> User:
"""A superuser, the only account that may change code the engine runs.
Node code, a command, an installed package: each runs as the engine, with
its signing key and every secret in reach, so whoever writes one holds
the instance whatever their account says.
"""
if not current_user.is_superuser:
raise HTTPException(status_code=403, detail=CODE_IS_SUPERUSERS)
return current_user
+56 -2
View File
@@ -19,16 +19,18 @@ from pydantic import BaseModel
from sqlmodel import Session, col, select
from fluksio.api.deps import (
CODE_IS_SUPERUSERS,
CurrentUser,
FlowControllerDep,
SessionDep,
decode_token,
get_code_author,
get_current_user,
user_from_token,
)
from fluksio.core.db import engine
from fluksio.flow.artifacts import is_reference
from fluksio.flow.controller import FlowController
from fluksio.flow.controller import NODE_TYPES, FlowController
from fluksio.flow.dashboards import DashboardStore
from fluksio.flow.events import event_bus
from fluksio.flow.messages import flow_of, qualify
@@ -46,6 +48,7 @@ from fluksio.flow.schemas import (
LibraryNode,
MessageHistory,
MessageValue,
NodeDef,
NodeSource,
NodeStatusPublic,
NodeTypeInfo,
@@ -377,11 +380,56 @@ def _check_flavors(definition: FlowDef) -> None:
)
def _worker_env(node: NodeDef | None) -> dict[str, str]:
return node.resources.env if node is not None and node.resources else {}
def _changes_code(node: NodeDef, old: NodeDef | None) -> bool:
"""Whether saving *node* over *old* changes code the engine would run.
A function node's body has its own route, gated there. What the document
carries is which nodes run code at all, which shared source each runs, the
command in an exec node's settings, and the environment a node's worker
starts with — where `PYTHONPATH` alone is enough to import a file of
somebody's choosing.
"""
if _worker_env(old) != _worker_env(node):
return True
spec = NODE_TYPES.get(node.type)
if spec is None or not spec.runs_code:
return False
return (
old is None
or old.type != node.type
or old.source_ref != node.source_ref
or (not spec.has_source and old.params != node.params)
)
def _refuse_code_changes(
controller: FlowController, name: str, definition: FlowDef
) -> None:
"""Refuse a non-superuser a change to code this flow would run.
Wiring, titles, a function node's settings and every other node stay
anyone's.
"""
before = _read_flow(controller, name) if controller.store.exists(name) else None
previous = {node.id: node for node in before.nodes} if before else {}
for node in definition.nodes:
if _changes_code(node, previous.get(node.id)):
raise HTTPException(
status_code=403,
detail=f"{CODE_IS_SUPERUSERS} (node '{node.id}')",
)
@router.put("/{name}", response_model=FlowDetail)
async def save_flow(
name: str,
definition: FlowDef,
controller: FlowControllerDep,
user: CurrentUser,
) -> Any:
"""Save unpublished changes to a flow.
@@ -400,6 +448,8 @@ async def save_flow(
raise HTTPException(status_code=400, detail="Node names must be unique")
_check_flavors(definition)
if not user.is_superuser:
_refuse_code_changes(controller, name, definition)
try:
stored = await run_in_threadpool(
@@ -548,7 +598,11 @@ def read_node_source(
)
@router.put("/{name}/nodes/{node_id}/source", response_model=NodeStatusPublic)
@router.put(
"/{name}/nodes/{node_id}/source",
response_model=NodeStatusPublic,
dependencies=[Depends(get_code_author)],
)
async def save_node_source(
name: str,
node_id: str,
+6 -1
View File
@@ -16,6 +16,7 @@ from fluksio.api.deps import (
CurrentUser,
FlowControllerDep,
WorkerPoolDep,
get_code_author,
get_current_user,
)
from fluksio.flow import modules
@@ -34,7 +35,9 @@ async def read_modules(controller: FlowControllerDep) -> Any:
return await run_in_threadpool(modules.info, controller.store)
@router.post("/apply", response_model=ApplyResult)
@router.post(
"/apply", response_model=ApplyResult, dependencies=[Depends(get_code_author)]
)
async def apply_modules(
body: ApplyRequest,
controller: FlowControllerDep,
@@ -43,6 +46,8 @@ async def apply_modules(
) -> Any:
"""Install exactly these requirements, then hand them to the workers.
A superuser's: a package runs its own code on install and on import.
A manifest that does not resolve changes nothing: the venv is left as it
was and the stored manifest is only written once the install succeeded.
+6
View File
@@ -303,6 +303,9 @@ class NodeType:
# Constructor signatures differ per node type.
cls: Any
has_source: bool = False
#: Runs code its definition supplies — a source file, or a command among
#: its settings — with everything the engine can reach.
runs_code: bool = False
free_params: bool = False
params_schema: dict[str, Any] = field(default_factory=dict)
#: Which installed package supplied this type, for the ones that are not
@@ -321,6 +324,7 @@ NODE_TYPES: dict[str, NodeType] = {
description="Your own Python code, run on every incoming message.",
cls=Node,
has_source=True,
runs_code=True,
free_params=True,
),
"mqtt": NodeType(
@@ -399,6 +403,7 @@ NODE_TYPES: dict[str, NodeType] = {
title="Command",
description="Run a command in the engine's container and read its output.",
cls=ExecNode,
runs_code=True,
params_schema=_schema_of(ExecNode),
),
"file": NodeType(
@@ -424,6 +429,7 @@ def node_type_info() -> list[NodeTypeInfo]:
description=spec.description,
params_schema=spec.params_schema,
has_source=spec.has_source,
runs_code=spec.runs_code,
free_params=spec.free_params,
plugin=spec.plugin,
)
+4
View File
@@ -476,6 +476,10 @@ class NodeTypeInfo(BaseModel):
description: str
params_schema: dict[str, Any] = Field(default_factory=dict)
has_source: bool = False
#: Whether a node of this type runs code its definition supplies: its
#: source, or a command among its settings. Only a superuser may add one
#: or change that code.
runs_code: bool = False
#: Whether this type takes settings beyond the ones its schema declares.
#: A function node's settings are its author's to name, and reach `process`
#: as keyword arguments beside its ports.
@@ -0,0 +1,138 @@
"""Code the engine runs is a superuser's to change; everything else is not."""
from collections.abc import Callable
from typing import Any
import pytest
from fastapi.testclient import TestClient
from fluksio.api.deps import CODE_IS_SUPERUSERS
from fluksio.api.routes import modules as modules_route
from fluksio.core.config import settings
PREFIX = f"{settings.API_V1_STR}/flows"
FLOW = "code_rights"
def a_flow(version: int, **changes: Any) -> dict[str, Any]:
nodes: dict[str, dict[str, Any]] = {
"fn": {
"id": "fn",
"type": "python",
"params": {"gain": 1},
"provides": [{"name": "reading", "dtype": "float"}],
},
"cmd": {"id": "cmd", "type": "exec", "params": {"command": "echo hi"}},
"hold": {"id": "hold", "type": "delay", "params": {"delay": 1}},
}
for node_id, patch in changes.items():
nodes[node_id] = {**nodes.get(node_id, {"id": node_id}), **patch}
return {"name": FLOW, "version": version, "nodes": list(nodes.values())}
@pytest.fixture
def save(
client: TestClient, superuser_token_headers: dict[str, str]
) -> Callable[..., Any]:
"""Seed the flow as a superuser, then save a change as someone else."""
client.delete(f"{PREFIX}/{FLOW}", headers=superuser_token_headers)
seeded = client.put(
f"{PREFIX}/{FLOW}", headers=superuser_token_headers, json=a_flow(0)
)
assert seeded.status_code == 200, seeded.text
version = seeded.json()["definition"]["version"]
def put(headers: dict[str, str], **changes: Any) -> Any:
return client.put(
f"{PREFIX}/{FLOW}", headers=headers, json=a_flow(version, **changes)
)
return put
@pytest.mark.parametrize(
"changes",
[
{"new": {"type": "python"}},
{"new": {"type": "exec", "params": {"command": "id"}}},
{"cmd": {"params": {"command": "cat /proc/self/environ"}}},
{"hold": {"type": "exec", "params": {"command": "id"}}},
{"fn": {"source_ref": "someone_elses"}},
{"fn": {"resources": {"env": {"PYTHONPATH": "/data/files"}}}},
],
ids=["add-function", "add-exec", "exec-command", "retype", "repoint", "env"],
)
def test_a_user_cannot_change_code_in_a_flow(
save: Callable[..., Any],
normal_user_token_headers: dict[str, str],
superuser_token_headers: dict[str, str],
changes: dict[str, Any],
) -> None:
refused = save(normal_user_token_headers, **changes)
assert refused.status_code == 403
assert refused.json()["detail"].startswith(CODE_IS_SUPERUSERS)
assert save(superuser_token_headers, **changes).status_code == 200
def test_a_user_keeps_everything_but_the_code(
save: Callable[..., Any], normal_user_token_headers: dict[str, str]
) -> None:
saved = save(
normal_user_token_headers,
fn={
"title": "Renamed",
"params": {"gain": 2},
"requires": [{"name": "trigger", "dtype": "float"}],
},
hold={"params": {"delay": 5}},
late={"type": "delay", "params": {"delay": 2}},
)
assert saved.status_code == 200, saved.text
@pytest.mark.usefixtures("save")
def test_node_source_is_a_superusers_to_write(
client: TestClient,
normal_user_token_headers: dict[str, str],
superuser_token_headers: dict[str, str],
) -> None:
url = f"{PREFIX}/{FLOW}/nodes/fn/source"
body = {"code": "def process():\n return {'reading': 1.0}\n"}
refused = client.put(url, headers=normal_user_token_headers, json=body)
assert refused.status_code == 403
assert refused.json()["detail"] == CODE_IS_SUPERUSERS
# Reading it is still anyone's.
assert client.get(url, headers=normal_user_token_headers).status_code == 200
saved = client.put(url, headers=superuser_token_headers, json=body)
assert saved.status_code == 200, saved.text
def test_installing_modules_is_a_superusers(
client: TestClient,
monkeypatch: pytest.MonkeyPatch,
normal_user_token_headers: dict[str, str],
superuser_token_headers: dict[str, str],
) -> None:
installs: list[str] = []
def sync(requirements: str) -> tuple[bool, str]:
installs.append(requirements)
return True, ""
async def retire(*_: Any) -> None:
return None
monkeypatch.setattr(modules_route.modules, "sync", sync)
monkeypatch.setattr(modules_route, "_retire", retire)
url = f"{settings.API_V1_STR}/modules/apply"
body = {"requirements": ""}
refused = client.post(url, headers=normal_user_token_headers, json=body)
assert refused.status_code == 403
assert refused.json()["detail"] == CODE_IS_SUPERUSERS
assert installs == []
assert client.post(url, headers=superuser_token_headers, json=body).is_success
assert installs == [""]
+30
View File
@@ -1,6 +1,7 @@
"""The MCP endpoint: who it lets in, and what happens once they are in."""
import asyncio
import json
import uuid
from collections.abc import Awaitable, Callable, Generator
from datetime import timedelta
@@ -11,6 +12,7 @@ import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session
from fluksio.api.deps import CODE_IS_SUPERUSERS
from fluksio.core import security
from fluksio.core.config import settings
from fluksio.main import app
@@ -170,3 +172,31 @@ def test_revoking_an_agent_stops_the_token_it_already_holds(
before, after = over_mcp(block)
assert before.status_code == 200
assert after.status_code == 401
def test_an_agent_cannot_write_code_its_person_may_not(
over_mcp,
client: TestClient,
normal_user_token_headers: dict[str, str],
agent: uuid.UUID,
) -> None:
"""The tools call the same routes, so an agent inherits the code gate."""
me = client.get(
f"{settings.API_V1_STR}/users/me", headers=normal_user_token_headers
).json()
assert me["is_superuser"] is False
token = mcp_token(uuid.UUID(me["id"]), agent)
async def block(http: httpx.AsyncClient) -> httpx.Response:
return await http.post(
"/mcp",
**rpc(
token,
"tools/call",
name="save_node_source",
arguments={"name": "any", "node_id": "fn", "code": "import os"},
),
)
answer = json.loads(over_mcp(block).json()["result"]["content"][0]["text"])
assert answer == {"error": CODE_IS_SUPERUSERS, "status": 403}
+7 -1
View File
@@ -62,6 +62,12 @@ curl -X PUT $FLUKSIO/flows/house/nodes/decide/source -H "Authorization: Bearer $
-H 'Content-Type: application/json' -d '{"code": "def process(x):\n return {\"y\": x * 2}\n"}'
```
Writing a source is a superuser's, and so is any `PUT /flows/{name}` that
adds a Function or Command node, retypes or repoints one, changes a Command
node's settings, or changes any node's `resources.env`: each of them is code
the engine runs. Anyone else gets a 403 and keeps the rest — wiring, titles,
a Function node's settings and every other node.
`POST .../share` promotes a node's source to the shared library;
`POST .../unshare` gives it a private copy back. `GET /flows/library` lists the
shared sources and which flows use each.
@@ -197,7 +203,7 @@ curl -X POST $FLUKSIO/alerts/test/my-phone -H "Authorization: Bearer $TOKEN"
```
Secrets are write-only over the API: you can list names and set values, never
read one back.
read one back. Secrets and `POST /modules/apply` are a superuser's.
## Observability
+2
View File
@@ -297,6 +297,8 @@ helper a node calls into is reported as that node changing (`train: updated
(flow, fit)`) and re-runs it, while editing something the node never reaches
is left alone.
Uploading node code takes a superuser's token; anyone else's sync is refused.
A sync that changed nothing says `unchanged`, which is the answer worth
having. `published` and `draft` are said only when there was something to
publish or a draft was genuinely left behind. An engine too old to store what
+4
View File
@@ -80,6 +80,10 @@ reloads without the flow stopping. A node file defines `process(...)`; if it
defines exactly one public function under another name, that one is used
instead.
Code runs as the engine, so changing it is a superuser's. Anyone else sees the
editor read-only, a Command node's settings disabled, and no Function, Command
or reusable nodes to add.
**Shared** turns a node's code into a library entry other flows can reuse.
Editing a shared node edits the copy every flow using it runs, which is the
point and also the caution.
+3 -2
View File
@@ -39,7 +39,8 @@ Node code runs in a virtual environment of its own, on the instance's data
volume, separate from the packages Fluksio itself runs on. A pin of yours can
never shadow one of ours, and vice versa.
**Modules** is a pip manifest and an **Apply** button. It is installed with
**Modules** is a pip manifest and an **Apply** button, which only a superuser
sees: a package runs its own code on install. It is installed with
`uv pip sync`, so a line you delete is uninstalled rather than left behind.
The manifest is versioned alongside your flows, so what a deployment installed
is recorded with what uses it.
@@ -114,7 +115,7 @@ is a superuser.
Accounts that arrived through a portal are badged *Portal*. They have no
password (they sign in at the portal, not here) and they are never
superusers. Deleting one cuts their access immediately and independently of the
superusers, so they read node code but cannot change it. Deleting one cuts their access immediately and independently of the
portal, so it works even if the portal cannot be reached. See
[Accounts and the portal](portal.md).
+1
View File
@@ -244,6 +244,7 @@ The cheapest fix for a sensor that reports every second and changes every hour.
### Command
**`exec`** — run a command in the engine's container and read its output.
Adding one or changing its settings is a superuser's, since the command is code.
| Setting | Default | Notes |
|---|---|---|
+5
View File
@@ -2573,6 +2573,11 @@ export const NodeTypeInfoSchema = {
title: 'Has Source',
default: false
},
runs_code: {
type: 'boolean',
title: 'Runs Code',
default: false
},
free_params: {
type: 'boolean',
title: 'Free Params',
+2
View File
@@ -1478,6 +1478,8 @@ export class ModulesService {
* Apply Modules
* Install exactly these requirements, then hand them to the workers.
*
* A superuser's: a package runs its own code on install and on import.
*
* A manifest that does not resolve changes nothing: the venv is left as it
* was and the stored manifest is only written once the install succeeded.
*
+1
View File
@@ -888,6 +888,7 @@ export type NodeTypeInfo = {
[key: string]: unknown;
};
has_source?: boolean;
runs_code?: boolean;
free_params?: boolean;
plugin?: (string | null);
};
@@ -30,13 +30,14 @@ export function CommandPalette({
nodeTypes: NodeTypeInfo[]
flows: FlowSummary[]
onAddNode: (type: string) => void
onAddSharedNode: (libName: string) => void
/** Absent for an account that may not add code, which a shared node is. */
onAddSharedNode?: (libName: string) => void
onRun: () => void
}) {
const navigate = useNavigate()
const { data: libraryData } = useQuery({
...libraryQueryOptions(),
enabled: open,
enabled: open && Boolean(onAddSharedNode),
})
// Picking an item re-renders the canvas, which can interrupt the dialog's
@@ -44,7 +45,7 @@ export function CommandPalette({
// dialog outright is deterministic; the palette does not need to fade out.
if (!open) return null
const library = libraryData ?? []
const library = onAddSharedNode ? (libraryData ?? []) : []
const close = (action: () => void) => {
onOpenChange(false)
@@ -85,7 +86,7 @@ export function CommandPalette({
<CommandItem
key={entry.name}
value={`shared ${entry.name}`}
onSelect={() => close(() => onAddSharedNode(entry.name))}
onSelect={() => close(() => onAddSharedNode?.(entry.name))}
>
<span className="flex flex-col">
<span className="font-mono">{entry.name}</span>
+42 -5
View File
@@ -42,6 +42,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { useIsMobile } from "@/hooks/useMobile"
import { scaleIn } from "@/lib/motion"
@@ -367,6 +368,22 @@ function FlowEditorInner({
[nodeTypeInfo],
)
// Which types run code of their own, a source or a command. Adding one is a
// superuser's, so nobody else is offered one.
const { user } = useAuth()
const mayEditCode = Boolean(user?.is_superuser)
const lockedTypes = useMemo(
() =>
new Set(
mayEditCode
? []
: (nodeTypeInfo ?? [])
.filter((info) => info.runs_code)
.map((info) => info.type),
),
[nodeTypeInfo, mayEditCode],
)
// Which types came from an installed connector rather than the engine. They
// cannot be in the icon map, so they share one.
const pluginTypes = useMemo(
@@ -1042,9 +1059,18 @@ function FlowEditorInner({
return
}
// A node that runs code is a superuser's to add, so anyone else pastes
// the rest without it.
const nodes = (clipboard.nodes ?? []).filter(
(node) => !lockedTypes.has(node.type ?? "python"),
)
if (nodes.length < (clipboard.nodes ?? []).length) {
notify("Only a superuser can add nodes that run code.", "error")
}
let pool = definitions
const pasted: NodeDef_Input[] = []
for (const node of clipboard.nodes ?? []) {
for (const node of nodes) {
const copy = { ...node, id: uniqueNodeId(pool, node.id) }
pool = [...pool, copy]
pasted.push(copy)
@@ -1063,13 +1089,20 @@ function FlowEditorInner({
commit(pool)
setSelectedId(pasted[pasted.length - 1].id)
clipboard.nodes.forEach((node, index) => {
nodes.forEach((node, index) => {
const code = clipboard.sources?.[node.id]
if (code !== undefined) {
sourceMutation.mutate({ nodeId: pasted[index].id, code })
}
})
}, [canvasNodes, commit, definitions, setCanvasNodes, sourceMutation.mutate])
}, [
canvasNodes,
commit,
definitions,
lockedTypes,
setCanvasNodes,
sourceMutation.mutate,
])
useShortcuts(
{
@@ -1335,10 +1368,14 @@ function FlowEditorInner({
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
nodeTypes={nodeTypeInfo ?? []}
nodeTypes={(nodeTypeInfo ?? []).filter(
(info) => !lockedTypes.has(info.type),
)}
flows={flows.data}
onAddNode={addNode}
onAddSharedNode={(libName) => addNode("python", libName)}
onAddSharedNode={
mayEditCode ? (libName) => addNode("python", libName) : undefined
}
onRun={startRun}
/>
+51 -10
View File
@@ -42,6 +42,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
@@ -1204,6 +1205,9 @@ const PLACEHOLDER: Record<DType, string> = {
video: 'fluksio.save_artifact(b"", "clip.mp4", media_type="video/mp4")',
}
/** Why a code editor or an exec node's settings are read-only. */
const CODE_LOCKED = "Only a superuser can change code the engine runs."
const SCAFFOLD_DOC =
'"""A new node. Return a dict keyed by your output ports."""'
@@ -1293,6 +1297,12 @@ function PanelBody({
...nodeSourceQueryOptions(flow, node.id),
enabled: hasSource,
})
// Code the engine runs is a superuser's to change; the server refuses
// anyone else, so the panel shows it read-only rather than failing a save.
const { user } = useAuth()
const mayEditCode = Boolean(user?.is_superuser)
const settingsAreCode =
!mayEditCode && Boolean(nodeType?.runs_code) && !hasSource
const [code, setCode] = useState<string | null>(null)
// The port the X is dropping or its name field renaming, noted just before
@@ -1333,7 +1343,14 @@ function PanelBody({
const editNode = (next: NodeDef_Input) => {
onChange(next)
const current = code ?? source?.code
if (!hasSource || next.source_ref || origin || current === undefined) return
if (
!hasSource ||
!mayEditCode ||
next.source_ref ||
origin ||
current === undefined
)
return
const wanted = scaffoldFor(next)
if (current !== wanted && SCAFFOLD_SHAPE.test(current)) editCode(wanted)
}
@@ -1389,6 +1406,16 @@ function PanelBody({
}
}, [])
const paramsForm = (
<ParamsForm
type={node.type}
schema={nodeType?.params_schema}
params={node.params ?? {}}
provides={node.provides ?? []}
onChange={(params) => editNode({ ...node, params })}
/>
)
return (
<div
className={cn(
@@ -1434,13 +1461,20 @@ function PanelBody({
portMove.current = [portName(spec), null]
}}
/>
<ParamsForm
type={node.type}
schema={nodeType?.params_schema}
params={node.params ?? {}}
provides={node.provides ?? []}
onChange={(params) => editNode({ ...node, params })}
/>
{settingsAreCode ? (
/* An exec node's command is its code, so the whole form is. */
<fieldset disabled className="grid min-w-0 gap-2">
{paramsForm}
<p
className="text-xs text-muted-foreground"
data-testid="node-settings-locked"
>
{CODE_LOCKED}
</p>
</fieldset>
) : (
paramsForm
)}
{nodeType?.free_params ? (
<FreeParamsForm
params={node.params ?? {}}
@@ -1524,7 +1558,14 @@ function PanelBody({
<span className="font-mono">{origin.repo || "a repository"}</span>
. Edit it there and sync again.
</p>
) : null}
) : mayEditCode ? null : (
<p
className="text-xs text-muted-foreground"
data-testid="node-source-locked"
>
{CODE_LOCKED}
</p>
)}
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border">
<Suspense
fallback={
@@ -1534,7 +1575,7 @@ function PanelBody({
<NodeEditor
value={code ?? source?.code ?? ""}
onChange={editCode}
readOnly={Boolean(origin)}
readOnly={Boolean(origin) || !mayEditCode}
/>
</Suspense>
</div>
+12 -1
View File
@@ -6,6 +6,7 @@ import { useState } from "react"
import { type ApiError, ModulesService } from "@/client"
import { InfoTip } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
@@ -32,6 +33,9 @@ function Modules() {
})
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
// A package runs its own code on install, so installing is a superuser's.
const { user } = useAuth()
const mayInstall = Boolean(user?.is_superuser)
// Null until the field is touched, so what is stored shows through until
// someone actually starts editing it.
const [draft, setDraft] = useState<string | null>(null)
@@ -84,7 +88,14 @@ function Modules() {
</p>
</div>
{adopted ? null : (
{adopted ? null : !mayInstall ? (
<p
className="text-sm text-muted-foreground"
data-testid="modules-locked"
>
Only a superuser can install packages.
</p>
) : (
<section className="grid gap-3">
<h2 className={SECTION}>Requirements</h2>
<textarea