From 71cc705cd239e3aa06d3fd3740ae4c2018aa19c7 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sat, 22 Aug 2026 19:08:52 +0700 Subject: [PATCH] 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 --- README.md | 10 +- packages/server/package.json | 1 + packages/server/reset-admin.js | 105 +++++++++++++++++ packages/server/src/api/scripts.js | 2 +- packages/server/store.js | 3 +- .../components/DuplicateWorkflowDialog.jsx | 26 +---- packages/web/src/components/Layout.jsx | 2 - .../web/src/components/ProfileEditorModal.jsx | 64 ++++------- .../web/src/components/SecretEditorModal.jsx | 70 +++--------- .../src/components/VariableEditorModal.jsx | 68 +++-------- packages/web/src/lib/tenant.js | 2 + packages/web/src/pages/EventsPage.jsx | 17 +-- packages/web/src/pages/ProfilesPage.jsx | 107 +++++------------- packages/web/src/pages/ScriptDryRunPage.jsx | 21 +--- packages/web/src/pages/SecretsPage.jsx | 106 +++++------------ packages/web/src/pages/VariablesPage.jsx | 106 +++++------------ packages/web/src/pages/WorkflowEditPage.jsx | 34 +----- packages/web/src/pages/WorkflowTrashPage.jsx | 2 - packages/web/src/pages/WorkflowsPage.jsx | 9 -- 19 files changed, 271 insertions(+), 484 deletions(-) create mode 100644 packages/server/reset-admin.js create mode 100644 packages/web/src/lib/tenant.js diff --git a/README.md b/README.md index bb9f08f..524fbd5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,13 @@ pnpm dev - UI (dev): http://localhost:8500 - 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 @@ -52,7 +58,7 @@ The first account created becomes **admin**. Later accounts are created from Use | **Live workflows** | Yes | `packages/server/data/workflows//` (gitignored) | | **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/`. - 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`. diff --git a/packages/server/package.json b/packages/server/package.json index 0c8e20c..ee83215 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -14,6 +14,7 @@ "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: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:set-dry-run": "node test/set-dry-run-smoke.js" }, diff --git a/packages/server/reset-admin.js b/packages/server/reset-admin.js new file mode 100644 index 0000000..49fc475 --- /dev/null +++ b/packages/server/reset-admin.js @@ -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 --password + +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(); +} diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js index c799a56..d851f5c 100644 --- a/packages/server/src/api/scripts.js +++ b/packages/server/src/api/scripts.js @@ -258,7 +258,7 @@ export default function scriptsPluginFactory(registry) { req.body ?? {} ); - let owner = "default"; + let owner = "local"; if (body.owner != null && body.owner !== "") { try { owner = fsStore.assertOwner(String(body.owner)); diff --git a/packages/server/store.js b/packages/server/store.js index a23a1cd..74adc87 100644 --- a/packages/server/store.js +++ b/packages/server/store.js @@ -582,12 +582,13 @@ export async function listUsers() { /** * @param {string} id - * @param {{ passwordHash?: string, role?: string }} patch + * @param {{ passwordHash?: string, role?: string, username?: string }} patch */ export async function updateUser(id, patch) { const update = { updated_at: nowIso() }; if (patch.passwordHash) update.password_hash = patch.passwordHash; if (patch.role) update.role = patch.role; + if (patch.username) update.username = patch.username; await db("users").where({ id }).update(update); return getUserById(id); } diff --git a/packages/web/src/components/DuplicateWorkflowDialog.jsx b/packages/web/src/components/DuplicateWorkflowDialog.jsx index 168d004..3d0e088 100644 --- a/packages/web/src/components/DuplicateWorkflowDialog.jsx +++ b/packages/web/src/components/DuplicateWorkflowDialog.jsx @@ -1,22 +1,19 @@ -import { useState } from "react"; import { errorMessage } from "../api/client.js"; -import { useDuplicateWorkflow, useOwners } from "../api/hooks.js"; +import { useDuplicateWorkflow } from "../api/hooks.js"; import { useNotifications } from "../notifications.jsx"; export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) { const { notify } = useNotifications(); - const { data: owners = [] } = useOwners(); const duplicate = useDuplicateWorkflow(); - const [destOwner, setDestOwner] = useState(source.owner); function onSubmit(e) { e.preventDefault(); - if (destOwner === source.owner && duplicate.isPending) return; + if (duplicate.isPending) return; duplicate.mutate( { owner: source.owner, file: source.file, - destOwner, + destOwner: source.owner, }, { onSuccess: (data) => { @@ -33,7 +30,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic

