diff --git a/packages/server/http-auths-store.js b/packages/server/http-auths-store.js index afb805c..45e4661 100644 --- a/packages/server/http-auths-store.js +++ b/packages/server/http-auths-store.js @@ -4,12 +4,27 @@ import { assertHttpStatus } from "./http-pages-store.js"; const MAX_NAME_LENGTH = 128; const NAME_RE = /^[A-Za-z0-9._-]+$/; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]); function nowIso() { return new Date().toISOString(); } +/** + * @param {unknown} id + * @returns {string} + */ +export function assertAuthId(id) { + if (typeof id !== "string" || !UUID_RE.test(id)) { + const err = new Error("invalid auth id"); + err.statusCode = 400; + throw err; + } + return id.toLowerCase(); +} + /** * @param {unknown} name * @returns {string} @@ -218,10 +233,11 @@ function publicAuth(row, { includeConfig = true } = {}) { /** * Internal: full config including literals (for runtime auth checks). - * @param {string} name + * @param {string} id */ -export async function getHttpAuthInternal(name) { - const row = await db("http_auths").where({ name: assertAuthName(name) }).first(); +export async function getHttpAuthInternal(id) { + const authId = assertAuthId(id); + const row = await db("http_auths").where({ id: authId }).first(); if (!row) return null; return { id: row.id, @@ -235,11 +251,11 @@ export async function getHttpAuthInternal(name) { /** * Return only plaintext literal credential fields (not KV refs or encrypted secrets). - * @param {string} name - * @returns {Promise<{ name: string, type: string, literals: Record } | null>} + * @param {string} id + * @returns {Promise<{ id: string, name: string, type: string, literals: Record } | null>} */ -export async function revealHttpAuthLiterals(name) { - const internal = await getHttpAuthInternal(name); +export async function revealHttpAuthLiterals(id) { + const internal = await getHttpAuthInternal(id); if (!internal) return null; /** @type {Record} */ const literals = {}; @@ -248,7 +264,12 @@ export async function revealHttpAuthLiterals(name) { const v = cfg[key]; if (typeof v === "string") literals[key] = v; } - return { name: internal.name, type: internal.type, literals }; + return { + id: internal.id, + name: internal.name, + type: internal.type, + literals, + }; } export async function listHttpAuths() { @@ -256,24 +277,23 @@ export async function listHttpAuths() { return rows.map((r) => publicAuth(r)); } -/** - * @param {string} name - */ -export async function getHttpAuthByName(name) { - const row = await db("http_auths").where({ name: assertAuthName(name) }).first(); - return row ? publicAuth(row) : null; -} - /** * @param {string} id */ export async function getHttpAuthById(id) { - const row = await db("http_auths").where({ id }).first(); + let authId; + try { + authId = assertAuthId(id); + } catch { + return null; + } + const row = await db("http_auths").where({ id: authId }).first(); return row ? publicAuth(row) : null; } /** * @param {{ + * id?: string | null, * name: string, * type: string, * config?: unknown, @@ -282,6 +302,7 @@ export async function getHttpAuthById(id) { * }} opts */ export async function upsertHttpAuth({ + id, name, type, config, @@ -290,7 +311,26 @@ export async function upsertHttpAuth({ }) { const authName = assertAuthName(name); const authType = assertAuthType(type); - const existing = await db("http_auths").where({ name: authName }).first(); + + /** @type {Record | null} */ + let existing = null; + if (id != null && String(id).length > 0) { + const authId = assertAuthId(id); + existing = await db("http_auths").where({ id: authId }).first(); + if (!existing) { + const err = new Error("auth not found"); + err.statusCode = 404; + throw err; + } + } + + const nameClash = await db("http_auths").where({ name: authName }).first(); + if (nameClash && (!existing || nameClash.id !== existing.id)) { + const err = new Error(`auth name "${authName}" already exists`); + err.statusCode = 409; + throw err; + } + const prevConfig = existing ? parseConfig(existing.config) : {}; const normalized = normalizeAuthConfig(authType, config, { keepLiteralsFrom: prevConfig, @@ -315,18 +355,19 @@ export async function upsertHttpAuth({ await db("http_auths") .where({ id: existing.id }) .update({ + name: authName, type: authType, config: configJson, unauthorized_status: unauthStatus, unauthorized_response: unauthResponse, updated_at: now, }); - return getHttpAuthById(existing.id); + return getHttpAuthById(/** @type {string} */ (existing.id)); } - const id = randomUUID(); + const newId = randomUUID(); await db("http_auths").insert({ - id, + id: newId, name: authName, type: authType, config: configJson, @@ -335,7 +376,7 @@ export async function upsertHttpAuth({ created_at: now, updated_at: now, }); - return getHttpAuthById(id); + return getHttpAuthById(newId); } /** @@ -343,6 +384,7 @@ export async function upsertHttpAuth({ * @returns {Promise} */ export async function deleteHttpAuth(id) { - const n = await db("http_auths").where({ id }).del(); + const authId = assertAuthId(id); + const n = await db("http_auths").where({ id: authId }).del(); return n > 0; } diff --git a/packages/server/http-trigger-auth.js b/packages/server/http-trigger-auth.js index c5d1e86..24b0eff 100644 --- a/packages/server/http-trigger-auth.js +++ b/packages/server/http-trigger-auth.js @@ -77,38 +77,47 @@ export async function resolveCredentialValue(field, ctx) { } /** - * Normalize trigger.auth into an inline auth mechanism object. - * @param {unknown} authField - * @returns {Promise<{ + * @typedef {{ * type: string, * config: Record, * unauthorized_status?: number | null, * unauthorized_response?: string | null, * label: string, - * } | null>} + * }} AuthMechanism */ -export async function resolveAuthMechanism(authField) { - if (authField == null || authField === false) return null; - if (typeof authField === "string") { - const named = await getHttpAuthInternal(authField); - if (!named) { - log.warn({ name: authField }, "http auth: named profile not found"); +/** + * Resolve one auth entry (auth profile id UUID, or inline object). + * @param {unknown} entry + * @returns {Promise} + */ +export async function resolveAuthMechanism(entry) { + if (entry == null || entry === false) return null; + + if (typeof entry === "string") { + try { + const named = await getHttpAuthInternal(entry); + if (!named) { + log.warn({ id: entry }, "http auth: profile id not found"); + return null; + } + return { + type: named.type, + config: named.config, + unauthorized_status: named.unauthorized_status, + unauthorized_response: named.unauthorized_response, + label: named.name, + }; + } catch (err) { + log.warn({ err, id: entry }, "http auth: invalid profile id"); return null; } - return { - type: named.type, - config: named.config, - unauthorized_status: named.unauthorized_status, - unauthorized_response: named.unauthorized_response, - label: authField, - }; } - if (typeof authField === "object" && !Array.isArray(authField)) { - const obj = /** @type {Record} */ (authField); - if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) { - return resolveAuthMechanism(obj.name); + if (typeof entry === "object" && !Array.isArray(entry)) { + const obj = /** @type {Record} */ (entry); + if (typeof obj.id === "string" && obj.id.length > 0 && !obj.type) { + return resolveAuthMechanism(obj.id); } try { const type = assertAuthType(obj.type); @@ -116,6 +125,7 @@ export async function resolveAuthMechanism(authField) { const config = { ...obj }; delete config.type; delete config.name; + delete config.id; return { type, config, @@ -133,18 +143,64 @@ export async function resolveAuthMechanism(authField) { } /** - * Label for mermaid / summary (sync, no DB). + * Normalize trigger.auth (array of auth ids / inline objects) into mechanisms. + * Empty / null / false → no auth. Any entry that fails to resolve is skipped; + * if the field was non-empty but nothing resolves, returns [] (caller treats as unauthorized). * @param {unknown} authField + * @returns {Promise} */ -export function authLabel(authField) { - if (authField == null) return null; - if (typeof authField === "string") return authField; - if (typeof authField === "object" && !Array.isArray(authField)) { - const o = /** @type {Record} */ (authField); - if (typeof o.name === "string" && o.name) return o.name; - if (typeof o.type === "string" && o.type) return o.type; +export async function resolveAuthMechanisms(authField) { + if (authField == null || authField === false) return []; + if (!Array.isArray(authField) || authField.length === 0) return []; + + /** @type {AuthMechanism[]} */ + const out = []; + for (const entry of authField) { + const mech = await resolveAuthMechanism(entry); + if (mech) out.push(mech); } - return "auth"; + return out; +} + +/** + * True if any mechanism accepts the request (OR). + * @param {import("fastify").FastifyRequest} req + * @param {AuthMechanism[]} mechanisms + * @param {{ owner: string, workflowKey: string }} ctx + */ +export async function checkAnyHttpAuth(req, mechanisms, ctx) { + for (const mechanism of mechanisms) { + if (await checkHttpAuth(req, mechanism, ctx)) return true; + } + return false; +} + +/** + * Label for mermaid / summary (sync). Prefer resolved display names when provided. + * @param {unknown} authField + * @param {Map | Record} [nameById] + */ +export function authLabel(authField, nameById) { + if (authField == null || authField === false) return null; + if (!Array.isArray(authField) || authField.length === 0) return null; + const lookup = + nameById instanceof Map + ? (id) => nameById.get(id) + : nameById + ? (id) => nameById[id] + : () => undefined; + const parts = authField.map((entry) => { + if (typeof entry === "string") return lookup(entry) ?? entry; + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const o = /** @type {Record} */ (entry); + if (typeof o.id === "string" && o.id && !o.type) { + return lookup(o.id) ?? o.id; + } + if (typeof o.type === "string" && o.type) return o.type; + } + return "auth"; + }); + return parts.join("|"); } /** diff --git a/packages/server/src/api/http-auths.js b/packages/server/src/api/http-auths.js index c7d85f0..b729953 100644 --- a/packages/server/src/api/http-auths.js +++ b/packages/server/src/api/http-auths.js @@ -1,9 +1,9 @@ import { + assertAuthId, assertAuthName, assertAuthType, listHttpAuths, getHttpAuthById, - getHttpAuthByName, upsertHttpAuth, deleteHttpAuth, revealHttpAuthLiterals, @@ -18,28 +18,28 @@ export default async function httpAuthsPlugin(fastify) { return { auths: await listHttpAuths() }; }); - fastify.get("/http-auths/:name/reveal", async (req, reply) => { - const { name } = /** @type {{ name: string }} */ (req.params); + fastify.get("/http-auths/:id/reveal", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); try { - assertAuthName(name); + assertAuthId(id); } catch (err) { return reply.code(err.statusCode ?? 400).send({ error: err.message }); } - const revealed = await revealHttpAuthLiterals(name); + const revealed = await revealHttpAuthLiterals(id); if (!revealed) { return reply.code(404).send({ error: "auth not found" }); } return revealed; }); - fastify.get("/http-auths/:name", async (req, reply) => { - const { name } = /** @type {{ name: string }} */ (req.params); + fastify.get("/http-auths/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); try { - assertAuthName(name); + assertAuthId(id); } catch (err) { return reply.code(err.statusCode ?? 400).send({ error: err.message }); } - const auth = await getHttpAuthByName(name); + const auth = await getHttpAuthById(id); if (!auth) { return reply.code(404).send({ error: "auth not found" }); } @@ -48,6 +48,7 @@ export default async function httpAuthsPlugin(fastify) { fastify.put("/http-auths", async (req, reply) => { const body = /** @type {{ + id?: string | null, name?: string, type?: string, config?: unknown, @@ -57,6 +58,9 @@ export default async function httpAuthsPlugin(fastify) { try { assertAuthName(String(body.name ?? "")); assertAuthType(body.type); + if (body.id != null && String(body.id).length > 0) { + assertAuthId(String(body.id)); + } if ( body.unauthorized_response != null && String(body.unauthorized_response).length > 0 @@ -69,6 +73,7 @@ export default async function httpAuthsPlugin(fastify) { ); } const auth = await upsertHttpAuth({ + id: body.id != null && String(body.id).length > 0 ? String(body.id) : null, name: String(body.name), type: String(body.type), config: body.config, @@ -83,6 +88,11 @@ export default async function httpAuthsPlugin(fastify) { fastify.delete("/http-auths/:id", async (req, reply) => { const { id } = /** @type {{ id: string }} */ (req.params); + try { + assertAuthId(id); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } const existing = await getHttpAuthById(id); if (!existing) { return reply.code(404).send({ error: "auth not found" }); diff --git a/packages/server/test/http-trigger-auth-smoke.js b/packages/server/test/http-trigger-auth-smoke.js index 06e64ea..7d300e5 100644 --- a/packages/server/test/http-trigger-auth-smoke.js +++ b/packages/server/test/http-trigger-auth-smoke.js @@ -12,9 +12,12 @@ import { getHttpAuthInternal, } from "../http-auths-store.js"; import { + authLabel, + checkAnyHttpAuth, checkHttpAuth, coerceCredentialString, resolveAuthMechanism, + resolveAuthMechanisms, resolveCredentialValue, resolveUnauthorizedSpec, sendHttpPageOrJson, @@ -176,11 +179,11 @@ const secret = await upsertSecret({ config: { token: { secret: "does_not_exist_xyz" } }, }, ctx, - ); + ); assert(!missingSec, "missing secret fails closed"); } -// --- named profile --- +// --- named profile (by id) --- const profile = await upsertHttpAuth({ name: "webhook-smoke", type: "bearer", @@ -188,9 +191,10 @@ const profile = await upsertHttpAuth({ unauthorized_status: 403, unauthorized_response: "deny-smoke", }); +assert(typeof profile.id === "string" && profile.id.length > 0, "profile has id"); { - const mech = await resolveAuthMechanism("webhook-smoke"); - assert(mech?.label === "webhook-smoke", "named profile"); + const mech = await resolveAuthMechanism(profile.id); + assert(mech?.label === "webhook-smoke", "profile by id"); const ok = await checkHttpAuth( mockReq({ authorization: "Bearer named-token" }), mech, @@ -202,9 +206,68 @@ const profile = await upsertHttpAuth({ assert(pageName === "deny-smoke", "profile unauth page"); } +// --- rename keeps id --- +{ + const renamed = await upsertHttpAuth({ + id: profile.id, + name: "webhook-renamed", + type: "bearer", + config: { token: { keep: true } }, + unauthorized_status: 403, + unauthorized_response: "deny-smoke", + }); + assert(renamed.id === profile.id, "rename keeps id"); + assert(renamed.name === "webhook-renamed", "rename updates name"); + const mech = await resolveAuthMechanism(profile.id); + assert(mech?.label === "webhook-renamed", "resolve uses new name label"); + const ok = await checkHttpAuth( + mockReq({ authorization: "Bearer named-token" }), + mech, + ctx, + ); + assert(ok, "credentials survive rename"); +} + +// --- multi-auth OR --- +const basicProfile = await upsertHttpAuth({ + name: "basic-smoke", + type: "basic", + config: { user: "bob", password: "p@ss" }, +}); +{ + const mechs = await resolveAuthMechanisms([profile.id, basicProfile.id]); + assert(mechs.length === 2, "resolve two mechanisms"); + assert( + authLabel([profile.id, basicProfile.id], { + [profile.id]: "webhook-renamed", + [basicProfile.id]: "basic-smoke", + }) === "webhook-renamed|basic-smoke", + "authLabel", + ); + const viaBearer = await checkAnyHttpAuth( + mockReq({ authorization: "Bearer named-token" }), + mechs, + ctx, + ); + assert(viaBearer, "OR accepts bearer"); + const encoded = Buffer.from("bob:p@ss").toString("base64"); + const viaBasic = await checkAnyHttpAuth( + mockReq({ authorization: `Basic ${encoded}` }), + mechs, + ctx, + ); + assert(viaBasic, "OR accepts basic"); + const neither = await checkAnyHttpAuth( + mockReq({ authorization: "Bearer wrong" }), + mechs, + ctx, + ); + assert(!neither, "OR rejects when none match"); +} + // trigger-level override { - const mech = await getHttpAuthInternal("webhook-smoke"); + const mech = await getHttpAuthInternal(profile.id); const { status, pageName } = resolveUnauthorizedSpec( { unauthorized: { status: 401, response: "deny-smoke" } }, mech, @@ -226,7 +289,7 @@ await validateWorkflowHttpTriggers({ type: "HTTP", method: "POST", path: "/x", - auth: "webhook-smoke", + auth: [profile.id, basicProfile.id], response: "deny-smoke", }, ], @@ -235,12 +298,38 @@ await validateWorkflowHttpTriggers({ let threw = false; try { await validateWorkflowHttpTriggers({ - triggers: [{ type: "HTTP", path: "/x", auth: "no-such-profile" }], + triggers: [{ type: "HTTP", path: "/x", auth: profile.id }], }); } catch { threw = true; } -assert(threw, "unknown auth fails validation"); +assert(threw, "non-array auth fails validation"); + +threw = false; +try { + await validateWorkflowHttpTriggers({ + triggers: [{ type: "HTTP", path: "/x", auth: ["webhook-renamed"] }], + }); +} catch { + threw = true; +} +assert(threw, "name string fails validation"); + +threw = false; +try { + await validateWorkflowHttpTriggers({ + triggers: [ + { + type: "HTTP", + path: "/x", + auth: ["00000000-0000-4000-8000-000000000000"], + }, + ], + }); +} catch { + threw = true; +} +assert(threw, "unknown auth id fails validation"); threw = false; try { @@ -280,6 +369,7 @@ assert(threw, "unknown page fails validation"); // cleanup await deleteHttpAuth(profile.id); +await deleteHttpAuth(basicProfile.id); await deleteHttpPage(page.id); await deleteSecret(secret.id); const leftover = (await listSecrets({ owner })).find( diff --git a/packages/server/workflow-http-validate.js b/packages/server/workflow-http-validate.js index 58a0b06..2f44ce7 100644 --- a/packages/server/workflow-http-validate.js +++ b/packages/server/workflow-http-validate.js @@ -1,7 +1,11 @@ /** * Validate HTTP trigger auth / response fields on workflow save. */ -import { assertAuthType, getHttpAuthByName } from "./http-auths-store.js"; +import { + assertAuthId, + assertAuthType, + getHttpAuthById, +} from "./http-auths-store.js"; import { getHttpPageByName, assertHttpResponsePage } from "./http-pages-store.js"; import { authLabel } from "./http-trigger-auth.js"; @@ -24,51 +28,80 @@ function assertCredentialFieldShape(field, label) { } /** - * @param {unknown} auth + * @param {unknown} entry + * @param {string} path */ -async function validateAuthField(auth) { - if (auth == null || auth === false) return; - - if (typeof auth === "string") { - const named = await getHttpAuthByName(auth); +async function validateAuthEntry(entry, path) { + if (typeof entry === "string") { + try { + assertAuthId(entry); + } catch { + const err = new Error(`${path} must be an auth profile UUID`); + err.statusCode = 400; + throw err; + } + const named = await getHttpAuthById(entry); if (!named) { - const err = new Error(`unknown auth profile "${auth}"`); + const err = new Error(`unknown auth profile id "${entry}"`); err.statusCode = 400; throw err; } return; } - if (typeof auth === "object" && !Array.isArray(auth)) { - const obj = /** @type {Record} */ (auth); - if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) { - await validateAuthField(obj.name); + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const obj = /** @type {Record} */ (entry); + if (typeof obj.id === "string" && obj.id.length > 0 && !obj.type) { + await validateAuthEntry(obj.id, path); return; } const type = assertAuthType(obj.type); if (type === "bearer") { - assertCredentialFieldShape(obj.token, "auth.token"); + assertCredentialFieldShape(obj.token, `${path}.token`); } else if (type === "basic") { - assertCredentialFieldShape(obj.user, "auth.user"); + assertCredentialFieldShape(obj.user, `${path}.user`); if (obj.password != null && obj.password !== "") { - assertCredentialFieldShape(obj.password, "auth.password"); + assertCredentialFieldShape(obj.password, `${path}.password`); } } else if (type === "header") { if (typeof obj.header !== "string" || obj.header.length === 0) { - const err = new Error("auth.header must be a non-empty string"); + const err = new Error(`${path}.header must be a non-empty string`); err.statusCode = 400; throw err; } - assertCredentialFieldShape(obj.value, "auth.value"); + assertCredentialFieldShape(obj.value, `${path}.value`); } return; } - const err = new Error("auth must be a profile name or an auth object"); + const err = new Error( + `${path} must be an auth profile UUID or an inline auth object`, + ); err.statusCode = 400; throw err; } +/** + * auth is an array of auth profile UUIDs and/or inline auth objects (OR). + * null / false / [] = no auth. + * @param {unknown} auth + */ +async function validateAuthField(auth) { + if (auth == null || auth === false) return; + + if (!Array.isArray(auth)) { + const err = new Error( + "auth must be an array of auth profile UUIDs and/or inline auth objects", + ); + err.statusCode = 400; + throw err; + } + + for (let i = 0; i < auth.length; i++) { + await validateAuthEntry(auth[i], `auth[${i}]`); + } +} + /** * @param {unknown} pageName * @param {string} label diff --git a/packages/server/workflows/default/dev-zte-sms.yaml b/packages/server/workflows/default/dev-zte-sms.yaml index e351541..1a85a56 100644 --- a/packages/server/workflows/default/dev-zte-sms.yaml +++ b/packages/server/workflows/default/dev-zte-sms.yaml @@ -13,4 +13,5 @@ triggers: - type: HTTP method: POST path: /dev-zte-sms - auth: basic-auth + auth: + - 0f78d6d7-bd44-45d7-a826-f51c027b767f diff --git a/packages/server/workflows/default/send-gmail.yaml b/packages/server/workflows/default/send-gmail.yaml index d6b42cf..e0d5889 100644 --- a/packages/server/workflows/default/send-gmail.yaml +++ b/packages/server/workflows/default/send-gmail.yaml @@ -36,4 +36,5 @@ triggers: - type: HTTP method: POST path: /send-gmail - auth: basic-auth + auth: + - 0f78d6d7-bd44-45d7-a826-f51c027b767f diff --git a/packages/server/workflows/default/time-to-ntfy-example.yaml b/packages/server/workflows/default/time-to-ntfy-example.yaml index 34404f9..67dcdf3 100644 --- a/packages/server/workflows/default/time-to-ntfy-example.yaml +++ b/packages/server/workflows/default/time-to-ntfy-example.yaml @@ -1,6 +1,7 @@ name: time to ntfy example description: | this workflow will send a message to ntfy with the current time +enabled: false scripts: - script: plugin/get-current-time config: @@ -12,3 +13,5 @@ triggers: - type: HTTP method: POST path: /time-to-ntfy + auth: + - 0f78d6d7-bd44-45d7-a826-f51c027b767f diff --git a/packages/web/src/components/AuthEditorModal.jsx b/packages/web/src/components/AuthEditorModal.jsx new file mode 100644 index 0000000..8b01e7d --- /dev/null +++ b/packages/web/src/components/AuthEditorModal.jsx @@ -0,0 +1,422 @@ +import { useEffect, useState } from "react"; +import { LuEye, LuEyeOff } from "react-icons/lu"; +import { errorMessage } from "../api/client.js"; +import { + fetchHttpAuthLiterals, + useHttpPages, + useUpsertHttpAuth, +} from "../api/hooks.js"; + +function emptyCred(source = "literal") { + return { source, value: "", kv: "", namespace: "", secret: "" }; +} + +function credFromPublic(field, literalValue) { + if (!field || field.source === "missing") return emptyCred("literal"); + if (field.source === "kv") { + return { + source: "kv", + value: "", + kv: field.kv ?? "", + namespace: field.namespace ?? "", + secret: "", + }; + } + if (field.source === "secret") { + return { + source: "secret", + value: "", + kv: "", + namespace: "", + secret: field.secret ?? "", + }; + } + if (typeof literalValue === "string") { + return { + source: "literal", + value: literalValue, + kv: "", + namespace: "", + secret: "", + keep: true, + }; + } + return { + source: "literal", + value: "", + kv: "", + namespace: "", + secret: "", + keep: field.set === true, + }; +} + +function toApiField(cred, { required = true } = {}) { + if (cred.source === "kv") { + const out = { kv: cred.kv }; + if (cred.namespace) out.namespace = cred.namespace; + return out; + } + if (cred.source === "secret") { + return { secret: cred.secret }; + } + if (cred.value) return cred.value; + if (cred.keep) return { keep: true }; + if (!required) return ""; + return null; +} + +function emptyForm() { + return { + id: null, + name: "", + type: "bearer", + token: emptyCred(), + user: emptyCred(), + password: emptyCred(), + header: "", + value: emptyCred(), + unauthorized_status: "", + unauthorized_response: "", + }; +} + +function formFromAuth(auth, literals = {}) { + const cfg = auth.config ?? {}; + return { + id: auth.id, + name: auth.name, + type: auth.type, + token: credFromPublic(cfg.token, literals.token), + user: credFromPublic(cfg.user, literals.user), + password: credFromPublic(cfg.password, literals.password), + header: cfg.header ?? "", + value: credFromPublic(cfg.value, literals.value), + unauthorized_status: auth.unauthorized_status ?? "", + unauthorized_response: auth.unauthorized_response ?? "", + }; +} + +function Field({ label, children, hint }) { + return ( +
+
+ {label} +
+ {children} + {hint ? ( +
+ {hint} +
+ ) : null} +
+ ); +} + +function CredentialFields({ label, cred, onChange, allowEmpty, masked }) { + const [show, setShow] = useState(false); + + return ( +
+

{label}

+ + + + {cred.source === "literal" ? ( + +
+ onChange({ ...cred, value: e.target.value, keep: false })} + placeholder={ + cred.keep && !cred.value ? "(unchanged — leave blank to keep)" : "" + } + required={!allowEmpty && !cred.keep && !cred.value} + autoComplete="off" + /> + {masked ? ( + + ) : null} +
+
+ ) : null} + {cred.source === "kv" ? ( + <> + + onChange({ ...cred, namespace: e.target.value })} + /> + + + onChange({ ...cred, kv: e.target.value })} + required + /> + + + ) : null} + {cred.source === "secret" ? ( + + onChange({ ...cred, secret: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + /> + + ) : null} +
+ ); +} + +/** + * Add / edit an HTTP trigger auth profile. + * Reusable: mount when open; pass `auth` for edit (literals loaded inside). + * + * @param {"add" | "edit"} mode + * @param {object} [auth] Public auth row when mode is "edit" + * @param {() => void} onClose + * @param {(saved: unknown) => void} [onSaved] + */ +export function AuthEditorModal({ mode, auth, onClose, onSaved }) { + const { data: pages = [] } = useHttpPages(); + const upsert = useUpsertHttpAuth(); + const [form, setForm] = useState(emptyForm); + const [loading, setLoading] = useState(mode === "edit"); + + useEffect(() => { + if (mode !== "edit" || !auth?.id) { + setForm(emptyForm()); + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + (async () => { + /** @type {Record} */ + let literals = {}; + try { + const data = await fetchHttpAuthLiterals(auth.id); + literals = data.literals ?? {}; + } catch { + // Form still works with keep markers + } + if (cancelled) return; + setForm(formFromAuth(auth, literals)); + setLoading(false); + })(); + return () => { + cancelled = true; + }; + }, [mode, auth]); + + function onSubmit(e) { + e.preventDefault(); + /** @type {Record} */ + let config = {}; + if (form.type === "bearer") { + const token = toApiField(form.token); + if (token == null) return; + config = { token }; + } else if (form.type === "basic") { + const user = toApiField(form.user); + if (user == null) return; + const password = toApiField(form.password, { required: false }); + config = { user, password: password ?? "" }; + } else { + const value = toApiField(form.value); + if (value == null) return; + config = { header: form.header, value }; + } + + upsert.mutate( + { + id: form.id, + name: form.name, + type: form.type, + config, + unauthorized_status: + form.unauthorized_status === "" ? null : Number(form.unauthorized_status), + unauthorized_response: form.unauthorized_response || null, + }, + { + onSuccess: (data) => { + onSaved?.(data?.auth ?? data); + onClose(); + }, + }, + ); + } + + const title = mode === "add" ? "New auth profile" : `Edit ${form.name || auth?.name || ""}`; + + return ( + +
+

{title}

+ {loading ? ( +
+ +
+ ) : ( +
+ + setForm({ ...form, name: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + autoComplete="off" + /> + + + + + + + {form.type === "bearer" ? ( + setForm({ ...form, token })} + /> + ) : null} + {form.type === "basic" ? ( + <> + setForm({ ...form, user })} + /> + setForm({ ...form, password })} + allowEmpty + masked + /> + + ) : null} + {form.type === "header" ? ( + <> + + setForm({ ...form, header: e.target.value })} + required + placeholder="X-Webhook-Secret" + /> + + setForm({ ...form, value })} + /> + + ) : null} + + + setForm({ ...form, unauthorized_status: e.target.value })} + min={100} + max={599} + placeholder="401" + /> + + + + + + + {upsert.isError ? ( +

{errorMessage(upsert.error)}

+ ) : null} +
+ + +
+ + )} + {loading ? ( +
+ +
+ ) : null} +
+
+ +
+
+ ); +} diff --git a/packages/web/src/components/workflow/TriggerCard.jsx b/packages/web/src/components/workflow/TriggerCard.jsx index 4e735d2..a7df469 100644 --- a/packages/web/src/components/workflow/TriggerCard.jsx +++ b/packages/web/src/components/workflow/TriggerCard.jsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2, LuX } from "react-icons/lu"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import cronstrue from "cronstrue"; @@ -138,15 +138,27 @@ function typeLabel(type) { return type || "Trigger"; } +function Field({ label, children, hint }) { + return ( +
+ {label ? {label} : null} + {children} + {hint ? {hint} : null} +
+ ); +} + function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDestinations }) { const path = trigger.path || "/"; const url = namespacedPath(owner || "owner", path); - const authIsInline = trigger.auth != null && typeof trigger.auth === "object"; - const authSelect = authIsInline - ? "__inline__" - : typeof trigger.auth === "string" && trigger.auth - ? trigger.auth - : ""; + const authEntries = Array.isArray(trigger.auth) ? trigger.auth : []; + const selectedIds = authEntries.filter((e) => typeof e === "string" && e).map(String); + const inlineEntries = authEntries.filter((e) => e && typeof e === "object"); + + function setAuthIds(nextIds) { + const next = [...nextIds, ...inlineEntries]; + onChange({ ...trigger, auth: next.length ? next : null }); + } function copyUrl() { if (typeof navigator?.clipboard?.writeText === "function") { @@ -155,11 +167,10 @@ function HttpFields({ trigger, owner, disabled, onChange, auths, pages, alertDes } return ( -
-