diff --git a/packages/server/http-auths-store.js b/packages/server/http-auths-store.js index 2f0cda8..afb805c 100644 --- a/packages/server/http-auths-store.js +++ b/packages/server/http-auths-store.js @@ -233,6 +233,24 @@ 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>} + */ +export async function revealHttpAuthLiterals(name) { + const internal = await getHttpAuthInternal(name); + if (!internal) return null; + /** @type {Record} */ + const literals = {}; + const cfg = internal.config ?? {}; + for (const key of ["token", "user", "password", "value"]) { + const v = cfg[key]; + if (typeof v === "string") literals[key] = v; + } + return { name: internal.name, type: internal.type, literals }; +} + export async function listHttpAuths() { const rows = await db("http_auths").select("*").orderBy("name", "asc"); return rows.map((r) => publicAuth(r)); diff --git a/packages/server/src/api/http-auths.js b/packages/server/src/api/http-auths.js index 99264ec..d579710 100644 --- a/packages/server/src/api/http-auths.js +++ b/packages/server/src/api/http-auths.js @@ -6,6 +6,7 @@ import { getHttpAuthByName, upsertHttpAuth, deleteHttpAuth, + revealHttpAuthLiterals, } from "../../http-auths-store.js"; import { getHttpPageByName } from "../../http-pages-store.js"; @@ -17,6 +18,20 @@ 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); + try { + assertAuthName(name); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + const revealed = await revealHttpAuthLiterals(name); + 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); try { diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index eefdb69..ec87e50 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -383,3 +383,8 @@ export function useDeleteHttpAuth() { onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }), }); } + +/** Fetch plaintext literals only (not encrypted secrets). */ +export async function fetchHttpAuthLiterals(name) { + return (await api.get(`/http-auths/${encodeURIComponent(name)}/reveal`)).data; +} diff --git a/packages/web/src/pages/AuthProfilesPage.jsx b/packages/web/src/pages/AuthProfilesPage.jsx index b8761a3..5106109 100644 --- a/packages/web/src/pages/AuthProfilesPage.jsx +++ b/packages/web/src/pages/AuthProfilesPage.jsx @@ -1,7 +1,8 @@ import { useState } from "react"; -import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu"; +import { LuEye, LuEyeOff, LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu"; import { errorMessage } from "../api/client.js"; import { + fetchHttpAuthLiterals, useDeleteHttpAuth, useHttpAuths, useHttpPages, @@ -13,7 +14,7 @@ function emptyCred(source = "literal") { return { source, value: "", kv: "", namespace: "", secret: "" }; } -function credFromPublic(field) { +function credFromPublic(field, literalValue) { if (!field || field.source === "missing") return emptyCred("literal"); if (field.source === "kv") { return { @@ -33,7 +34,17 @@ function credFromPublic(field) { secret: field.secret ?? "", }; } - // literal — already set; keep marker so we don't require re-entry on edit + // literal — prefer revealed value when available + if (typeof literalValue === "string") { + return { + source: "literal", + value: literalValue, + kv: "", + namespace: "", + secret: "", + keep: true, + }; + } return { source: "literal", value: "", @@ -59,17 +70,9 @@ function toApiField(cred, { required = true } = {}) { return null; } -function sourceLabel(field) { - if (!field || field.source === "missing") return "—"; - if (field.source === "kv") { - return field.namespace ? `kv:${field.namespace}/${field.kv}` : `kv:${field.kv}`; - } - if (field.source === "secret") return `secret:${field.secret}`; - if (field.source === "literal") return "literal"; - return "—"; -} +function CredentialFields({ label, cred, onChange, allowEmpty, masked }) { + const [show, setShow] = useState(false); -function CredentialFields({ label, cred, onChange, allowEmpty }) { return (
@@ -84,17 +87,34 @@ function CredentialFields({ label, cred, onChange, allowEmpty }) { {cred.source === "literal" ? ( <> - onChange({ ...cred, value: e.target.value, keep: false })} - placeholder={cred.keep ? "(unchanged — leave blank to keep)" : ""} - required={!allowEmpty && !cred.keep} - autoComplete="new-password" - /> - {cred.keep ? ( -

A value is already set. Enter a new one to replace it.

+
+ 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} +
+ {cred.keep && !cred.value ? ( +

+ A value is already set. Enter a new one to replace it. +

) : null} ) : null} @@ -116,19 +136,157 @@ function CredentialFields({ label, cred, onChange, allowEmpty }) {
) : null} {cred.source === "secret" ? ( - onChange({ ...cred, secret: e.target.value })} - required - pattern="[A-Za-z0-9._-]+" - /> + <> + onChange({ ...cred, secret: e.target.value })} + required + pattern="[A-Za-z0-9._-]+" + /> +

+ Encrypted secret — value is never shown here. Manage it on the Secrets page. +

+ ) : null} ); } +/** + * List cell: literals show *** with reveal; secrets never reveal; kv shows ref only. + */ +function CredDisplay({ field, fieldKey, authName, cache, onRevealed }) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + if (!field || field.source === "missing") { + return —; + } + + if (field.source === "secret") { + return ( + + secret:{field.secret} + + ); + } + + if (field.source === "kv") { + const ref = field.namespace + ? `kv:${field.namespace}/${field.kv}` + : `kv:${field.kv}`; + return {ref}; + } + + // literal + const revealed = cache?.[fieldKey]; + const shown = open && typeof revealed === "string"; + + async function toggle() { + if (open) { + setOpen(false); + return; + } + if (typeof revealed === "string") { + setOpen(true); + return; + } + setLoading(true); + setError(null); + try { + const data = await fetchHttpAuthLiterals(authName); + onRevealed?.(data.literals ?? {}); + setOpen(true); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } + } + + return ( + + {shown ? revealed : "***"} + + {error ? {error} : null} + + ); +} + +function CredentialsCell({ auth, cache, onRevealed }) { + const cfg = auth.config ?? {}; + if (auth.type === "bearer") { + return ( + + ); + } + if (auth.type === "basic") { + return ( + + + user + + + + pass + + + + ); + } + return ( + + {cfg.header ?? "?"} + + + ); +} + const emptyForm = () => ({ name: "", type: "bearer", @@ -149,23 +307,38 @@ export function AuthProfilesPage() { const [mode, setMode] = useState(null); const [form, setForm] = useState(emptyForm); const [confirmDelete, setConfirmDelete] = useState(null); + /** @type {[Record>, Function]} */ + const [revealCache, setRevealCache] = useState({}); + const [editLoading, setEditLoading] = useState(false); function openAdd() { setMode("add"); setForm(emptyForm()); } - function openEdit(a) { - setMode("edit"); + async function openEdit(a) { + setEditLoading(true); + /** @type {Record} */ + let literals = {}; + try { + const data = await fetchHttpAuthLiterals(a.name); + literals = data.literals ?? {}; + setRevealCache((prev) => ({ ...prev, [a.name]: literals })); + } catch { + // keep empty; form still works with keep markers + } finally { + setEditLoading(false); + } const cfg = a.config ?? {}; + setMode("edit"); setForm({ name: a.name, type: a.type, - token: credFromPublic(cfg.token), - user: credFromPublic(cfg.user), - password: credFromPublic(cfg.password), + 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), + value: credFromPublic(cfg.value, literals.value), unauthorized_status: a.unauthorized_status ?? "", unauthorized_response: a.unauthorized_response ?? "", }); @@ -204,19 +377,19 @@ export function AuthProfilesPage() { form.unauthorized_status === "" ? null : Number(form.unauthorized_status), unauthorized_response: form.unauthorized_response || null, }, - { onSuccess: closeForm }, + { + onSuccess: () => { + setRevealCache((prev) => { + const next = { ...prev }; + delete next[form.name]; + return next; + }); + closeForm(); + }, + }, ); } - function summarize(a) { - const cfg = a.config ?? {}; - if (a.type === "bearer") return sourceLabel(cfg.token); - if (a.type === "basic") { - return `user=${sourceLabel(cfg.user)} pass=${sourceLabel(cfg.password)}`; - } - return `${cfg.header ?? "?"} = ${sourceLabel(cfg.value)}`; - } - return (
@@ -253,13 +426,22 @@ export function AuthProfilesPage() { {a.name} {a.type} - {summarize(a)} + + + setRevealCache((prev) => ({ ...prev, [a.name]: literals })) + } + /> + {formatTime(a.updated_at)}