feat(server): introduce admin reset functionality and enhance user management

- Added a new `reset-admin` script to reset or create the admin username and password.
- Updated the README to include instructions for resetting the admin login.
- Enhanced user management by allowing username updates during admin reset.
- Defaulted new resources to the internal namespace `local` for better organization.
- Removed unused owner selection from various components to streamline the UI.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
2026-08-22 19:08:52 +07:00
parent 811890443b
commit 71cc705cd2
19 changed files with 271 additions and 484 deletions
+8 -2
View File
@@ -20,7 +20,13 @@ pnpm dev
- UI (dev): http://localhost:8500 - UI (dev): http://localhost:8500
- API: http://localhost:8700 - API: http://localhost:8700
The first account created becomes **admin**. Later accounts are created from Users. The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`.
Reset or create the admin login from the host:
```bash
pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
```
### Process modes ### Process modes
@@ -52,7 +58,7 @@ The first account created becomes **admin**. Later accounts are created from Use
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) | | **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) |
| **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow | | **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow |
- Live YAML is **instance data**, same as SQLite and secrets — not product source. Prefer owner `local` for personal workflows. - Live YAML is **instance data**, same as SQLite and secrets — not product source. New resources use owner `local` (owner remains in storage/URLs for a possible future multi-tenant mode; the UI hides it).
- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`. - On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`.
- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save). - New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save).
- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`. - Override the live store in tests with `JFLOW_WORKFLOWS_DIR`.
+1
View File
@@ -14,6 +14,7 @@
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"", "migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js", "test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
"test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js", "test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
"reset-admin": "node reset-admin.js",
"test:profiles": "node test/profiles-smoke.js", "test:profiles": "node test/profiles-smoke.js",
"test:set-dry-run": "node test/set-dry-run-smoke.js" "test:set-dry-run": "node test/set-dry-run-smoke.js"
}, },
+105
View File
@@ -0,0 +1,105 @@
/**
* Reset (or create) the admin username and password.
*
* Usage:
* pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
*
* Uses JFLOW_DB_PATH like the app. Never prints the password.
*/
import bcrypt from "bcryptjs";
import { db, migrate } from "./db.js";
import * as store from "./store.js";
import { validateCredentials } from "./src/api/auth.js";
function parseArgs(argv) {
/** @type {{ username?: string, password?: string }} */
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--username" || arg === "-u") {
out.username = argv[++i];
continue;
}
if (arg === "--password" || arg === "-p") {
out.password = argv[++i];
continue;
}
if (arg === "--help" || arg === "-h") {
out.help = true;
}
}
return out;
}
function usage() {
console.log(`Usage:
pnpm --dir packages/server reset-admin -- --username <name> --password <secret>
Creates an admin if none exist; otherwise updates the oldest admin's
username and password. Credentials must match login rules
(username 3-32 [A-Za-z0-9_], password at least 8 characters).`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
process.exit(0);
}
const username = typeof args.username === "string" ? args.username.trim() : "";
const password = typeof args.password === "string" ? args.password : "";
if (!username || !password) {
usage();
process.exit(1);
}
const credErr = validateCredentials(username, password);
if (credErr) {
console.error(credErr);
process.exit(1);
}
await migrate();
const passwordHash = await bcrypt.hash(password, 10);
const admins = await db("users")
.where({ role: "admin" })
.orderBy("created_at", "asc")
.select("id", "username");
if (admins.length === 0) {
const user = await store.createUser({
username,
passwordHash,
role: "admin",
});
console.log(`Created admin user "${user.username}" (${user.id})`);
return;
}
const admin = admins[0];
const taken = await store.getUserAuthByUsername(username);
if (taken && taken.id !== admin.id) {
console.error(`username "${username}" is already taken by another user`);
process.exit(1);
}
const updated = await store.updateUser(admin.id, {
username,
passwordHash,
role: "admin",
});
console.log(
`Updated admin "${admin.username}" → "${updated.username}" (${updated.id})`,
);
}
try {
await main();
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
} finally {
await db.destroy();
}
+1 -1
View File
@@ -258,7 +258,7 @@ export default function scriptsPluginFactory(registry) {
req.body ?? {} req.body ?? {}
); );
let owner = "default"; let owner = "local";
if (body.owner != null && body.owner !== "") { if (body.owner != null && body.owner !== "") {
try { try {
owner = fsStore.assertOwner(String(body.owner)); owner = fsStore.assertOwner(String(body.owner));
+2 -1
View File
@@ -582,12 +582,13 @@ export async function listUsers() {
/** /**
* @param {string} id * @param {string} id
* @param {{ passwordHash?: string, role?: string }} patch * @param {{ passwordHash?: string, role?: string, username?: string }} patch
*/ */
export async function updateUser(id, patch) { export async function updateUser(id, patch) {
const update = { updated_at: nowIso() }; const update = { updated_at: nowIso() };
if (patch.passwordHash) update.password_hash = patch.passwordHash; if (patch.passwordHash) update.password_hash = patch.passwordHash;
if (patch.role) update.role = patch.role; if (patch.role) update.role = patch.role;
if (patch.username) update.username = patch.username;
await db("users").where({ id }).update(update); await db("users").where({ id }).update(update);
return getUserById(id); return getUserById(id);
} }
@@ -1,22 +1,19 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useDuplicateWorkflow, useOwners } from "../api/hooks.js"; import { useDuplicateWorkflow } from "../api/hooks.js";
import { useNotifications } from "../notifications.jsx"; import { useNotifications } from "../notifications.jsx";
export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) { export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) {
const { notify } = useNotifications(); const { notify } = useNotifications();
const { data: owners = [] } = useOwners();
const duplicate = useDuplicateWorkflow(); const duplicate = useDuplicateWorkflow();
const [destOwner, setDestOwner] = useState(source.owner);
function onSubmit(e) { function onSubmit(e) {
e.preventDefault(); e.preventDefault();
if (destOwner === source.owner && duplicate.isPending) return; if (duplicate.isPending) return;
duplicate.mutate( duplicate.mutate(
{ {
owner: source.owner, owner: source.owner,
file: source.file, file: source.file,
destOwner, destOwner: source.owner,
}, },
{ {
onSuccess: (data) => { onSuccess: (data) => {
@@ -33,7 +30,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
<h3 className="font-bold">Duplicate {source.key}?</h3> <h3 className="font-bold">Duplicate {source.key}?</h3>
<p className="mt-2 text-sm opacity-70"> <p className="mt-2 text-sm opacity-70">
A new UUID filename is assigned automatically. The copy starts disabled. HTTP paths are A new UUID filename is assigned automatically. The copy starts disabled. HTTP paths are
rewritten when staying under the same owner so triggers do not collide. rewritten so triggers do not collide.
</p> </p>
{warnUnsaved ? ( {warnUnsaved ? (
<p className="text-warning mt-2 text-sm"> <p className="text-warning mt-2 text-sm">
@@ -41,21 +38,6 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
</p> </p>
) : null} ) : null}
<form className="mt-4 space-y-3" onSubmit={onSubmit}> <form className="mt-4 space-y-3" onSubmit={onSubmit}>
<label className="form-control w-full">
<span className="label py-0 text-sm">Owner</span>
<input
className="input input-sm w-full"
list="duplicate-workflow-owners"
value={destOwner}
onChange={(e) => setDestOwner(e.target.value)}
required
/>
<datalist id="duplicate-workflow-owners">
{owners.map((o) => (
<option key={o} value={o} />
))}
</datalist>
</label>
{duplicate.isError ? ( {duplicate.isError ? (
<p className="text-error text-sm">{errorMessage(duplicate.error)}</p> <p className="text-error text-sm">{errorMessage(duplicate.error)}</p>
) : null} ) : null}
-2
View File
@@ -17,7 +17,6 @@ import {
LuShield, LuShield,
LuSun, LuSun,
LuTags, LuTags,
LuUsers,
} from "react-icons/lu"; } from "react-icons/lu";
import { useLogout, useOpsStatus } from "../api/hooks.js"; import { useLogout, useOpsStatus } from "../api/hooks.js";
import { brandMark } from "../theme/brand.js"; import { brandMark } from "../theme/brand.js";
@@ -52,7 +51,6 @@ const navSections = [
items: [ items: [
{ to: "/manage", label: "Manage", icon: LuServer }, { to: "/manage", label: "Manage", icon: LuServer },
{ to: "/backup", label: "Backup", icon: LuArchive }, { to: "/backup", label: "Backup", icon: LuArchive },
{ to: "/users", label: "Users", icon: LuUsers },
], ],
}, },
]; ];
@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useOwners, useScripts, useUpsertProfile } from "../api/hooks.js"; import { useScripts, useUpsertProfile } from "../api/hooks.js";
import { FormInput, FormSelect } from "./FormControls.jsx"; import { FormInput, FormSelect } from "./FormControls.jsx";
import { ConfigFields } from "./workflow/ConfigFields.jsx"; import { ConfigFields } from "./workflow/ConfigFields.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
/** /**
* @param {"add" | "edit"} mode * @param {"add" | "edit"} mode
@@ -16,11 +17,10 @@ import { ConfigFields } from "./workflow/ConfigFields.jsx";
* @param {number} [usageCount] * @param {number} [usageCount]
*/ */
export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount = 0 }) { export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount = 0 }) {
const { data: owners = [] } = useOwners();
const { data: scripts = [] } = useScripts(); const { data: scripts = [] } = useScripts();
const upsert = useUpsertProfile(); const upsert = useUpsertProfile();
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default", owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "", name: initial.name || "",
script: initial.script || "", script: initial.script || "",
config: config:
@@ -72,7 +72,7 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
submit(); submit();
} }
const title = mode === "add" ? "New profile" : `Edit ${form.owner}/${form.name}`; const title = mode === "add" ? "New profile" : `Edit ${form.name}`;
return ( return (
<dialog className="modal modal-open"> <dialog className="modal modal-open">
@@ -80,49 +80,23 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
<h3 className="font-bold">{title}</h3> <h3 className="font-bold">{title}</h3>
<form className="mt-3 space-y-2" onSubmit={onSubmit}> <form className="mt-3 space-y-2" onSubmit={onSubmit}>
{mode === "add" ? ( {mode === "add" ? (
<> <label className="form-control w-full">
<label className="form-control w-full"> <span className="label py-0 text-sm">Name</span>
<span className="label py-0 text-sm">Owner</span> <FormInput
{owners.length > 0 ? ( className="w-full font-mono"
<FormSelect value={form.name}
className="w-full" onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.owner} required
onChange={(e) => setForm({ ...form, owner: e.target.value })} pattern="[A-Za-z0-9._-]+"
required title="Letters, numbers, dots, underscores, hyphens. Cannot be changed later."
> />
{owners.map((o) => ( <span className="label-text-alt opacity-60">
<option key={o} value={o}> YAML id. Locked after create so live workflow refs stay valid.
{o} </span>
</option> </label>
))}
</FormSelect>
) : (
<FormInput
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
/>
)}
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<FormInput
className="w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens. Cannot be changed later."
/>
<span className="label-text-alt opacity-60">
YAML id. Locked after create so live workflow refs stay valid.
</span>
</label>
</>
) : ( ) : (
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">
<span className="font-mono">{form.owner}/{form.name}</span> <span className="font-mono">{form.name}</span>
<span className="ml-2 opacity-60">(name cannot be changed)</span> <span className="ml-2 opacity-60">(name cannot be changed)</span>
</p> </p>
)} )}
@@ -1,30 +1,22 @@
import { useState } from "react"; import { useState } from "react";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useOwners, useUpsertSecret } from "../api/hooks.js"; import { useUpsertSecret } from "../api/hooks.js";
import { FormInput, FormSelect } from "./FormControls.jsx"; import { FormInput } from "./FormControls.jsx";
import { Modal } from "./Modal.jsx"; import { Modal } from "./Modal.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
/** /**
* Add / replace an encrypted secret. * Add / replace an encrypted secret.
* Reusable: mount when open; parent supplies mode + initial fields.
* *
* @param {"add" | "replace"} mode * @param {"add" | "replace"} mode
* @param {{ owner: string, name?: string }} initial * @param {{ owner: string, name?: string }} initial
* @param {() => void} onClose * @param {() => void} onClose
* @param {(saved: unknown) => void} [onSaved] * @param {(saved: unknown) => void} [onSaved]
* @param {boolean} [lockOwner]
*/ */
export function SecretEditorModal({ export function SecretEditorModal({ mode, initial, onClose, onSaved }) {
mode,
initial,
onClose,
onSaved,
lockOwner = false,
}) {
const { data: owners = [] } = useOwners();
const upsert = useUpsertSecret(); const upsert = useUpsertSecret();
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default", owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "", name: initial.name || "",
value: "", value: "",
})); }));
@@ -42,52 +34,24 @@ export function SecretEditorModal({
); );
} }
const title = mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`; const title = mode === "add" ? "New secret" : `Replace ${form.name}`;
return ( return (
<Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}> <Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}>
<h3 className="font-bold">{title}</h3> <h3 className="font-bold">{title}</h3>
<form className="mt-3 space-y-2" onSubmit={onSubmit}> <form className="mt-3 space-y-2" onSubmit={onSubmit}>
{mode === "add" ? ( {mode === "add" ? (
<> <label className="form-control w-full">
<label className="form-control w-full"> <span className="label py-0 text-sm">Name</span>
<span className="label py-0 text-sm">Owner</span> <FormInput
{owners.length > 0 ? ( className="w-full font-mono"
<FormSelect value={form.name}
className="w-full" onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.owner} required
onChange={(e) => setForm({ ...form, owner: e.target.value })} pattern="[A-Za-z0-9._-]+"
required title="Letters, numbers, dots, underscores, hyphens"
disabled={lockOwner} />
> </label>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
) : (
<FormInput
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
disabled={lockOwner}
/>
)}
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<FormInput
className="w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens"
/>
</label>
</>
) : null} ) : null}
<label className="form-control w-full"> <label className="form-control w-full">
<span className="label py-0 text-sm">Value</span> <span className="label py-0 text-sm">Value</span>
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useOwners, useUpsertVariable } from "../api/hooks.js"; import { useUpsertVariable } from "../api/hooks.js";
import { FormInput, FormSelect, FormTextarea } from "./FormControls.jsx"; import { FormInput, FormSelect, FormTextarea } from "./FormControls.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
const TYPES = ["string", "number", "boolean"]; const TYPES = ["string", "number", "boolean"];
@@ -13,25 +14,16 @@ function defaultValue(type) {
/** /**
* Add / edit a plaintext workflow variable. * Add / edit a plaintext workflow variable.
* Reusable: mount when open; parent supplies mode + initial fields.
* *
* @param {"add" | "edit"} mode * @param {"add" | "edit"} mode
* @param {{ owner: string, name?: string, type?: string, value?: string | number | boolean }} initial * @param {{ owner: string, name?: string, type?: string, value?: string | number | boolean }} initial
* @param {() => void} onClose * @param {() => void} onClose
* @param {(saved: unknown) => void} [onSaved] * @param {(saved: unknown) => void} [onSaved]
* @param {boolean} [lockOwner] When true, owner cannot be changed (add mode).
*/ */
export function VariableEditorModal({ export function VariableEditorModal({ mode, initial, onClose, onSaved }) {
mode,
initial,
onClose,
onSaved,
lockOwner = false,
}) {
const { data: owners = [] } = useOwners();
const upsert = useUpsertVariable(); const upsert = useUpsertVariable();
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default", owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "", name: initial.name || "",
type: initial.type || "string", type: initial.type || "string",
value: value:
@@ -72,7 +64,7 @@ export function VariableEditorModal({
); );
} }
const title = mode === "add" ? "New variable" : `Edit ${form.owner}/${form.name}`; const title = mode === "add" ? "New variable" : `Edit ${form.name}`;
return ( return (
<dialog className="modal modal-open"> <dialog className="modal modal-open">
@@ -80,45 +72,17 @@ export function VariableEditorModal({
<h3 className="font-bold">{title}</h3> <h3 className="font-bold">{title}</h3>
<form className="mt-3 space-y-2" onSubmit={onSubmit}> <form className="mt-3 space-y-2" onSubmit={onSubmit}>
{mode === "add" ? ( {mode === "add" ? (
<> <label className="form-control w-full">
<label className="form-control w-full"> <span className="label py-0 text-sm">Name</span>
<span className="label py-0 text-sm">Owner</span> <FormInput
{owners.length > 0 ? ( className="w-full font-mono"
<FormSelect value={form.name}
className="w-full" onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.owner} required
onChange={(e) => setForm({ ...form, owner: e.target.value })} pattern="[A-Za-z0-9._-]+"
required title="Letters, numbers, dots, underscores, hyphens"
disabled={lockOwner} />
> </label>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
) : (
<FormInput
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
disabled={lockOwner}
/>
)}
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<FormInput
className="w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens"
/>
</label>
</>
) : null} ) : null}
<label className="form-control w-full"> <label className="form-control w-full">
<span className="label py-0 text-sm">Type</span> <span className="label py-0 text-sm">Type</span>
+2
View File
@@ -0,0 +1,2 @@
/** Default owner folder for new resources (latent tenant id). */
export const DEFAULT_OWNER = "local";
+1 -16
View File
@@ -1,5 +1,5 @@
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { useOwners, useRuns } from "../api/hooks.js"; import { useRuns } from "../api/hooks.js";
import { formatTime, StatusBadge } from "../lib/format"; import { formatTime, StatusBadge } from "../lib/format";
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
@@ -47,7 +47,6 @@ export function EventsPage() {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const workflow = params.get("workflow") || ""; const workflow = params.get("workflow") || "";
const status = params.get("status") || ""; const status = params.get("status") || "";
const owner = params.get("owner") || "";
const trigger = params.get("trigger") || ""; const trigger = params.get("trigger") || "";
const afterDate = params.get("after") || ""; const afterDate = params.get("after") || "";
const beforeDate = params.get("before") || ""; const beforeDate = params.get("before") || "";
@@ -57,11 +56,9 @@ export function EventsPage() {
const sort = params.get("sort") || "started_at"; const sort = params.get("sort") || "started_at";
const order = params.get("order") === "asc" ? "asc" : "desc"; const order = params.get("order") === "asc" ? "asc" : "desc";
const { data: owners = [] } = useOwners();
const { data, isLoading } = useRuns({ const { data, isLoading } = useRuns({
workflow: workflow || undefined, workflow: workflow || undefined,
status: status || undefined, status: status || undefined,
owner: owner || undefined,
trigger: trigger || undefined, trigger: trigger || undefined,
after: dateToAfterIso(afterDate), after: dateToAfterIso(afterDate),
before: dateToBeforeIso(beforeDate), before: dateToBeforeIso(beforeDate),
@@ -116,18 +113,6 @@ export function EventsPage() {
<option value="failed">failed</option> <option value="failed">failed</option>
<option value="skipped">skipped</option> <option value="skipped">skipped</option>
</select> </select>
<select
className="select select-sm w-full sm:max-w-xs"
value={owner}
onChange={(e) => update("owner", e.target.value)}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<select <select
className="select select-sm w-full sm:max-w-xs" className="select select-sm w-full sm:max-w-xs"
value={trigger} value={trigger}
+30 -77
View File
@@ -2,13 +2,13 @@ import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useDeleteProfile, useOwners, useProfileUsage, useProfiles } from "../api/hooks.js"; import { useDeleteProfile, useProfileUsage, useProfiles } from "../api/hooks.js";
import { FormSelect } from "../components/FormControls.jsx";
import { Modal } from "../components/Modal.jsx"; import { Modal } from "../components/Modal.jsx";
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx"; import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx"; import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format"; import { formatTime } from "../lib/format";
import { DEFAULT_OWNER } from "../lib/tenant.js";
function previewConfig(config) { function previewConfig(config) {
if (!config || typeof config !== "object") return ""; if (!config || typeof config !== "object") return "";
@@ -37,50 +37,32 @@ export function ProfilesPage() {
const isNewRoute = /\/profiles\/new\/?$/.test(location.pathname); const isNewRoute = /\/profiles\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeOwner && routeName); const isEditRoute = Boolean(routeOwner && routeName);
const { data: owners = [] } = useOwners(); const { data: profiles = [], isLoading } = useProfiles();
const [ownerFilter, setOwnerFilter] = useState(
() => routeOwner || params.get("owner") || "",
);
const { data: profiles = [], isLoading } = useProfiles(ownerFilter || undefined);
const del = useDeleteProfile(); const del = useDeleteProfile();
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || ""); const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null); const highlightRef = useRef(null);
const listPath = ownerFilter const listPath = "/profiles";
? `/profiles?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles";
const { editor, closeEditor } = useRouteDrivenModal({ const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute, isNewRoute,
isEditRoute, isEditRoute,
listPath, listPath,
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`, newRouteKey: () => `new:${params.get("name") || ""}:${params.get("script") || ""}`,
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0), canOpenNew: () => true,
onBeforeOpenNew: () => { buildNewEditor: () => ({
if (params.get("owner")) setOwnerFilter(params.get("owner")); mode: "add",
}, initial: {
buildNewEditor: () => { owner: DEFAULT_OWNER,
const owner = params.get("owner") || ownerFilter || owners[0] || "default"; name: params.get("name") || "",
return { script: params.get("script") || "",
mode: "add", config: {},
initial: { description: "",
owner, },
name: params.get("name") || "", }),
script: params.get("script") || "",
config: {},
description: "",
},
};
},
editRouteKey: () => `edit:${routeOwner}/${routeName}`, editRouteKey: () => `edit:${routeOwner}/${routeName}`,
canOpenEdit: () => { canOpenEdit: () => !isLoading,
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return false;
}
return !isLoading;
},
onOpenEdit: () => setHighlightName(routeName), onOpenEdit: () => setHighlightName(routeName),
buildEditEditor: () => { buildEditEditor: () => {
const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName); const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName);
@@ -100,7 +82,7 @@ export function ProfilesPage() {
return { return {
mode: "add", mode: "add",
initial: { initial: {
owner: routeOwner, owner: routeOwner || DEFAULT_OWNER,
name: routeName, name: routeName,
script: "", script: "",
config: {}, config: {},
@@ -108,8 +90,8 @@ export function ProfilesPage() {
}, },
}; };
}, },
newDeps: [params, owners, ownerFilter], newDeps: [params],
editDeps: [routeOwner, routeName, ownerFilter, isLoading, profiles], editDeps: [routeOwner, routeName, isLoading, profiles],
}); });
useEffect(() => { useEffect(() => {
@@ -121,42 +103,14 @@ export function ProfilesPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Profiles</h1> <h1 className="text-xl font-semibold">Profiles</h1>
<div className="flex gap-2"> <button
<FormSelect type="button"
value={ownerFilter} className="btn btn-primary btn-sm"
onChange={(e) => { onClick={() => navigate("/profiles/new")}
setOwnerFilter(e.target.value); >
setHighlightName(""); <LuPlus className="size-4" />
navigate( Add
e.target.value </button>
? `/profiles?owner=${encodeURIComponent(e.target.value)}`
: "/profiles",
{ replace: true },
);
}}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/profiles/new?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles/new",
)
}
>
<LuPlus className="size-4" />
Add
</button>
</div>
</div> </div>
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">
@@ -174,7 +128,7 @@ export function ProfilesPage() {
const highlighted = const highlighted =
highlightName && highlightName &&
row.name === highlightName && row.name === highlightName &&
(!ownerFilter || row.owner === ownerFilter); (!routeOwner || row.owner === routeOwner);
const preview = previewConfig(row.config); const preview = previewConfig(row.config);
return ( return (
<article <article
@@ -191,7 +145,6 @@ export function ProfilesPage() {
<h2 className="font-mono font-semibold truncate" title={row.name}> <h2 className="font-mono font-semibold truncate" title={row.name}>
{row.name} {row.name}
</h2> </h2>
<p className="text-xs opacity-60 font-mono truncate">{row.owner}</p>
</div> </div>
</div> </div>
<p className="text-xs font-mono opacity-80 truncate" title={row.script}> <p className="text-xs font-mono opacity-80 truncate" title={row.script}>
@@ -275,7 +228,7 @@ function DeleteProfileDialog({ profile, del, onClose }) {
const usage = useProfileUsage(profile.id, true); const usage = useProfileUsage(profile.id, true);
const usages = usage.data ?? []; const usages = usage.data ?? [];
const used = usages.length > 0; const used = usages.length > 0;
const title = `Delete ${profile.owner}/${profile.name}?`; const title = `Delete ${profile.name}?`;
return ( return (
<Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}> <Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}>
+2 -19
View File
@@ -4,7 +4,6 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { import {
useDryRunScript, useDryRunScript,
useOwners,
useSaveScript, useSaveScript,
useScript, useScript,
} from "../api/hooks.js"; } from "../api/hooks.js";
@@ -19,6 +18,7 @@ import {
contextFromMeta, contextFromMeta,
prettyJson, prettyJson,
} from "../lib/script.js"; } from "../lib/script.js";
import { DEFAULT_OWNER } from "../lib/tenant.js";
import { useNotifications } from "../notifications.jsx"; import { useNotifications } from "../notifications.jsx";
export function ScriptDryRunPage() { export function ScriptDryRunPage() {
@@ -31,8 +31,6 @@ export function ScriptDryRunPage() {
const existing = useScript(name, stateContent == null); const existing = useScript(name, stateContent == null);
const dryRun = useDryRunScript(); const dryRun = useDryRunScript();
const save = useSaveScript(); const save = useSaveScript();
const { data: owners = [] } = useOwners();
const [owner, setOwner] = useState("default");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT); const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT);
@@ -126,7 +124,7 @@ export function ScriptDryRunPage() {
data: ctx.data, data: ctx.data,
context: ctx.context, context: ctx.context,
config: ctx.config, config: ctx.config,
owner, owner: DEFAULT_OWNER,
}); });
} }
@@ -151,21 +149,6 @@ export function ScriptDryRunPage() {
<span className="text-sm opacity-60">{lastRun.durationMs}ms</span> <span className="text-sm opacity-60">{lastRun.durationMs}ms</span>
) : null} ) : null}
<div className="flex-1" /> <div className="flex-1" />
<label className="flex items-center gap-1 text-sm">
<span className="opacity-60 hidden sm:inline">Owner</span>
<select
className="select select-sm"
value={owner}
onChange={(e) => setOwner(e.target.value)}
>
{owners.includes(owner) ? null : <option value={owner}>{owner}</option>}
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<button <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
+31 -75
View File
@@ -2,12 +2,12 @@ import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useDeleteSecret, useOwners, useSecrets } from "../api/hooks.js"; import { useDeleteSecret, useSecrets } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx";
import { SecretEditorModal } from "../components/SecretEditorModal.jsx"; import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format"; import { formatTime } from "../lib/format";
import { DEFAULT_OWNER } from "../lib/tenant.js";
export function SecretsPage() { export function SecretsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,51 +17,36 @@ export function SecretsPage() {
const isNewRoute = /\/secrets\/new\/?$/.test(location.pathname); const isNewRoute = /\/secrets\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeOwner && routeName); const isEditRoute = Boolean(routeOwner && routeName);
const { data: owners = [] } = useOwners(); const { data: secrets = [], isLoading } = useSecrets();
const [ownerFilter, setOwnerFilter] = useState(
() => routeOwner || params.get("owner") || "",
);
const { data: secrets = [], isLoading } = useSecrets(ownerFilter || undefined);
const del = useDeleteSecret(); const del = useDeleteSecret();
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || ""); const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null); const highlightRef = useRef(null);
const listPath = ownerFilter const listPath = "/secrets";
? `/secrets?owner=${encodeURIComponent(ownerFilter)}`
: "/secrets";
const { editor, closeEditor } = useRouteDrivenModal({ const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute, isNewRoute,
isEditRoute, isEditRoute,
listPath, listPath,
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`, newRouteKey: () => `new:${params.get("name") || ""}`,
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0), canOpenNew: () => true,
onBeforeOpenNew: () => { buildNewEditor: () => ({
if (params.get("owner")) setOwnerFilter(params.get("owner")); mode: "add",
}, initial: {
buildNewEditor: () => { owner: DEFAULT_OWNER,
const owner = params.get("owner") || ownerFilter || owners[0] || "default"; name: params.get("name") || "",
return { },
mode: "add", }),
initial: { owner, name: params.get("name") || "" },
};
},
editRouteKey: () => `edit:${routeOwner}/${routeName}`, editRouteKey: () => `edit:${routeOwner}/${routeName}`,
canOpenEdit: () => { canOpenEdit: () => true,
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return false;
}
return true;
},
onOpenEdit: () => setHighlightName(routeName), onOpenEdit: () => setHighlightName(routeName),
buildEditEditor: () => ({ buildEditEditor: () => ({
mode: "replace", mode: "replace",
initial: { owner: routeOwner, name: routeName }, initial: { owner: routeOwner, name: routeName },
}), }),
newDeps: [params, owners, ownerFilter], newDeps: [params],
editDeps: [routeOwner, routeName, ownerFilter], editDeps: [routeOwner, routeName],
}); });
useEffect(() => { useEffect(() => {
@@ -73,42 +58,14 @@ export function SecretsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Secrets</h1> <h1 className="text-xl font-semibold">Secrets</h1>
<div className="flex gap-2"> <button
<FormSelect type="button"
value={ownerFilter} className="btn btn-primary btn-sm"
onChange={(e) => { onClick={() => navigate("/secrets/new")}
setOwnerFilter(e.target.value); >
setHighlightName(""); <LuPlus className="size-4" />
navigate( Add
e.target.value </button>
? `/secrets?owner=${encodeURIComponent(e.target.value)}`
: "/secrets",
{ replace: true },
);
}}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/secrets/new?owner=${encodeURIComponent(ownerFilter)}`
: "/secrets/new",
)
}
>
<LuPlus className="size-4" />
Add
</button>
</div>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -120,7 +77,6 @@ export function SecretsPage() {
<table className="table table-sm"> <table className="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Owner</th>
<th>Name</th> <th>Name</th>
<th>Updated</th> <th>Updated</th>
<th /> <th />
@@ -129,16 +85,16 @@ export function SecretsPage() {
<tbody> <tbody>
{secrets.map((s) => { {secrets.map((s) => {
const highlighted = const highlighted =
highlightName && highlightName && s.name === highlightName && s.owner === routeOwner;
s.name === highlightName && const highlightByNameOnly =
(!ownerFilter || s.owner === ownerFilter); highlightName && s.name === highlightName && !routeOwner;
const isHi = highlighted || highlightByNameOnly;
return ( return (
<tr <tr
key={s.id} key={s.id}
ref={highlighted ? highlightRef : undefined} ref={isHi ? highlightRef : undefined}
className={`hover ${highlighted ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`} className={`hover ${isHi ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`}
> >
<td className="font-mono">{s.owner}</td>
<td className="font-mono">{s.name}</td> <td className="font-mono">{s.name}</td>
<td className="whitespace-nowrap">{formatTime(s.updated_at)}</td> <td className="whitespace-nowrap">{formatTime(s.updated_at)}</td>
<td className="text-right whitespace-nowrap"> <td className="text-right whitespace-nowrap">
@@ -186,7 +142,7 @@ export function SecretsPage() {
<ConfirmDialog <ConfirmDialog
open={Boolean(confirmDelete)} open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete.owner}/${confirmDelete.name}?` : ""} title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
message="This cannot be undone. Workflows that retrieve this name will fail." message="This cannot be undone. Workflows that retrieve this name will fail."
error={del.isError ? errorMessage(del.error) : null} error={del.isError ? errorMessage(del.error) : null}
loading={del.isPending} loading={del.isPending}
+29 -77
View File
@@ -2,12 +2,12 @@ import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useDeleteVariable, useOwners, useVariables } from "../api/hooks.js"; import { useDeleteVariable, useVariables } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx";
import { VariableEditorModal } from "../components/VariableEditorModal.jsx"; import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format"; import { formatTime } from "../lib/format";
import { DEFAULT_OWNER } from "../lib/tenant.js";
function displayValue(value) { function displayValue(value) {
if (typeof value === "string") return value === "" ? '""' : value; if (typeof value === "string") return value === "" ? '""' : value;
@@ -22,49 +22,31 @@ export function VariablesPage() {
const isNewRoute = /\/variables\/new\/?$/.test(location.pathname); const isNewRoute = /\/variables\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeOwner && routeName); const isEditRoute = Boolean(routeOwner && routeName);
const { data: owners = [] } = useOwners(); const { data: variables = [], isLoading } = useVariables();
const [ownerFilter, setOwnerFilter] = useState(
() => routeOwner || params.get("owner") || "",
);
const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined);
const del = useDeleteVariable(); const del = useDeleteVariable();
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || ""); const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null); const highlightRef = useRef(null);
const listPath = ownerFilter const listPath = "/variables";
? `/variables?owner=${encodeURIComponent(ownerFilter)}`
: "/variables";
const { editor, closeEditor } = useRouteDrivenModal({ const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute, isNewRoute,
isEditRoute, isEditRoute,
listPath, listPath,
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`, newRouteKey: () => `new:${params.get("name") || ""}`,
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0), canOpenNew: () => true,
onBeforeOpenNew: () => { buildNewEditor: () => ({
if (params.get("owner")) setOwnerFilter(params.get("owner")); mode: "add",
}, initial: {
buildNewEditor: () => { owner: DEFAULT_OWNER,
const owner = params.get("owner") || ownerFilter || owners[0] || "default"; name: params.get("name") || "",
return { type: "string",
mode: "add", value: "",
initial: { },
owner, }),
name: params.get("name") || "",
type: "string",
value: "",
},
};
},
editRouteKey: () => `edit:${routeOwner}/${routeName}`, editRouteKey: () => `edit:${routeOwner}/${routeName}`,
canOpenEdit: () => { canOpenEdit: () => !isLoading,
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return false;
}
return !isLoading;
},
onOpenEdit: () => setHighlightName(routeName), onOpenEdit: () => setHighlightName(routeName),
buildEditEditor: () => { buildEditEditor: () => {
const row = variables.find((v) => v.owner === routeOwner && v.name === routeName); const row = variables.find((v) => v.owner === routeOwner && v.name === routeName);
@@ -82,15 +64,15 @@ export function VariablesPage() {
return { return {
mode: "add", mode: "add",
initial: { initial: {
owner: routeOwner, owner: routeOwner || DEFAULT_OWNER,
name: routeName, name: routeName,
type: "string", type: "string",
value: "", value: "",
}, },
}; };
}, },
newDeps: [params, owners, ownerFilter], newDeps: [params],
editDeps: [routeOwner, routeName, ownerFilter, isLoading, variables], editDeps: [routeOwner, routeName, isLoading, variables],
}); });
useEffect(() => { useEffect(() => {
@@ -102,42 +84,14 @@ export function VariablesPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Variables</h1> <h1 className="text-xl font-semibold">Variables</h1>
<div className="flex gap-2"> <button
<FormSelect type="button"
value={ownerFilter} className="btn btn-primary btn-sm"
onChange={(e) => { onClick={() => navigate("/variables/new")}
setOwnerFilter(e.target.value); >
setHighlightName(""); <LuPlus className="size-4" />
navigate( Add
e.target.value </button>
? `/variables?owner=${encodeURIComponent(e.target.value)}`
: "/variables",
{ replace: true },
);
}}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/variables/new?owner=${encodeURIComponent(ownerFilter)}`
: "/variables/new",
)
}
>
<LuPlus className="size-4" />
Add
</button>
</div>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -149,7 +103,6 @@ export function VariablesPage() {
<table className="table table-sm"> <table className="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Owner</th>
<th>Name</th> <th>Name</th>
<th>Type</th> <th>Type</th>
<th>Value</th> <th>Value</th>
@@ -162,14 +115,13 @@ export function VariablesPage() {
const highlighted = const highlighted =
highlightName && highlightName &&
row.name === highlightName && row.name === highlightName &&
(!ownerFilter || row.owner === ownerFilter); (!routeOwner || row.owner === routeOwner);
return ( return (
<tr <tr
key={row.id} key={row.id}
ref={highlighted ? highlightRef : undefined} ref={highlighted ? highlightRef : undefined}
className={`hover ${highlighted ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`} className={`hover ${highlighted ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`}
> >
<td className="font-mono">{row.owner}</td>
<td className="font-mono">{row.name}</td> <td className="font-mono">{row.name}</td>
<td className="font-mono text-xs">{row.type}</td> <td className="font-mono text-xs">{row.type}</td>
<td <td
@@ -224,7 +176,7 @@ export function VariablesPage() {
<ConfirmDialog <ConfirmDialog
open={Boolean(confirmDelete)} open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete.owner}/${confirmDelete.name}?` : ""} title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
message={ message={
confirmDelete confirmDelete
? `This cannot be undone. Workflows that reference $VAR_${confirmDelete.name} will fail.` ? `This cannot be undone. Workflows that reference $VAR_${confirmDelete.name} will fail.`
+3 -31
View File
@@ -4,7 +4,6 @@ import { LuArrowLeft, LuCopy, LuPause, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { import {
useCreateWorkflow, useCreateWorkflow,
useOwners,
useSaveWorkflow, useSaveWorkflow,
useSetWorkflowEnabled, useSetWorkflowEnabled,
useWorkflow, useWorkflow,
@@ -24,6 +23,7 @@ import {
import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { WorkflowRevisionBanner } from "../components/workflow/WorkflowRevisionBanner.jsx"; import { WorkflowRevisionBanner } from "../components/workflow/WorkflowRevisionBanner.jsx";
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js"; import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
import { DEFAULT_OWNER } from "../lib/tenant.js";
import { useNotifications } from "../notifications.jsx"; import { useNotifications } from "../notifications.jsx";
function WorkflowEditorLayout({ function WorkflowEditorLayout({
@@ -100,26 +100,15 @@ function WorkflowEditorLayout({
export function WorkflowNewPage() { export function WorkflowNewPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { notify } = useNotifications(); const { notify } = useNotifications();
const { data: owners = [] } = useOwners();
const [owner, setOwner] = useState("local");
const [ready, setReady] = useState(() => shouldSkipNewWorkflowPreset()); const [ready, setReady] = useState(() => shouldSkipNewWorkflowPreset());
const [content, setContent] = useState(NEW_WORKFLOW_YAML); const [content, setContent] = useState(NEW_WORKFLOW_YAML);
const [savedYaml, setSavedYaml] = useState(NEW_WORKFLOW_YAML); const [savedYaml, setSavedYaml] = useState(NEW_WORKFLOW_YAML);
const [saveWarnings, setSaveWarnings] = useState(null); const [saveWarnings, setSaveWarnings] = useState(null);
const create = useCreateWorkflow(); const create = useCreateWorkflow();
useEffect(() => {
// Prefer local when it exists; otherwise first owner. Do not clobber a typed value.
setOwner((prev) => {
if (prev && prev !== "local") return prev;
if (owners.includes("local") || owners.length === 0) return "local";
return owners[0];
});
}, [owners]);
function onSave(saveAnyway = false) { function onSave(saveAnyway = false) {
create.mutate( create.mutate(
{ owner, content, saveAnyway }, { owner: DEFAULT_OWNER, content, saveAnyway },
{ {
onSuccess: (data) => { onSuccess: (data) => {
setSaveWarnings(null); setSaveWarnings(null);
@@ -159,34 +148,17 @@ export function WorkflowNewPage() {
title="New workflow" title="New workflow"
onSave={() => onSave(false)} onSave={() => onSave(false)}
savePending={create.isPending} savePending={create.isPending}
saveDisabled={!owner}
saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null} saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null}
> >
<div className="flex min-h-0 flex-1 flex-col gap-3"> <div className="flex min-h-0 flex-1 flex-col gap-3">
<WorkflowVisualEditor <WorkflowVisualEditor
yaml={content} yaml={content}
onYamlChange={setContent} onYamlChange={setContent}
owner={owner} owner={DEFAULT_OWNER}
file="" file=""
savedYaml={savedYaml} savedYaml={savedYaml}
showTest={false} showTest={false}
extraChrome={
<input
className="input input-sm w-full sm:max-w-xs"
placeholder="owner"
value={owner}
onChange={(e) => setOwner(e.target.value)}
list="new-workflow-owners"
required
/>
}
/> />
<datalist id="new-workflow-owners">
{owners.includes("local") ? null : <option value="local" />}
{owners.map((o) => (
<option key={o} value={o} />
))}
</datalist>
</div> </div>
</WorkflowEditorLayout> </WorkflowEditorLayout>
{saveWarnings ? ( {saveWarnings ? (
@@ -61,7 +61,6 @@ export function WorkflowTrashPage() {
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>File</th> <th>File</th>
<th>Owner</th>
<th>Deleted</th> <th>Deleted</th>
<th>Age</th> <th>Age</th>
<th>Purge in</th> <th>Purge in</th>
@@ -73,7 +72,6 @@ export function WorkflowTrashPage() {
<tr key={item.id} className="hover"> <tr key={item.id} className="hover">
<td>{item.name ?? "—"}</td> <td>{item.name ?? "—"}</td>
<td className="font-mono text-xs">{item.file}</td> <td className="font-mono text-xs">{item.file}</td>
<td className="font-mono text-xs">{item.owner}</td>
<td className="whitespace-nowrap">{formatTime(item.deleted_at)}</td> <td className="whitespace-nowrap">{formatTime(item.deleted_at)}</td>
<td>{formatAge(item.age_ms)}</td> <td>{formatAge(item.age_ms)}</td>
<td> <td>
-9
View File
@@ -16,7 +16,6 @@ import { formatTime, WorkflowStatusBadge } from "../lib/format";
const SORT_COLUMNS = [ const SORT_COLUMNS = [
{ key: "name", label: "Name", defaultOrder: "asc" }, { key: "name", label: "Name", defaultOrder: "asc" },
{ key: "owner", label: "Owner", defaultOrder: "asc" },
{ key: "status", label: "Status", defaultOrder: "asc" }, { key: "status", label: "Status", defaultOrder: "asc" },
{ key: "lastModifiedAt", label: "Last modified", defaultOrder: "desc" }, { key: "lastModifiedAt", label: "Last modified", defaultOrder: "desc" },
{ key: "lastInvokedAt", label: "Last run", defaultOrder: "desc" }, { key: "lastInvokedAt", label: "Last run", defaultOrder: "desc" },
@@ -59,12 +58,6 @@ function compareName(a, b) {
function compareWorkflows(a, b, sort) { function compareWorkflows(a, b, sort) {
if (sort === "name") return compareName(a, b); if (sort === "name") return compareName(a, b);
if (sort === "owner") {
const byOwner = String(a.owner ?? "").localeCompare(String(b.owner ?? ""), undefined, {
sensitivity: "base",
});
return byOwner !== 0 ? byOwner : compareName(a, b);
}
if (sort === "status") { if (sort === "status") {
const byStatus = statusSortKey(a).localeCompare(statusSortKey(b)); const byStatus = statusSortKey(a).localeCompare(statusSortKey(b));
return byStatus !== 0 ? byStatus : compareName(a, b); return byStatus !== 0 ? byStatus : compareName(a, b);
@@ -196,7 +189,6 @@ export function WorkflowsPage() {
<thead> <thead>
<tr> <tr>
<SortHeader column="name" label="Name" sort={sort} order={order} onSort={setSort} /> <SortHeader column="name" label="Name" sort={sort} order={order} onSort={setSort} />
<SortHeader column="owner" label="Owner" sort={sort} order={order} onSort={setSort} />
<SortHeader column="status" label="Status" sort={sort} order={order} onSort={setSort} /> <SortHeader column="status" label="Status" sort={sort} order={order} onSort={setSort} />
<th>Triggers</th> <th>Triggers</th>
<SortHeader <SortHeader
@@ -244,7 +236,6 @@ export function WorkflowsPage() {
</span> </span>
) : null} ) : null}
</td> </td>
<td className="font-mono text-xs">{w.owner}</td>
<td> <td>
<WorkflowStatusBadge workflow={w} /> <WorkflowStatusBadge workflow={w} />
</td> </td>