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:
Yaowei ZhengandClaude Opus 4.8 authored and GitHub committed 2026-07-24 18:43:18 +08:00
1 parent c7465625a4
commit 3f868ac385
50 files changed
+2391 -1190

No files matched your search

+60 -37
View File
@@ -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>
);
}