Files
jerapah-flow/packages/web/src/pages/AuthProfilesPage.jsx
T
nsrbandCursor 96c4cc7b47 refactor(web): route-driven modal hook and CRUD conventions
Extract useRouteDrivenModal for the five list editors, move AddTriggerDialog into its own file, and document modal vs full-page CRUD patterns in CONTRIBUTING.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 00:48:44 +07:00

280 lines
8.5 KiB
React

import { useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { LuEye, LuEyeOff, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
fetchHttpAuthLiterals,
useDeleteHttpAuth,
useHttpAuths,
} from "../api/hooks.js";
import { AuthEditorModal } from "../components/AuthEditorModal.jsx";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format.jsx";
function CredDisplay({ field, fieldKey, authId, 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>;
}
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(authId);
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" : "Reveal"}
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"
authId={auth.id}
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"
authId={auth.id}
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"
authId={auth.id}
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"
authId={auth.id}
cache={cache}
onRevealed={onRevealed}
/>
</span>
);
}
export function AuthProfilesPage() {
const navigate = useNavigate();
const location = useLocation();
const { name: routeName } = useParams();
const isNewRoute = /\/auth\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeName) && !isNewRoute;
const { data: auths = [], isLoading } = useHttpAuths();
const del = useDeleteHttpAuth();
const [confirmDelete, setConfirmDelete] = useState(null);
/** @type {[Record<string, Record<string, string>>, Function]} */
const [revealCache, setRevealCache] = useState({});
const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute,
isEditRoute,
listPath: "/auth",
buildNewEditor: () => ({ mode: "add" }),
editRouteKey: () => `edit:${routeName}`,
canOpenEdit: () => !isLoading,
buildEditEditor: () => {
const auth = auths.find((a) => a.name === routeName);
if (!auth) return { mode: "add" };
return { mode: "edit", auth };
},
editDeps: [routeName, isLoading, auths],
});
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">Auth</h1>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => navigate("/auth/new")}
>
<LuPlus className="size-4" />
Add
</button>
</div>
<p className="text-sm opacity-70">
HTTP trigger auth profiles. Rename freely — workflows keep working via a stable id.
Add them to a trigger from the workflow editor dropdown. Secrets are managed on the
Secrets page; KV values stay in KV.
</p>
{isLoading ? (
<span className="loading loading-spinner" />
) : auths.length === 0 ? (
<p className="text-sm opacity-60">No auth profiles yet.</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Credentials</th>
<th>Updated</th>
<th />
</tr>
</thead>
<tbody>
{auths.map((a) => (
<tr key={a.id} className="hover">
<td className="font-mono">{a.name}</td>
<td>{a.type}</td>
<td>
<CredentialsCell
auth={a}
cache={revealCache[a.id]}
onRevealed={(literals) =>
setRevealCache((prev) => ({ ...prev, [a.id]: literals }))
}
/>
</td>
<td className="whitespace-nowrap">{formatTime(a.updated_at)}</td>
<td className="text-right whitespace-nowrap">
<button
type="button"
className="btn btn-ghost btn-xs"
title="Edit"
aria-label="Edit"
onClick={() => navigate(`/auth/${encodeURIComponent(a.name)}/edit`)}
>
<LuPencil className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
title="Delete"
aria-label="Delete"
onClick={() => setConfirmDelete(a)}
>
<LuTrash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editor ? (
<AuthEditorModal
mode={editor.mode}
auth={editor.auth}
onClose={closeEditor}
onSaved={(saved) => {
const id = saved?.id || editor.auth?.id;
if (!id) return;
setRevealCache((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}}
/>
) : null}
<ConfirmDialog
open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
message="Workflows that reference this profile will fail auth until updated."
error={del.isError ? errorMessage(del.error) : null}
loading={del.isPending}
onCancel={() => setConfirmDelete(null)}
onConfirm={() =>
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
}
/>
</div>
);
}