refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c7465625a4
commit
3f868ac385
50 files changed
+2391
-1190
No files matched your search
@@ -12,14 +12,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { VaultEntryInfo, VaultUpdateRequest } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { useProject } from "../../state/project";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { PasswordInput } from "../../components/ui/password-input";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { ConfirmModal } from "../../components/ui/confirm-modal";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
import { toastError, toastSuccess } from "../../components/ui/toast";
|
||||
|
||||
/** Vault key naming rule (consistent with core/server): shell environment variable name. */
|
||||
const VAULT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
@@ -30,16 +32,19 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
|
||||
const [entries, setEntries] = useState<VaultEntryInfo[] | null>(null);
|
||||
// Tab-level error is only the initial load failure; saves/deletes report via toast.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Add modal: both form state and errors travel with the modal (tab-level error would be hidden behind the modal).
|
||||
// Add modal: form state and per-field errors travel with the modal (a tab-level error would be hidden behind it).
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [keyInput, setKeyInput] = useState("");
|
||||
const [valueInput, setValueInput] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [addErrors, setAddErrors] = useState<{ key?: string; value?: string }>({});
|
||||
const clearAddErrors = () => setAddErrors((p) => (p.key || p.value ? {} : p));
|
||||
// Key pending deletion confirmation (non-null shows the confirm modal).
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
// Existing key pending overwrite confirmation (adding a key that's already configured replaces its value).
|
||||
const [overwriting, setOverwriting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId || !agentId) return;
|
||||
@@ -49,7 +54,7 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const res = await api.getVault(projectId, agentId);
|
||||
setEntries(res.entries);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
setError(apiErrorText(e));
|
||||
}
|
||||
}, [projectId, agentId]);
|
||||
|
||||
@@ -61,14 +66,13 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const persist = async (body: VaultUpdateRequest): Promise<string | null> => {
|
||||
if (!projectId || !agentId) return S.common.unknownError;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await api.putVault(projectId, agentId, body);
|
||||
setEntries(res.entries);
|
||||
setNotice(S.common.saved);
|
||||
toastSuccess(S.common.saved);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e instanceof ApiError ? e.message : S.common.unknownError;
|
||||
return apiErrorText(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -84,40 +88,42 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const openAdd = () => {
|
||||
setKeyInput("");
|
||||
setValueInput("");
|
||||
setAddError(null);
|
||||
setAddErrors({});
|
||||
setAdding(true);
|
||||
};
|
||||
|
||||
const addEntry = async () => {
|
||||
const key = keyInput.trim();
|
||||
if (!key) {
|
||||
setAddError(S.common.requiredField);
|
||||
const next: { key?: string; value?: string } = {};
|
||||
if (!key) next.key = S.common.requiredField;
|
||||
else if (!VAULT_KEY_PATTERN.test(key)) next.key = S.vault.keyInvalid;
|
||||
if (!valueInput) next.value = S.vault.valueRequired;
|
||||
if (next.key || next.value) {
|
||||
setAddErrors(next);
|
||||
return;
|
||||
}
|
||||
if (!VAULT_KEY_PATTERN.test(key)) {
|
||||
setAddError(S.vault.keyInvalid);
|
||||
setAddErrors({});
|
||||
// Submitting an already-configured key overwrites its value (unrecoverable): confirm first.
|
||||
if (overwriting !== key && (entries ?? []).some((e) => e.key === key)) {
|
||||
setOverwriting(key);
|
||||
return;
|
||||
}
|
||||
if (!valueInput) {
|
||||
setAddError(S.vault.valueRequired);
|
||||
return;
|
||||
}
|
||||
setAddError(null);
|
||||
setOverwriting(null);
|
||||
// Upsert by same key name: don't resend the existing entry too, to avoid a 400 from PUT's duplicate-key validation.
|
||||
const err = await persist({ entries: [...keepEntries(key), { key, value: valueInput }] });
|
||||
if (err !== null) {
|
||||
setAddError(err);
|
||||
// Server rejection (e.g. duplicate key) — surface it on the key field.
|
||||
setAddErrors({ key: err });
|
||||
return;
|
||||
}
|
||||
setAdding(false);
|
||||
};
|
||||
|
||||
/** Confirm modal's "Confirm": closes the modal after deletion (on failure, the error lands on the tab's error line). */
|
||||
/** Confirm modal's "Confirm": closes the modal after deletion; a failure pops a toast. */
|
||||
const confirmRemove = async () => {
|
||||
if (deleting === null) return;
|
||||
setError(null);
|
||||
const err = await persist({ entries: keepEntries(deleting) });
|
||||
if (err !== null) setError(err);
|
||||
if (err !== null) toastError(err);
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
@@ -200,9 +206,14 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.vault.key}
|
||||
required
|
||||
hint={S.vault.keyHint}
|
||||
error={addErrors.key}
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setKeyInput(e.target.value);
|
||||
clearAddErrors();
|
||||
}}
|
||||
className="font-mono"
|
||||
placeholder="OPENAI_API_KEY"
|
||||
autoComplete="off"
|
||||
@@ -210,39 +221,51 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
<PasswordInput
|
||||
size="sm"
|
||||
label={S.vault.value}
|
||||
required
|
||||
error={addErrors.value}
|
||||
value={valueInput}
|
||||
onChange={(e) => setValueInput(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValueInput(e.target.value);
|
||||
clearAddErrors();
|
||||
}}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !busy) void addEntry();
|
||||
}}
|
||||
/>
|
||||
{addError && <p className="text-xs text-red-600 dark:text-red-400">{addError}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation (same pattern as Agent / Session deletion: Modal + cancel/danger-confirm). */}
|
||||
<Modal
|
||||
{/* Overwrite confirmation: the add modal stays underneath, so cancel returns to the form. */}
|
||||
<ConfirmModal
|
||||
open={overwriting !== null}
|
||||
title={S.vault.overwriteTitle}
|
||||
tone="primary"
|
||||
confirmLabel={S.common.save}
|
||||
busy={busy}
|
||||
onClose={() => setOverwriting(null)}
|
||||
onConfirm={() => void addEntry()}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{overwriting !== null ? S.vault.overwriteConfirm(overwriting) : ""}
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
|
||||
{/* Delete confirmation (shared ConfirmModal, same pattern as Agent / Session deletion). */}
|
||||
<ConfirmModal
|
||||
open={deleting !== null}
|
||||
title={S.vault.deleteTitle}
|
||||
busy={busy}
|
||||
onClose={() => setDeleting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
|
||||
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onConfirm={() => void confirmRemove()}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting !== null ? S.vault.deleteConfirm(deleting) : ""}
|
||||
</p>
|
||||
</Modal>
|
||||
</ConfirmModal>
|
||||
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user