feat(server): add revealHttpAuthLiterals API and enhance web interface for credential management

- Implemented revealHttpAuthLiterals function to return plaintext credential fields from HTTP auth configurations.
- Added a new endpoint in the HTTP auths API to reveal credentials securely.
- Updated the web interface to fetch and display plaintext literals, improving user experience for managing credentials.
- Enhanced CredentialFields component to support revealing and masking credential values dynamically.
This commit is contained in:
2026-08-15 06:43:16 +07:00
parent d666dc001d
commit 5bfbafeb91
4 changed files with 270 additions and 49 deletions
+18
View File
@@ -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<string, string> } | null>}
*/
export async function revealHttpAuthLiterals(name) {
const internal = await getHttpAuthInternal(name);
if (!internal) return null;
/** @type {Record<string, string>} */
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() { export async function listHttpAuths() {
const rows = await db("http_auths").select("*").orderBy("name", "asc"); const rows = await db("http_auths").select("*").orderBy("name", "asc");
return rows.map((r) => publicAuth(r)); return rows.map((r) => publicAuth(r));
+15
View File
@@ -6,6 +6,7 @@ import {
getHttpAuthByName, getHttpAuthByName,
upsertHttpAuth, upsertHttpAuth,
deleteHttpAuth, deleteHttpAuth,
revealHttpAuthLiterals,
} from "../../http-auths-store.js"; } from "../../http-auths-store.js";
import { getHttpPageByName } from "../../http-pages-store.js"; import { getHttpPageByName } from "../../http-pages-store.js";
@@ -17,6 +18,20 @@ export default async function httpAuthsPlugin(fastify) {
return { auths: await listHttpAuths() }; 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) => { fastify.get("/http-auths/:name", async (req, reply) => {
const { name } = /** @type {{ name: string }} */ (req.params); const { name } = /** @type {{ name: string }} */ (req.params);
try { try {
+5
View File
@@ -383,3 +383,8 @@ export function useDeleteHttpAuth() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["http-auths"] }), 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;
}
+220 -37
View File
@@ -1,7 +1,8 @@
import { useState } from "react"; 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 { errorMessage } from "../api/client.js";
import { import {
fetchHttpAuthLiterals,
useDeleteHttpAuth, useDeleteHttpAuth,
useHttpAuths, useHttpAuths,
useHttpPages, useHttpPages,
@@ -13,7 +14,7 @@ function emptyCred(source = "literal") {
return { source, value: "", kv: "", namespace: "", secret: "" }; return { source, value: "", kv: "", namespace: "", secret: "" };
} }
function credFromPublic(field) { function credFromPublic(field, literalValue) {
if (!field || field.source === "missing") return emptyCred("literal"); if (!field || field.source === "missing") return emptyCred("literal");
if (field.source === "kv") { if (field.source === "kv") {
return { return {
@@ -33,7 +34,17 @@ function credFromPublic(field) {
secret: field.secret ?? "", 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 { return {
source: "literal", source: "literal",
value: "", value: "",
@@ -59,17 +70,9 @@ function toApiField(cred, { required = true } = {}) {
return null; return null;
} }
function sourceLabel(field) { function CredentialFields({ label, cred, onChange, allowEmpty, masked }) {
if (!field || field.source === "missing") return "—"; const [show, setShow] = useState(false);
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 }) {
return ( return (
<div className="space-y-1 border-base-300 border rounded-box p-3"> <div className="space-y-1 border-base-300 border rounded-box p-3">
<label className="label py-0">{label}</label> <label className="label py-0">{label}</label>
@@ -84,17 +87,34 @@ function CredentialFields({ label, cred, onChange, allowEmpty }) {
</select> </select>
{cred.source === "literal" ? ( {cred.source === "literal" ? (
<> <>
<div className="flex gap-1 items-center">
<input <input
type="password" type={masked && !show ? "password" : "text"}
className="input input-sm w-full" className="input input-sm w-full font-mono"
value={cred.value} value={cred.value}
onChange={(e) => onChange({ ...cred, value: e.target.value, keep: false })} onChange={(e) => onChange({ ...cred, value: e.target.value, keep: false })}
placeholder={cred.keep ? "(unchanged — leave blank to keep)" : ""} placeholder={
required={!allowEmpty && !cred.keep} cred.keep && !cred.value ? "(unchanged — leave blank to keep)" : ""
autoComplete="new-password" }
required={!allowEmpty && !cred.keep && !cred.value}
autoComplete="off"
/> />
{cred.keep ? ( {masked ? (
<p className="text-xs opacity-60">A value is already set. Enter a new one to replace it.</p> <button
type="button"
className="btn btn-ghost btn-sm btn-square"
title={show ? "Hide" : "Reveal"}
aria-label={show ? "Hide value" : "Reveal value"}
onClick={() => setShow((v) => !v)}
>
{show ? <LuEyeOff className="size-4" /> : <LuEye className="size-4" />}
</button>
) : null}
</div>
{cred.keep && !cred.value ? (
<p className="text-xs opacity-60">
A value is already set. Enter a new one to replace it.
</p>
) : null} ) : null}
</> </>
) : null} ) : null}
@@ -116,6 +136,7 @@ function CredentialFields({ label, cred, onChange, allowEmpty }) {
</div> </div>
) : null} ) : null}
{cred.source === "secret" ? ( {cred.source === "secret" ? (
<>
<input <input
className="input input-sm w-full font-mono" className="input input-sm w-full font-mono"
placeholder="secret name" placeholder="secret name"
@@ -124,11 +145,148 @@ function CredentialFields({ label, cred, onChange, allowEmpty }) {
required required
pattern="[A-Za-z0-9._-]+" pattern="[A-Za-z0-9._-]+"
/> />
<p className="text-xs opacity-60">
Encrypted secret — value is never shown here. Manage it on the Secrets page.
</p>
</>
) : null} ) : null}
</div> </div>
); );
} }
/**
* 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 <span className="opacity-50">—</span>;
}
if (field.source === "secret") {
return (
<span className="font-mono text-xs" title="Encrypted; cannot reveal">
secret:{field.secret}
</span>
);
}
if (field.source === "kv") {
const ref = field.namespace
? `kv:${field.namespace}/${field.kv}`
: `kv:${field.kv}`;
return <span className="font-mono text-xs">{ref}</span>;
}
// 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 (
<span className="inline-flex items-center gap-1 font-mono text-xs">
<span>{shown ? revealed : "***"}</span>
<button
type="button"
className="btn btn-ghost btn-xs btn-square"
title={shown ? "Hide" : "Reveal"}
aria-label={shown ? "Hide value" : "Reveal value"}
disabled={loading}
onClick={(e) => {
e.stopPropagation();
toggle();
}}
>
{loading ? (
<span className="loading loading-spinner loading-xs" />
) : shown ? (
<LuEyeOff className="size-3.5" />
) : (
<LuEye className="size-3.5" />
)}
</button>
{error ? <span className="text-error text-[10px]">{error}</span> : null}
</span>
);
}
function CredentialsCell({ auth, cache, onRevealed }) {
const cfg = auth.config ?? {};
if (auth.type === "bearer") {
return (
<CredDisplay
field={cfg.token}
fieldKey="token"
authName={auth.name}
cache={cache}
onRevealed={onRevealed}
/>
);
}
if (auth.type === "basic") {
return (
<span className="inline-flex flex-wrap gap-x-3 gap-y-1 items-center">
<span className="inline-flex items-center gap-1">
<span className="opacity-60 text-xs">user</span>
<CredDisplay
field={cfg.user}
fieldKey="user"
authName={auth.name}
cache={cache}
onRevealed={onRevealed}
/>
</span>
<span className="inline-flex items-center gap-1">
<span className="opacity-60 text-xs">pass</span>
<CredDisplay
field={cfg.password}
fieldKey="password"
authName={auth.name}
cache={cache}
onRevealed={onRevealed}
/>
</span>
</span>
);
}
return (
<span className="inline-flex flex-wrap gap-x-2 gap-y-1 items-center">
<span className="font-mono text-xs opacity-70">{cfg.header ?? "?"}</span>
<CredDisplay
field={cfg.value}
fieldKey="value"
authName={auth.name}
cache={cache}
onRevealed={onRevealed}
/>
</span>
);
}
const emptyForm = () => ({ const emptyForm = () => ({
name: "", name: "",
type: "bearer", type: "bearer",
@@ -149,23 +307,38 @@ export function AuthProfilesPage() {
const [mode, setMode] = useState(null); const [mode, setMode] = useState(null);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
/** @type {[Record<string, Record<string, string>>, Function]} */
const [revealCache, setRevealCache] = useState({});
const [editLoading, setEditLoading] = useState(false);
function openAdd() { function openAdd() {
setMode("add"); setMode("add");
setForm(emptyForm()); setForm(emptyForm());
} }
function openEdit(a) { async function openEdit(a) {
setMode("edit"); setEditLoading(true);
/** @type {Record<string, string>} */
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 ?? {}; const cfg = a.config ?? {};
setMode("edit");
setForm({ setForm({
name: a.name, name: a.name,
type: a.type, type: a.type,
token: credFromPublic(cfg.token), token: credFromPublic(cfg.token, literals.token),
user: credFromPublic(cfg.user), user: credFromPublic(cfg.user, literals.user),
password: credFromPublic(cfg.password), password: credFromPublic(cfg.password, literals.password),
header: cfg.header ?? "", header: cfg.header ?? "",
value: credFromPublic(cfg.value), value: credFromPublic(cfg.value, literals.value),
unauthorized_status: a.unauthorized_status ?? "", unauthorized_status: a.unauthorized_status ?? "",
unauthorized_response: a.unauthorized_response ?? "", unauthorized_response: a.unauthorized_response ?? "",
}); });
@@ -204,19 +377,19 @@ export function AuthProfilesPage() {
form.unauthorized_status === "" ? null : Number(form.unauthorized_status), form.unauthorized_status === "" ? null : Number(form.unauthorized_status),
unauthorized_response: form.unauthorized_response || null, 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
@@ -253,13 +426,22 @@ export function AuthProfilesPage() {
<tr key={a.id} className="hover"> <tr key={a.id} className="hover">
<td className="font-mono">{a.name}</td> <td className="font-mono">{a.name}</td>
<td>{a.type}</td> <td>{a.type}</td>
<td className="font-mono text-xs">{summarize(a)}</td> <td>
<CredentialsCell
auth={a}
cache={revealCache[a.name]}
onRevealed={(literals) =>
setRevealCache((prev) => ({ ...prev, [a.name]: literals }))
}
/>
</td>
<td className="whitespace-nowrap">{formatTime(a.updated_at)}</td> <td className="whitespace-nowrap">{formatTime(a.updated_at)}</td>
<td className="text-right whitespace-nowrap"> <td className="text-right whitespace-nowrap">
<button <button
type="button" type="button"
className="btn btn-ghost btn-xs" className="btn btn-ghost btn-xs"
title="Edit" title="Edit"
disabled={editLoading}
onClick={() => openEdit(a)} onClick={() => openEdit(a)}
> >
<LuPencil className="size-4" /> <LuPencil className="size-4" />
@@ -337,6 +519,7 @@ export function AuthProfilesPage() {
cred={form.password} cred={form.password}
onChange={(password) => setForm({ ...form, password })} onChange={(password) => setForm({ ...form, password })}
allowEmpty allowEmpty
masked
/> />
</> </>
) : null} ) : null}