Duplicate {source.key}?

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.

{warnUnsaved ? (

@@ -41,21 +38,6 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic

) : null}
- {duplicate.isError ? (

{errorMessage(duplicate.error)}

) : null} diff --git a/packages/web/src/components/Layout.jsx b/packages/web/src/components/Layout.jsx index 662d4df..f69868e 100644 --- a/packages/web/src/components/Layout.jsx +++ b/packages/web/src/components/Layout.jsx @@ -17,7 +17,6 @@ import { LuShield, LuSun, LuTags, - LuUsers, } from "react-icons/lu"; import { useLogout, useOpsStatus } from "../api/hooks.js"; import { brandMark } from "../theme/brand.js"; @@ -52,7 +51,6 @@ const navSections = [ items: [ { to: "/manage", label: "Manage", icon: LuServer }, { to: "/backup", label: "Backup", icon: LuArchive }, - { to: "/users", label: "Users", icon: LuUsers }, ], }, ]; diff --git a/packages/web/src/components/ProfileEditorModal.jsx b/packages/web/src/components/ProfileEditorModal.jsx index 1f6511d..b974ded 100644 --- a/packages/web/src/components/ProfileEditorModal.jsx +++ b/packages/web/src/components/ProfileEditorModal.jsx @@ -1,8 +1,9 @@ import { useState } from "react"; 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 { ConfigFields } from "./workflow/ConfigFields.jsx"; +import { DEFAULT_OWNER } from "../lib/tenant.js"; /** * @param {"add" | "edit"} mode @@ -16,11 +17,10 @@ import { ConfigFields } from "./workflow/ConfigFields.jsx"; * @param {number} [usageCount] */ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount = 0 }) { - const { data: owners = [] } = useOwners(); const { data: scripts = [] } = useScripts(); const upsert = useUpsertProfile(); const [form, setForm] = useState(() => ({ - owner: initial.owner || owners[0] || "default", + owner: initial.owner || DEFAULT_OWNER, name: initial.name || "", script: initial.script || "", config: @@ -72,7 +72,7 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount submit(); } - const title = mode === "add" ? "New profile" : `Edit ${form.owner}/${form.name}`; + const title = mode === "add" ? "New profile" : `Edit ${form.name}`; return ( @@ -80,49 +80,23 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount

{title}

{mode === "add" ? ( - <> - - - + ) : (

- {form.owner}/{form.name} + {form.name} (name cannot be changed)

)} diff --git a/packages/web/src/components/SecretEditorModal.jsx b/packages/web/src/components/SecretEditorModal.jsx index f25ec1b..1e566d9 100644 --- a/packages/web/src/components/SecretEditorModal.jsx +++ b/packages/web/src/components/SecretEditorModal.jsx @@ -1,30 +1,22 @@ import { useState } from "react"; import { errorMessage } from "../api/client.js"; -import { useOwners, useUpsertSecret } from "../api/hooks.js"; -import { FormInput, FormSelect } from "./FormControls.jsx"; +import { useUpsertSecret } from "../api/hooks.js"; +import { FormInput } from "./FormControls.jsx"; import { Modal } from "./Modal.jsx"; +import { DEFAULT_OWNER } from "../lib/tenant.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(); +export function SecretEditorModal({ mode, initial, onClose, onSaved }) { const upsert = useUpsertSecret(); const [form, setForm] = useState(() => ({ - owner: initial.owner || owners[0] || "default", + owner: initial.owner || DEFAULT_OWNER, name: initial.name || "", 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 (

{title}

{mode === "add" ? ( - <> - - - + ) : null}