diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx
index c191915..351f59f 100644
--- a/packages/web/src/App.jsx
+++ b/packages/web/src/App.jsx
@@ -69,12 +69,20 @@ export function App() {
} />
} />
} />
+ } />
+ } />
} />
+ } />
+ } />
} />
{user.role === "admin" ? (
<>
} />
+ } />
+ } />
} />
+ } />
+ } />
} />
} />
} />
@@ -82,7 +90,9 @@ export function App() {
) : (
<>
} />
+ } />
} />
+ } />
} />
} />
} />
diff --git a/packages/web/src/components/SecretEditorModal.jsx b/packages/web/src/components/SecretEditorModal.jsx
new file mode 100644
index 0000000..3d1644e
--- /dev/null
+++ b/packages/web/src/components/SecretEditorModal.jsx
@@ -0,0 +1,127 @@
+import { useState } from "react";
+import { errorMessage } from "../api/client.js";
+import { useOwners, useUpsertSecret } from "../api/hooks.js";
+
+/**
+ * Add / replace an encrypted secret.
+ * Reusable: mount when open; parent supplies mode + initial fields.
+ *
+ * @param {"add" | "replace"} mode
+ * @param {{ owner: string, name?: string }} initial
+ * @param {() => void} onClose
+ * @param {(saved: unknown) => void} [onSaved]
+ * @param {boolean} [lockOwner]
+ */
+export function SecretEditorModal({
+ mode,
+ initial,
+ onClose,
+ onSaved,
+ lockOwner = false,
+}) {
+ const { data: owners = [] } = useOwners();
+ const upsert = useUpsertSecret();
+ const [form, setForm] = useState(() => ({
+ owner: initial.owner || owners[0] || "default",
+ name: initial.name || "",
+ value: "",
+ }));
+
+ function onSubmit(e) {
+ e.preventDefault();
+ upsert.mutate(
+ { owner: form.owner, name: form.name, value: form.value },
+ {
+ onSuccess: (data) => {
+ onSaved?.(data?.secret ?? data);
+ onClose();
+ },
+ },
+ );
+ }
+
+ const title = mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`;
+
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/UserEditorModal.jsx b/packages/web/src/components/UserEditorModal.jsx
new file mode 100644
index 0000000..6a1d828
--- /dev/null
+++ b/packages/web/src/components/UserEditorModal.jsx
@@ -0,0 +1,120 @@
+import { useState } from "react";
+import { errorMessage } from "../api/client.js";
+import { useCreateUser, useUpdateUser } from "../api/hooks.js";
+
+/**
+ * Add / edit a local user.
+ * Reusable: mount when open; pass `user` for edit.
+ *
+ * @param {"add" | "edit"} mode
+ * @param {{ id: string, username: string, role: string }} [user]
+ * @param {() => void} onClose
+ * @param {(saved: unknown) => void} [onSaved]
+ */
+export function UserEditorModal({ mode, user, onClose, onSaved }) {
+ const create = useCreateUser();
+ const update = useUpdateUser();
+ const [form, setForm] = useState(() => ({
+ username: user?.username || "",
+ password: "",
+ role: user?.role || "operator",
+ id: user?.id || null,
+ }));
+
+ const mutation = mode === "add" ? create : update;
+
+ function onSubmit(e) {
+ e.preventDefault();
+ if (mode === "add") {
+ create.mutate(
+ {
+ username: form.username,
+ password: form.password,
+ role: form.role,
+ },
+ {
+ onSuccess: (data) => {
+ onSaved?.(data?.user ?? data);
+ onClose();
+ },
+ },
+ );
+ return;
+ }
+ const body = { id: form.id, role: form.role };
+ if (form.password) body.password = form.password;
+ update.mutate(body, {
+ onSuccess: (data) => {
+ onSaved?.(data?.user ?? data);
+ onClose();
+ },
+ });
+ }
+
+ const title = mode === "add" ? "New user" : form.username;
+
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/VariableEditorModal.jsx b/packages/web/src/components/VariableEditorModal.jsx
new file mode 100644
index 0000000..4f54ea3
--- /dev/null
+++ b/packages/web/src/components/VariableEditorModal.jsx
@@ -0,0 +1,191 @@
+import { useState } from "react";
+import { errorMessage } from "../api/client.js";
+import { useOwners, useUpsertVariable } from "../api/hooks.js";
+
+const TYPES = ["string", "number", "boolean"];
+
+function defaultValue(type) {
+ if (type === "boolean") return false;
+ if (type === "number") return "";
+ return "";
+}
+
+/**
+ * Add / edit a plaintext workflow variable.
+ * Reusable: mount when open; parent supplies mode + initial fields.
+ *
+ * @param {"add" | "edit"} mode
+ * @param {{ owner: string, name?: string, type?: string, value?: string | number | boolean }} initial
+ * @param {() => void} onClose
+ * @param {(saved: unknown) => void} [onSaved]
+ * @param {boolean} [lockOwner] When true, owner cannot be changed (add mode).
+ */
+export function VariableEditorModal({
+ mode,
+ initial,
+ onClose,
+ onSaved,
+ lockOwner = false,
+}) {
+ const { data: owners = [] } = useOwners();
+ const upsert = useUpsertVariable();
+ const [form, setForm] = useState(() => ({
+ owner: initial.owner || owners[0] || "default",
+ name: initial.name || "",
+ type: initial.type || "string",
+ value:
+ initial.type === "number"
+ ? String(initial.value ?? "")
+ : initial.value !== undefined
+ ? initial.value
+ : defaultValue(initial.type || "string"),
+ }));
+ const [formError, setFormError] = useState(null);
+
+ function onTypeChange(type) {
+ setForm({ ...form, type, value: defaultValue(type) });
+ }
+
+ function onSubmit(e) {
+ e.preventDefault();
+ let value = form.value;
+ if (form.type === "number") {
+ value = Number(form.value);
+ if (!Number.isFinite(value)) {
+ setFormError("value must be a finite number");
+ return;
+ }
+ }
+ if (form.type === "boolean") {
+ value = form.value === true;
+ }
+ setFormError(null);
+ upsert.mutate(
+ { owner: form.owner, name: form.name, type: form.type, value },
+ {
+ onSuccess: (data) => {
+ onSaved?.(data?.variable ?? data);
+ onClose();
+ },
+ },
+ );
+ }
+
+ const title = mode === "add" ? "New variable" : `Edit ${form.owner}/${form.name}`;
+
+ return (
+
+ );
+}
diff --git a/packages/web/src/pages/SecretsPage.jsx b/packages/web/src/pages/SecretsPage.jsx
index dfff63c..2442948 100644
--- a/packages/web/src/pages/SecretsPage.jsx
+++ b/packages/web/src/pages/SecretsPage.jsx
@@ -1,54 +1,80 @@
-import { useState } from "react";
-import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
+import { useEffect, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
+import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
-import {
- useDeleteSecret,
- useOwners,
- useSecrets,
- useUpsertSecret,
-} from "../api/hooks.js";
+import { useDeleteSecret, useOwners, useSecrets } from "../api/hooks.js";
+import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
import { formatTime } from "../lib/format.jsx";
export function SecretsPage() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { owner: routeOwner, name: routeName } = useParams();
+ const [params] = useSearchParams();
+ const isNewRoute = /\/secrets\/new\/?$/.test(location.pathname);
+ const isEditRoute = Boolean(routeOwner && routeName);
+
const { data: owners = [] } = useOwners();
- const [ownerFilter, setOwnerFilter] = useState("");
+ const [ownerFilter, setOwnerFilter] = useState(
+ () => routeOwner || params.get("owner") || "",
+ );
const { data: secrets = [], isLoading } = useSecrets(ownerFilter || undefined);
- const upsert = useUpsertSecret();
const del = useDeleteSecret();
- const [mode, setMode] = useState(null);
- const [form, setForm] = useState({
- owner: "",
- name: "",
- value: "",
- });
+ const [editor, setEditor] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
+ const [highlightName, setHighlightName] = useState(() => routeName || "");
+ const highlightRef = useRef(null);
+ const openedRouteKey = useRef(null);
- function openAdd() {
- setMode("add");
- setForm({
- owner: ownerFilter || owners[0] || "default",
- name: "",
- value: "",
+ const listPath = ownerFilter
+ ? `/secrets?owner=${encodeURIComponent(ownerFilter)}`
+ : "/secrets";
+
+ function closeEditor() {
+ setEditor(null);
+ openedRouteKey.current = null;
+ if (isNewRoute || isEditRoute) {
+ navigate(listPath, { replace: true });
+ }
+ }
+
+ useEffect(() => {
+ if (!isNewRoute) return;
+ const key = `new:${params.get("owner") || ""}:${params.get("name") || ""}`;
+ if (openedRouteKey.current === key) return;
+ if (!params.get("owner") && !ownerFilter && owners.length === 0) return;
+ const owner = params.get("owner") || ownerFilter || owners[0] || "default";
+ if (params.get("owner")) setOwnerFilter(params.get("owner"));
+ openedRouteKey.current = key;
+ setEditor({
+ mode: "add",
+ initial: { owner, name: params.get("name") || "" },
});
- }
+ }, [isNewRoute, params, owners, ownerFilter]);
- function openReplace(s) {
- setMode("replace");
- setForm({ owner: s.owner, name: s.name, value: "" });
- }
+ useEffect(() => {
+ if (!isEditRoute) {
+ if (!isNewRoute) openedRouteKey.current = null;
+ return;
+ }
+ if (ownerFilter !== routeOwner) {
+ setOwnerFilter(routeOwner);
+ return;
+ }
+ const key = `edit:${routeOwner}/${routeName}`;
+ if (openedRouteKey.current === key) return;
+ openedRouteKey.current = key;
+ setHighlightName(routeName);
+ setEditor({
+ mode: "replace",
+ initial: { owner: routeOwner, name: routeName },
+ });
+ }, [isEditRoute, isNewRoute, routeOwner, routeName, ownerFilter]);
- function closeForm() {
- setMode(null);
- setForm({ owner: "", name: "", value: "" });
- }
-
- function onSubmit(e) {
- e.preventDefault();
- upsert.mutate(
- { owner: form.owner, name: form.name, value: form.value },
- { onSuccess: closeForm },
- );
- }
+ useEffect(() => {
+ if (!highlightName || isLoading) return;
+ highlightRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
+ }, [highlightName, isLoading, secrets]);
return (
@@ -58,7 +84,16 @@ export function SecretsPage() {
-
)}
- {mode ? (
-
+ {editor ? (
+ {
+ setHighlightName(saved?.name || editor.initial.name);
+ }}
+ />
) : null}
{confirmDelete ? (
@@ -201,7 +196,9 @@ export function SecretsPage() {
Delete {confirmDelete.owner}/{confirmDelete.name}?
- This cannot be undone. Workflows that retrieve this name will fail.
+
+ This cannot be undone. Workflows that retrieve this name will fail.
+
{del.isError ? (
{errorMessage(del.error)}
) : null}
diff --git a/packages/web/src/pages/UsersPage.jsx b/packages/web/src/pages/UsersPage.jsx
index bc4dd47..179f74b 100644
--- a/packages/web/src/pages/UsersPage.jsx
+++ b/packages/web/src/pages/UsersPage.jsx
@@ -1,66 +1,64 @@
-import { useState } from "react";
-import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
+import { useEffect, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
-import {
- useCreateUser,
- useDeleteUser,
- useUpdateUser,
- useUsers,
-} from "../api/hooks.js";
+import { useDeleteUser, useUsers } from "../api/hooks.js";
+import { UserEditorModal } from "../components/UserEditorModal.jsx";
export function UsersPage() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { username: routeUsername } = useParams();
+ const isNewRoute = /\/users\/new\/?$/.test(location.pathname);
+ const isEditRoute = Boolean(routeUsername) && !isNewRoute;
+
const { data: users = [], isLoading } = useUsers();
- const create = useCreateUser();
- const update = useUpdateUser();
const del = useDeleteUser();
- const [mode, setMode] = useState(null);
- const [form, setForm] = useState({
- username: "",
- password: "",
- role: "operator",
- id: null,
- });
+ const [editor, setEditor] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
+ const openedRouteKey = useRef(null);
- function openAdd() {
- setMode("add");
- setForm({ username: "", password: "", role: "operator", id: null });
- }
-
- function openEdit(u) {
- setMode("edit");
- setForm({ username: u.username, password: "", role: u.role, id: u.id });
- }
-
- function closeForm() {
- setMode(null);
- }
-
- function onSubmit(e) {
- e.preventDefault();
- if (mode === "add") {
- create.mutate(
- {
- username: form.username,
- password: form.password,
- role: form.role,
- },
- { onSuccess: closeForm },
- );
- } else {
- const body = { id: form.id, role: form.role };
- if (form.password) body.password = form.password;
- update.mutate(body, { onSuccess: closeForm });
+ function closeEditor() {
+ setEditor(null);
+ openedRouteKey.current = null;
+ if (isNewRoute || isEditRoute) {
+ navigate("/users", { replace: true });
}
}
- const mutation = mode === "add" ? create : update;
+ useEffect(() => {
+ if (!isNewRoute) return;
+ if (openedRouteKey.current === "new") return;
+ openedRouteKey.current = "new";
+ setEditor({ mode: "add" });
+ }, [isNewRoute]);
+
+ useEffect(() => {
+ if (!isEditRoute) {
+ if (!isNewRoute) openedRouteKey.current = null;
+ return;
+ }
+ if (isLoading) return;
+ const key = `edit:${routeUsername}`;
+ if (openedRouteKey.current === key) return;
+ openedRouteKey.current = key;
+ const user = users.find((u) => u.username === routeUsername);
+ if (!user) {
+ setEditor({ mode: "add" });
+ return;
+ }
+ setEditor({ mode: "edit", user });
+ }, [isEditRoute, isNewRoute, routeUsername, isLoading, users]);
return (
Users
-
+ navigate("/users/new")}
+ >
Add
@@ -90,7 +88,9 @@ export function UsersPage() {
type="button"
className="btn btn-ghost btn-xs"
title="Edit"
- onClick={() => openEdit(u)}
+ onClick={() =>
+ navigate(`/users/${encodeURIComponent(u.username)}/edit`)
+ }
>
@@ -110,53 +110,12 @@ export function UsersPage() {
)}
- {mode ? (
-
+ {editor ? (
+
) : null}
{confirmDelete ? (
diff --git a/packages/web/src/pages/VariablesPage.jsx b/packages/web/src/pages/VariablesPage.jsx
index 1b65f3b..b4488a1 100644
--- a/packages/web/src/pages/VariablesPage.jsx
+++ b/packages/web/src/pages/VariablesPage.jsx
@@ -1,95 +1,110 @@
-import { useState } from "react";
-import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
+import { useEffect, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
+import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
-import {
- useDeleteVariable,
- useOwners,
- useUpsertVariable,
- useVariables,
-} from "../api/hooks.js";
+import { useDeleteVariable, useOwners, useVariables } from "../api/hooks.js";
+import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
import { formatTime } from "../lib/format.jsx";
-const TYPES = ["string", "number", "boolean"];
-
-function defaultValue(type) {
- if (type === "boolean") return false;
- if (type === "number") return "";
- return "";
-}
-
function displayValue(value) {
if (typeof value === "string") return value === "" ? '""' : value;
return String(value);
}
export function VariablesPage() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { owner: routeOwner, name: routeName } = useParams();
+ const [params] = useSearchParams();
+ const isNewRoute = /\/variables\/new\/?$/.test(location.pathname);
+ const isEditRoute = Boolean(routeOwner && routeName);
+
const { data: owners = [] } = useOwners();
- const [ownerFilter, setOwnerFilter] = useState("");
+ const [ownerFilter, setOwnerFilter] = useState(
+ () => routeOwner || params.get("owner") || "",
+ );
const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined);
- const upsert = useUpsertVariable();
const del = useDeleteVariable();
- const [mode, setMode] = useState(null);
- const [form, setForm] = useState({
- owner: "",
- name: "",
- type: "string",
- value: "",
- });
- const [formError, setFormError] = useState(null);
+ const [editor, setEditor] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
+ const [highlightName, setHighlightName] = useState(() => routeName || "");
+ const highlightRef = useRef(null);
+ const openedRouteKey = useRef(null);
- function openAdd() {
- setMode("add");
- setFormError(null);
- setForm({
- owner: ownerFilter || owners[0] || "default",
- name: "",
- type: "string",
- value: "",
- });
- }
+ const listPath = ownerFilter
+ ? `/variables?owner=${encodeURIComponent(ownerFilter)}`
+ : "/variables";
- function openEdit(row) {
- setMode("edit");
- setFormError(null);
- setForm({
- owner: row.owner,
- name: row.name,
- type: row.type,
- value: row.type === "number" ? String(row.value) : row.value,
- });
- }
-
- function closeForm() {
- setMode(null);
- setFormError(null);
- setForm({ owner: "", name: "", type: "string", value: "" });
- }
-
- function onTypeChange(type) {
- setForm({ ...form, type, value: defaultValue(type) });
- }
-
- function onSubmit(e) {
- e.preventDefault();
- let value = form.value;
- if (form.type === "number") {
- value = Number(form.value);
- if (!Number.isFinite(value)) {
- setFormError("value must be a finite number");
- return;
- }
+ function closeEditor() {
+ setEditor(null);
+ openedRouteKey.current = null;
+ if (isNewRoute || isEditRoute) {
+ navigate(listPath, { replace: true });
}
- if (form.type === "boolean") {
- value = form.value === true;
- }
- setFormError(null);
- upsert.mutate(
- { owner: form.owner, name: form.name, type: form.type, value },
- { onSuccess: closeForm },
- );
}
+ useEffect(() => {
+ if (!isNewRoute) return;
+ const key = `new:${params.get("owner") || ""}:${params.get("name") || ""}`;
+ if (openedRouteKey.current === key) return;
+ if (!params.get("owner") && !ownerFilter && owners.length === 0) return;
+ const owner = params.get("owner") || ownerFilter || owners[0] || "default";
+ if (params.get("owner")) setOwnerFilter(params.get("owner"));
+ openedRouteKey.current = key;
+ setEditor({
+ mode: "add",
+ initial: {
+ owner,
+ name: params.get("name") || "",
+ type: "string",
+ value: "",
+ },
+ });
+ }, [isNewRoute, params, owners, ownerFilter]);
+
+ useEffect(() => {
+ if (!isEditRoute) {
+ if (!isNewRoute) openedRouteKey.current = null;
+ return;
+ }
+ if (ownerFilter !== routeOwner) {
+ setOwnerFilter(routeOwner);
+ return;
+ }
+ if (isLoading) return;
+ const key = `edit:${routeOwner}/${routeName}`;
+ if (openedRouteKey.current === key) return;
+ openedRouteKey.current = key;
+ setHighlightName(routeName);
+ const row = variables.find((v) => v.owner === routeOwner && v.name === routeName);
+ if (row) {
+ setEditor({
+ mode: "edit",
+ initial: {
+ owner: row.owner,
+ name: row.name,
+ type: row.type,
+ value: row.value,
+ },
+ });
+ return;
+ }
+ setEditor({
+ mode: "add",
+ initial: {
+ owner: routeOwner,
+ name: routeName,
+ type: "string",
+ value: "",
+ },
+ });
+ }, [isEditRoute, isNewRoute, routeOwner, routeName, ownerFilter, isLoading, variables]);
+
+ useEffect(() => {
+ if (!highlightName || isLoading) return;
+ highlightRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
+ }, [highlightName, isLoading, variables]);
+
return (
@@ -98,7 +113,16 @@ export function VariablesPage() {
-
+
+ navigate(
+ ownerFilter
+ ? `/variables/new?owner=${encodeURIComponent(ownerFilter)}`
+ : "/variables/new",
+ )
+ }
+ >
Add
@@ -132,144 +166,66 @@ export function VariablesPage() {
- {variables.map((row) => (
-
- | {row.owner} |
- {row.name} |
- {row.type} |
-
- {displayValue(row.value)}
- |
- {formatTime(row.updated_at)} |
-
- openEdit(row)}
+ {variables.map((row) => {
+ const highlighted =
+ highlightName &&
+ row.name === highlightName &&
+ (!ownerFilter || row.owner === ownerFilter);
+ return (
+
+ | {row.owner} |
+ {row.name} |
+ {row.type} |
+
-
-
- setConfirmDelete(row)}
- >
-
-
- |
-
- ))}
+ {displayValue(row.value)}
+ |
+ {formatTime(row.updated_at)} |
+
+
+ navigate(
+ `/variables/${encodeURIComponent(row.owner)}/${encodeURIComponent(row.name)}/edit`,
+ )
+ }
+ >
+
+
+ setConfirmDelete(row)}
+ >
+
+
+ |
+
+ );
+ })}
)}
- {mode ? (
-
+ {editor ? (
+
{
+ setHighlightName(saved?.name || editor.initial.name);
+ }}
+ />
) : null}
{confirmDelete ? (