feat(server): enhance secret management and logging functionality
- Added SCRUNNER_SECRETS_KEY to README as a required variable for production. - Implemented redaction of sensitive information in logs across various components. - Enhanced script execution functions to include an owner parameter for better secret management. - Introduced a secrets API in the script sandbox for retrieving and managing secrets. - Updated UI components to support owner selection for script execution and secret management.
This commit is contained in:
@@ -35,6 +35,7 @@ The first account created becomes **admin**. Later accounts are created from Use
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `SCRUNNER_JWT_SECRET` | `scrunner-dev-secret` (dev only) | **Required in production** |
|
||||
| `SCRUNNER_SECRETS_KEY` | `scrunner-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
|
||||
| `SCRUNNER_DB_PATH` | `packages/server/data/scrunner.db` | SQLite file |
|
||||
| `SCRUNNER_LOG_LEVEL` | `debug` | Pino level |
|
||||
| `SCRUNNER_RETENTION_DAYS` | `30` | Run history prune |
|
||||
@@ -47,7 +48,7 @@ The first account created becomes **admin**. Later accounts are created from Use
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
SCRUNNER_JWT_SECRET=... NODE_ENV=production pnpm start
|
||||
SCRUNNER_JWT_SECRET=... SCRUNNER_SECRETS_KEY=... NODE_ENV=production pnpm start
|
||||
```
|
||||
|
||||
The server serves `packages/web/dist` when that folder exists.
|
||||
|
||||
@@ -3,6 +3,18 @@ import path from "path";
|
||||
import pino from "pino";
|
||||
import * as store from "./store.js";
|
||||
import { LOGS_DIR } from "./paths.js";
|
||||
import { redactString } from "./secret-value.js";
|
||||
|
||||
/**
|
||||
* @param {{ write: (line: string) => unknown }} dest
|
||||
*/
|
||||
function redactStream(dest) {
|
||||
return {
|
||||
write(line) {
|
||||
dest.write(redactString(typeof line === "string" ? line : String(line)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LEVEL_TO_NUM = {
|
||||
trace: 10,
|
||||
@@ -27,7 +39,7 @@ let timer = null;
|
||||
function enqueueLine(line) {
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
record = JSON.parse(redactString(line));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -103,9 +115,9 @@ const rollingFile = pino.transport({
|
||||
export const log = pino(
|
||||
{ level: process.env.SCRUNNER_LOG_LEVEL ?? "debug" },
|
||||
pino.multistream([
|
||||
{ level: "debug", stream: process.stdout },
|
||||
{ level: "debug", stream: rollingFile },
|
||||
{ level: "debug", stream: sqliteStream },
|
||||
{ level: "debug", stream: redactStream(process.stdout) },
|
||||
{ level: "debug", stream: redactStream(rollingFile) },
|
||||
{ level: "debug", stream: redactStream(sqliteStream) },
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable("secrets", (t) => {
|
||||
t.text("id").primary();
|
||||
t.text("owner").notNullable();
|
||||
t.text("name").notNullable();
|
||||
t.text("ciphertext").notNullable();
|
||||
t.text("iv").notNullable();
|
||||
t.text("auth_tag").notNullable();
|
||||
t.text("created_at").notNullable();
|
||||
t.text("updated_at").notNullable();
|
||||
t.unique(["owner", "name"]);
|
||||
});
|
||||
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX secrets_owner_name_idx ON secrets (owner, name)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTableIfExists("secrets");
|
||||
}
|
||||
@@ -207,12 +207,13 @@ export function createRegistry(server) {
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} key
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function runLinearSteps(compiled, ctx, runId, runLog, key) {
|
||||
async function runLinearSteps(compiled, ctx, runId, runLog, key, owner) {
|
||||
let next = ctx;
|
||||
for (const index of compiled.order) {
|
||||
const parsed = compiled.steps[index];
|
||||
next = await runCompiledStep(parsed, next, index, runId, runLog, key);
|
||||
next = await runCompiledStep(parsed, next, index, runId, runLog, key, owner);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -223,8 +224,9 @@ export function createRegistry(server) {
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} key
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function runDagSteps(compiled, ctx, runId, runLog, key) {
|
||||
async function runDagSteps(compiled, ctx, runId, runLog, key, owner) {
|
||||
const triggerData = ctx.data;
|
||||
/** @type {Map<string, unknown>} */
|
||||
const outputsById = new Map();
|
||||
@@ -240,6 +242,7 @@ export function createRegistry(server) {
|
||||
runId,
|
||||
runLog,
|
||||
key,
|
||||
owner,
|
||||
);
|
||||
if (parsed.id) {
|
||||
outputsById.set(parsed.id, last);
|
||||
@@ -255,8 +258,9 @@ export function createRegistry(server) {
|
||||
* @param {string} runId
|
||||
* @param {import("pino").Logger} runLog
|
||||
* @param {string} key
|
||||
* @param {string} owner
|
||||
*/
|
||||
async function runCompiledStep(parsed, ctx, index, runId, runLog, key) {
|
||||
async function runCompiledStep(parsed, ctx, index, runId, runLog, key, owner) {
|
||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||
const config = parsed.config;
|
||||
const step = await store.startStep({
|
||||
@@ -287,6 +291,7 @@ export function createRegistry(server) {
|
||||
const result = await runScript(script, { ...ctx, config }, {
|
||||
log: stepLog,
|
||||
workflowName: key,
|
||||
owner,
|
||||
});
|
||||
await store.finishStep(step.id, "success", result);
|
||||
return result;
|
||||
@@ -327,9 +332,9 @@ export function createRegistry(server) {
|
||||
try {
|
||||
const compiled = compileWorkflowScripts(workflow.scripts);
|
||||
if (compiled.dagMode) {
|
||||
ctx = await runDagSteps(compiled, ctx, run.id, runLog, key);
|
||||
ctx = await runDagSteps(compiled, ctx, run.id, runLog, key, owner);
|
||||
} else {
|
||||
ctx = await runLinearSteps(compiled, ctx, run.id, runLog, key);
|
||||
ctx = await runLinearSteps(compiled, ctx, run.id, runLog, key, owner);
|
||||
}
|
||||
await store.finishRun(run.id, "success", ctx);
|
||||
return { runId: run.id, status: "success", result: ctx };
|
||||
|
||||
@@ -15,7 +15,9 @@ import scriptsPluginFactory from "./src/api/scripts.js";
|
||||
import workflowsPluginFactory from "./src/api/workflows.js";
|
||||
import runsPlugin from "./src/api/runs.js";
|
||||
import dashboardPluginFactory from "./src/api/dashboard.js";
|
||||
import secretsPlugin from "./src/api/secrets.js";
|
||||
import { WEB_DIST } from "./paths.js";
|
||||
import { resolveSecretsKeyMaterial } from "./secrets.js";
|
||||
|
||||
await migrate();
|
||||
enableLogPersistence();
|
||||
@@ -29,6 +31,13 @@ if (!jwtSecret) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
resolveSecretsKeyMaterial();
|
||||
} catch (err) {
|
||||
log.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = fastify({ loggerInstance: log });
|
||||
|
||||
await server.register(cookie);
|
||||
@@ -78,6 +87,7 @@ await server.register(
|
||||
});
|
||||
await api.register(authPlugin);
|
||||
await api.register(usersPlugin);
|
||||
await api.register(secretsPlugin);
|
||||
await api.register(scriptsPluginFactory(registry));
|
||||
await api.register(workflowsPluginFactory(registry));
|
||||
await api.register(runsPlugin);
|
||||
|
||||
@@ -6,6 +6,8 @@ import axios from "axios";
|
||||
import pino from "pino";
|
||||
import { createKvApi } from "./kv-store.js";
|
||||
import { SCRIPTS_DIR } from "./paths.js";
|
||||
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
|
||||
import { getSecretPlaintext } from "./secrets-store.js";
|
||||
|
||||
const hostRequire = createRequire(import.meta.url);
|
||||
|
||||
@@ -256,7 +258,26 @@ function createScreenedAxios(log) {
|
||||
screenRequestUrl(url, log);
|
||||
return config;
|
||||
});
|
||||
return instance;
|
||||
|
||||
for (const method of [
|
||||
"request",
|
||||
"get",
|
||||
"delete",
|
||||
"head",
|
||||
"options",
|
||||
"post",
|
||||
"put",
|
||||
"patch",
|
||||
]) {
|
||||
const orig = instance[method].bind(instance);
|
||||
instance[method] = (...args) => orig(...unwrapSecretsDeep(args));
|
||||
}
|
||||
|
||||
return new Proxy(instance, {
|
||||
apply(_target, _thisArg, args) {
|
||||
return instance.request(...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createRestrictedRequire(screenedAxios) {
|
||||
@@ -272,18 +293,45 @@ function createRestrictedRequire(screenedAxios) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts
|
||||
* @param {string} owner
|
||||
*/
|
||||
function createScriptSandbox({ log, script, workflowName }) {
|
||||
function createSecretsApi(owner) {
|
||||
return {
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
async get(name) {
|
||||
const value = await getSecretPlaintext(owner, name);
|
||||
if (value == null) {
|
||||
throw new Error(`secret "${name}" not found`);
|
||||
}
|
||||
return new Secret(value);
|
||||
},
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
reveal(value) {
|
||||
if (isSecret(value)) return value.reveal();
|
||||
throw new Error("reveal() expects a Secret from $secrets.get()");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ log: import("pino").Logger, script: string, workflowName: string, owner?: string }} opts
|
||||
*/
|
||||
function createScriptSandbox({ log, script, workflowName, owner = "default" }) {
|
||||
const scriptLog = log.child({ workflow: workflowName, script });
|
||||
const $axios = createScreenedAxios(scriptLog);
|
||||
const $kv = createKvApi(workflowName);
|
||||
const $secrets = createSecretsApi(owner);
|
||||
const sandbox = {
|
||||
...pickBuiltins(),
|
||||
log: scriptLog,
|
||||
console: createConsole(scriptLog),
|
||||
$axios,
|
||||
$kv,
|
||||
$secrets,
|
||||
require: createRestrictedRequire($axios),
|
||||
};
|
||||
|
||||
@@ -328,10 +376,10 @@ export function extractScriptMeta(fn) {
|
||||
|
||||
/**
|
||||
* @param {import("node:vm").Script} compiled
|
||||
* @param {{ log: import("pino").Logger, script: string, workflowName: string }} opts
|
||||
* @param {{ log: import("pino").Logger, script: string, workflowName: string, owner?: string }} opts
|
||||
*/
|
||||
function instantiateCompiled(compiled, { log, script, workflowName }) {
|
||||
const sandbox = createScriptSandbox({ log, script, workflowName });
|
||||
function instantiateCompiled(compiled, { log, script, workflowName, owner }) {
|
||||
const sandbox = createScriptSandbox({ log, script, workflowName, owner });
|
||||
return compiled.runInContext(sandbox);
|
||||
}
|
||||
|
||||
@@ -341,7 +389,7 @@ function instantiateCompiled(compiled, { log, script, workflowName }) {
|
||||
*
|
||||
* @param {string} script
|
||||
* @param {string} source
|
||||
* @param {{ log?: import("pino").Logger, workflowName?: string }} [opts]
|
||||
* @param {{ log?: import("pino").Logger, workflowName?: string, owner?: string }} [opts]
|
||||
*/
|
||||
export function instantiateScriptSource(script, source, opts = {}) {
|
||||
const compiled = compileScriptSource(source, script);
|
||||
@@ -349,6 +397,7 @@ export function instantiateScriptSource(script, source, opts = {}) {
|
||||
log: opts.log ?? inspectLog,
|
||||
script,
|
||||
workflowName: opts.workflowName ?? "inspect",
|
||||
owner: opts.owner ?? "default",
|
||||
});
|
||||
return { fn, ...extractScriptMeta(fn) };
|
||||
}
|
||||
@@ -390,11 +439,11 @@ function loadCompiledScript(script) {
|
||||
*
|
||||
* @param {string} script
|
||||
* @param {unknown} ctx
|
||||
* @param {{ log: import("pino").Logger, workflowName: string }} opts
|
||||
* @param {{ log: import("pino").Logger, workflowName: string, owner?: string }} opts
|
||||
*/
|
||||
export async function runScript(script, ctx, { log, workflowName }) {
|
||||
export async function runScript(script, ctx, { log, workflowName, owner }) {
|
||||
const compiled = loadCompiledScript(script);
|
||||
const fn = instantiateCompiled(compiled, { log, script, workflowName });
|
||||
const fn = instantiateCompiled(compiled, { log, script, workflowName, owner });
|
||||
return await fn(ctx);
|
||||
}
|
||||
|
||||
@@ -404,9 +453,9 @@ export async function runScript(script, ctx, { log, workflowName }) {
|
||||
* @param {string} script
|
||||
* @param {string} source
|
||||
* @param {unknown} ctx
|
||||
* @param {{ log: import("pino").Logger, workflowName: string }} opts
|
||||
* @param {{ log: import("pino").Logger, workflowName: string, owner?: string }} opts
|
||||
*/
|
||||
export async function runScriptSource(script, source, ctx, { log, workflowName }) {
|
||||
const { fn } = instantiateScriptSource(script, source, { log, workflowName });
|
||||
export async function runScriptSource(script, source, ctx, { log, workflowName, owner }) {
|
||||
const { fn } = instantiateScriptSource(script, source, { log, workflowName, owner });
|
||||
return await fn(ctx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
async function getSecret(ctx) {
|
||||
const name = ctx.config?.name;
|
||||
if (typeof name !== "string" || name.length === 0) {
|
||||
throw new Error("config.name is required");
|
||||
}
|
||||
const as =
|
||||
typeof ctx.config?.as === "string" && ctx.config.as.length > 0
|
||||
? ctx.config.as
|
||||
: name;
|
||||
|
||||
const value = await $secrets.get(name);
|
||||
const base =
|
||||
ctx != null && typeof ctx === "object" && !Array.isArray(ctx) ? { ...ctx } : {};
|
||||
const data =
|
||||
base.data != null && typeof base.data === "object" && !Array.isArray(base.data)
|
||||
? { ...base.data }
|
||||
: {};
|
||||
data[as] = value;
|
||||
return { ...base, data };
|
||||
}
|
||||
|
||||
getSecret.meta = {
|
||||
description:
|
||||
"Load a named secret for this workflow owner into ctx.data. The value is wrapped and redacted in logs.",
|
||||
config: {
|
||||
name: {
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Secret name (per owner)",
|
||||
},
|
||||
as: {
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "ctx.data field to write (defaults to name)",
|
||||
},
|
||||
},
|
||||
input: {},
|
||||
output: {
|
||||
data: {
|
||||
type: "object",
|
||||
description: "Previous ctx.data plus the retrieved Secret at [as]",
|
||||
},
|
||||
},
|
||||
example: {
|
||||
data: {},
|
||||
config: { name: "ntfy_token", as: "ntfyToken" },
|
||||
},
|
||||
};
|
||||
|
||||
export default getSecret;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { inspect } from "node:util";
|
||||
|
||||
export const REDACTED = "[secret]";
|
||||
export const MIN_SECRET_LENGTH = 8;
|
||||
|
||||
/** @type {Set<string>} */
|
||||
const plaintextValues = new Set();
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export function registerPlaintext(value) {
|
||||
if (typeof value === "string" && value.length >= MIN_SECRET_LENGTH) {
|
||||
plaintextValues.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace registered secret strings in text (raw and JSON-escaped forms).
|
||||
* @param {string} text
|
||||
*/
|
||||
export function redactString(text) {
|
||||
if (typeof text !== "string" || plaintextValues.size === 0) return text;
|
||||
let out = text;
|
||||
for (const secret of plaintextValues) {
|
||||
if (out.includes(secret)) {
|
||||
out = out.split(secret).join("***");
|
||||
}
|
||||
const escaped = JSON.stringify(secret).slice(1, -1);
|
||||
if (escaped !== secret && out.includes(escaped)) {
|
||||
out = out.split(escaped).join("***");
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class Secret {
|
||||
/** @type {string} */
|
||||
#value;
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
constructor(value) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("secret value must be a string");
|
||||
}
|
||||
this.#value = value;
|
||||
registerPlaintext(value);
|
||||
}
|
||||
|
||||
reveal() {
|
||||
return this.#value;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return REDACTED;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return REDACTED;
|
||||
}
|
||||
|
||||
[inspect.custom]() {
|
||||
return REDACTED;
|
||||
}
|
||||
|
||||
[Symbol.toPrimitive]() {
|
||||
return REDACTED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function isSecret(value) {
|
||||
return value instanceof Secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {WeakSet<object>} [seen]
|
||||
*/
|
||||
export function unwrapSecretsDeep(value, seen = new WeakSet()) {
|
||||
if (isSecret(value)) return value.reveal();
|
||||
if (value == null || typeof value !== "object") return value;
|
||||
if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) return value;
|
||||
if (seen.has(value)) return value;
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => unwrapSecretsDeep(item, seen));
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
out[key] = unwrapSecretsDeep(child, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { assertOwner } from "./fs-store.js";
|
||||
import { decryptSecret, encryptSecret } from "./secrets.js";
|
||||
import { MIN_SECRET_LENGTH, registerPlaintext } from "./secret-value.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertSecretName(name) {
|
||||
if (typeof name !== "string" || !SECRET_NAME_RE.test(name)) {
|
||||
const err = new Error("invalid secret name");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
const err = new Error(`secret name must be at most ${MAX_NAME_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function publicSecret(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner?: string }} [filters]
|
||||
*/
|
||||
export async function listSecrets(filters = {}) {
|
||||
let q = db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.orderBy("owner", "asc")
|
||||
.orderBy("name", "asc");
|
||||
if (filters.owner) {
|
||||
q = q.where("owner", assertOwner(filters.owner));
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getSecretById(id) {
|
||||
const row = await db("secrets")
|
||||
.select("id", "owner", "name", "created_at", "updated_at")
|
||||
.where({ id })
|
||||
.first();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ owner: string, name: string, value: string }} opts
|
||||
*/
|
||||
export async function upsertSecret({ owner, name, value }) {
|
||||
if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) {
|
||||
const err = new Error(`value must be at least ${MIN_SECRET_LENGTH} characters`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
registerPlaintext(value);
|
||||
const { ciphertext, iv, authTag } = encryptSecret(value);
|
||||
const now = nowIso();
|
||||
const existing = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db("secrets")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(existing.id);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
await db("secrets").insert({
|
||||
id,
|
||||
owner: ownerName,
|
||||
name: secretName,
|
||||
ciphertext,
|
||||
iv,
|
||||
auth_tag: authTag,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getSecretById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteSecret(id) {
|
||||
const n = await db("secrets").where({ id }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a named secret for an owner. Returns null if missing.
|
||||
* @param {string} owner
|
||||
* @param {string} name
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function getSecretPlaintext(owner, name) {
|
||||
const ownerName = assertOwner(owner);
|
||||
const secretName = assertSecretName(name);
|
||||
const row = await db("secrets")
|
||||
.where({ owner: ownerName, name: secretName })
|
||||
.first();
|
||||
if (!row) return null;
|
||||
try {
|
||||
const plaintext = decryptSecret({
|
||||
ciphertext: row.ciphertext,
|
||||
iv: row.iv,
|
||||
authTag: row.auth_tag,
|
||||
});
|
||||
registerPlaintext(plaintext);
|
||||
return plaintext;
|
||||
} catch {
|
||||
throw new Error(`failed to decrypt secret "${secretName}"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
const DEV_DEFAULT = "scrunner-dev-secrets-key";
|
||||
const SCRYPT_SALT = Buffer.from("scrunner-secrets-v1");
|
||||
const KEY_LEN = 32;
|
||||
const IV_LEN = 12;
|
||||
const AUTH_TAG_LEN = 16;
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveSecretsKeyMaterial() {
|
||||
const raw =
|
||||
process.env.SCRUNNER_SECRETS_KEY ??
|
||||
(process.env.NODE_ENV === "production" ? "" : DEV_DEFAULT);
|
||||
if (!raw) {
|
||||
throw new Error("SCRUNNER_SECRETS_KEY is required in production");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** @type {Buffer | null} */
|
||||
let cachedKey = null;
|
||||
|
||||
/**
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function getMasterKey() {
|
||||
if (cachedKey) return cachedKey;
|
||||
const raw = resolveSecretsKeyMaterial();
|
||||
cachedKey = /^[0-9a-fA-F]{64}$/.test(raw)
|
||||
? Buffer.from(raw, "hex")
|
||||
: scryptSync(raw, SCRYPT_SALT, KEY_LEN);
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} plaintext
|
||||
* @returns {{ ciphertext: string, iv: string, authTag: string }}
|
||||
*/
|
||||
export function encryptSecret(plaintext) {
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv("aes-256-gcm", getMasterKey(), iv, {
|
||||
authTagLength: AUTH_TAG_LEN,
|
||||
});
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
return {
|
||||
ciphertext: encrypted.toString("base64"),
|
||||
iv: iv.toString("base64"),
|
||||
authTag: cipher.getAuthTag().toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ ciphertext: string, iv: string, authTag: string }} row
|
||||
* @returns {string}
|
||||
*/
|
||||
export function decryptSecret(row) {
|
||||
const decipher = createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
getMasterKey(),
|
||||
Buffer.from(row.iv, "base64"),
|
||||
{ authTagLength: AUTH_TAG_LEN },
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(row.authTag, "base64"));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(row.ciphertext, "base64")),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import pino from "pino";
|
||||
import { redactString } from "../../secret-value.js";
|
||||
|
||||
const LEVEL_TO_NUM = {
|
||||
trace: 10,
|
||||
@@ -22,7 +23,9 @@ export function createDryRunLogger() {
|
||||
write(line) {
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(typeof line === "string" ? line : String(line));
|
||||
record = JSON.parse(
|
||||
redactString(typeof line === "string" ? line : String(line)),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -43,7 +46,7 @@ export function createDryRunLogger() {
|
||||
logs.push({
|
||||
ts,
|
||||
level,
|
||||
msg,
|
||||
msg: typeof msg === "string" ? redactString(msg) : msg,
|
||||
payload: Object.keys(rest).length ? rest : null,
|
||||
});
|
||||
},
|
||||
@@ -60,6 +63,7 @@ export function safeSerialize(value) {
|
||||
const seen = new WeakSet();
|
||||
try {
|
||||
return JSON.parse(
|
||||
redactString(
|
||||
JSON.stringify(value, (_key, v) => {
|
||||
if (typeof v === "bigint") return v.toString();
|
||||
if (typeof v === "object" && v !== null) {
|
||||
@@ -68,6 +72,7 @@ export function safeSerialize(value) {
|
||||
}
|
||||
return v;
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
@@ -85,13 +85,22 @@ export default function scriptsPluginFactory(registry) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
|
||||
const body = /** @type {{ content?: string, data?: unknown, config?: unknown }} */ (
|
||||
const body = /** @type {{ content?: string, data?: unknown, config?: unknown, owner?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
if (typeof body.content !== "string") {
|
||||
return reply.code(400).send({ error: "content is required" });
|
||||
}
|
||||
|
||||
let owner = "default";
|
||||
if (body.owner != null && body.owner !== "") {
|
||||
try {
|
||||
owner = fsStore.assertOwner(String(body.owner));
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
data: body.data ?? null,
|
||||
config: body.config ?? null,
|
||||
@@ -104,6 +113,7 @@ export default function scriptsPluginFactory(registry) {
|
||||
const { fn, meta, metaError } = instantiateScriptSource(name, body.content, {
|
||||
log,
|
||||
workflowName: "dry-run",
|
||||
owner,
|
||||
});
|
||||
const output = await fn(ctx);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as fsStore from "../../fs-store.js";
|
||||
import {
|
||||
assertSecretName,
|
||||
deleteSecret,
|
||||
getSecretById,
|
||||
listSecrets,
|
||||
upsertSecret,
|
||||
} from "../../secrets-store.js";
|
||||
import { MIN_SECRET_LENGTH } from "../../secret-value.js";
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyInstance} fastify
|
||||
*/
|
||||
export default async function secretsPlugin(fastify) {
|
||||
fastify.addHook("onRequest", fastify.requireAdmin);
|
||||
|
||||
fastify.get("/secrets", async (req, reply) => {
|
||||
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
|
||||
try {
|
||||
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
|
||||
const secrets = await listSecrets({ owner });
|
||||
return { secrets };
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.put("/secrets", async (req, reply) => {
|
||||
const body = /** @type {{ owner?: string, name?: string, value?: string }} */ (
|
||||
req.body ?? {}
|
||||
);
|
||||
try {
|
||||
fsStore.assertOwner(String(body.owner ?? ""));
|
||||
assertSecretName(String(body.name ?? ""));
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
|
||||
const value = String(body.value ?? "");
|
||||
if (value.length < MIN_SECRET_LENGTH) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: `value must be at least ${MIN_SECRET_LENGTH} characters` });
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = await upsertSecret({
|
||||
owner: String(body.owner),
|
||||
name: String(body.name),
|
||||
value,
|
||||
});
|
||||
return reply.send({ secret });
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
fastify.delete("/secrets/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
const existing = await getSecretById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "secret not found" });
|
||||
}
|
||||
await deleteSecret(id);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { redactString } from "./secret-value.js";
|
||||
|
||||
const MAX_JSON_BYTES = 64 * 1024;
|
||||
|
||||
@@ -15,6 +16,7 @@ export function serialize(value) {
|
||||
} catch {
|
||||
json = JSON.stringify({ truncated: true, reason: "unserializable" });
|
||||
}
|
||||
json = redactString(json);
|
||||
if (Buffer.byteLength(json, "utf8") <= MAX_JSON_BYTES) return json;
|
||||
return JSON.stringify({
|
||||
truncated: true,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx"
|
||||
import { EventsPage } from "./pages/EventsPage.jsx";
|
||||
import { EventDetailPage } from "./pages/EventDetailPage.jsx";
|
||||
import { UsersPage } from "./pages/UsersPage.jsx";
|
||||
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
||||
|
||||
export function App() {
|
||||
const qc = useQueryClient();
|
||||
@@ -57,9 +58,15 @@ export function App() {
|
||||
<Route path="/events" element={<EventsPage />} />
|
||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||
{user.role === "admin" ? (
|
||||
<>
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/secrets" element={<SecretsPage />} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Route path="/users" element={<Navigate to="/" replace />} />
|
||||
<Route path="/secrets" element={<Navigate to="/" replace />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -107,12 +107,13 @@ export function useDeleteScript() {
|
||||
|
||||
export function useDryRunScript() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, content, data, config }) =>
|
||||
mutationFn: async ({ name, content, data, config, owner }) =>
|
||||
(
|
||||
await api.post(`/scripts/${encodeURIComponent(name)}/dry-run`, {
|
||||
content,
|
||||
data,
|
||||
config,
|
||||
owner,
|
||||
})
|
||||
).data,
|
||||
});
|
||||
@@ -267,3 +268,30 @@ export function useDeleteUser() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSecrets(owner) {
|
||||
return useQuery({
|
||||
queryKey: ["secrets", owner ?? "all"],
|
||||
queryFn: async () => {
|
||||
const params = owner ? { owner } : {};
|
||||
return (await api.get("/secrets", { params })).data.secrets;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertSecret() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.put("/secrets", body)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSecret() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id) =>
|
||||
(await api.delete(`/secrets/${encodeURIComponent(id)}`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["secrets"] }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
LuCode,
|
||||
LuGitBranch,
|
||||
LuHouse,
|
||||
LuKey,
|
||||
LuLogOut,
|
||||
LuMenu,
|
||||
LuMoon,
|
||||
@@ -28,7 +29,10 @@ export function Layout({ user, children }) {
|
||||
const navItems = [
|
||||
...links,
|
||||
...(user.role === "admin"
|
||||
? [{ to: "/users", label: "Users", icon: LuUsers }]
|
||||
? [
|
||||
{ to: "/secrets", label: "Secrets", icon: LuKey },
|
||||
{ to: "/users", label: "Users", icon: LuUsers },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useDryRunScript,
|
||||
useOwners,
|
||||
useSaveScript,
|
||||
useScript,
|
||||
} from "../api/hooks.js";
|
||||
@@ -27,6 +28,8 @@ export function ScriptDryRunPage() {
|
||||
const existing = useScript(name, stateContent == null);
|
||||
const dryRun = useDryRunScript();
|
||||
const save = useSaveScript();
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [owner, setOwner] = useState("default");
|
||||
|
||||
const [content, setContent] = useState("");
|
||||
const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT);
|
||||
@@ -116,6 +119,7 @@ export function ScriptDryRunPage() {
|
||||
content,
|
||||
data: ctx.data,
|
||||
config: ctx.config,
|
||||
owner,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,6 +140,21 @@ export function ScriptDryRunPage() {
|
||||
<span className="text-sm opacity-60">{lastRun.durationMs}ms</span>
|
||||
) : null}
|
||||
<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
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState } from "react";
|
||||
import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useDeleteSecret,
|
||||
useOwners,
|
||||
useSecrets,
|
||||
useUpsertSecret,
|
||||
} from "../api/hooks.js";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
|
||||
export function SecretsPage() {
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [ownerFilter, setOwnerFilter] = useState("");
|
||||
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 [confirmDelete, setConfirmDelete] = useState(null);
|
||||
|
||||
function openAdd() {
|
||||
setMode("add");
|
||||
setForm({
|
||||
owner: ownerFilter || owners[0] || "default",
|
||||
name: "",
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
|
||||
function openReplace(s) {
|
||||
setMode("replace");
|
||||
setForm({ owner: s.owner, name: s.name, value: "" });
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">Secrets</h1>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={ownerFilter}
|
||||
onChange={(e) => setOwnerFilter(e.target.value)}
|
||||
>
|
||||
<option value="">all owners</option>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<span className="loading loading-spinner" />
|
||||
) : secrets.length === 0 ? (
|
||||
<p className="text-sm opacity-60">No secrets yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Name</th>
|
||||
<th>Updated</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{secrets.map((s) => (
|
||||
<tr key={s.id} className="hover">
|
||||
<td className="font-mono">{s.owner}</td>
|
||||
<td className="font-mono">{s.name}</td>
|
||||
<td className="whitespace-nowrap">{formatTime(s.updated_at)}</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Replace value"
|
||||
onClick={() => openReplace(s)}
|
||||
>
|
||||
<LuPencil className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
title="Delete"
|
||||
onClick={() => setConfirmDelete(s)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode ? (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="fieldset bg-base-100 border-base-300 rounded-box max-w-md border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<legend className="fieldset-legend">
|
||||
{mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`}
|
||||
</legend>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
onClick={closeForm}
|
||||
aria-label="Close"
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{mode === "add" ? (
|
||||
<>
|
||||
<label className="label">Owner</label>
|
||||
{owners.length > 0 ? (
|
||||
<select
|
||||
className="select w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<label className="label">Name</label>
|
||||
<input
|
||||
className="input 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"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<label className="label">Value</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full"
|
||||
value={form.value}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
Values are encrypted at rest and never shown again after save.
|
||||
</p>
|
||||
{upsert.isError ? (
|
||||
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary mt-2" disabled={upsert.isPending}>
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{confirmDelete ? (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold">
|
||||
Delete {confirmDelete.owner}/{confirmDelete.name}?
|
||||
</h3>
|
||||
<p className="text-sm mt-2">This cannot be undone. Workflows that retrieve this name will fail.</p>
|
||||
{del.isError ? (
|
||||
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
|
||||
) : null}
|
||||
<div className="modal-action">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error"
|
||||
disabled={del.isPending}
|
||||
onClick={() =>
|
||||
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={() => setConfirmDelete(null)}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user