Skip to content

Commit 3563f2f

Browse files
committed
feat: add reveal toggles for claude secrets
1 parent 8a4432d commit 3563f2f

6 files changed

Lines changed: 143 additions & 17 deletions

File tree

apps/web/src/components/Settings/ClaudeSettingsPanel.tsx

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useId, useMemo, useRef, useState } from "react";
22
import type { HTMLAttributes } from "react";
33
import type { Locale, Translator } from "../../i18n.ts";
4+
import { EyeIcon, EyeOffIcon } from "../icons.tsx";
45
import type {
56
AppSettings,
67
ClaudeRuntimeProfile,
@@ -304,6 +305,9 @@ const ClaudeInputField = ({
304305
testId,
305306
inputMode,
306307
min,
308+
allowSecretReveal = false,
309+
revealLabel,
310+
concealLabel,
307311
className = "",
308312
}: {
309313
label: string;
@@ -316,22 +320,49 @@ const ClaudeInputField = ({
316320
testId?: string;
317321
inputMode?: HTMLAttributes<HTMLInputElement>["inputMode"];
318322
min?: number;
323+
allowSecretReveal?: boolean;
324+
revealLabel?: string;
325+
concealLabel?: string;
319326
className?: string;
320-
}) => (
321-
<label className={`claude-field ${className}`.trim()}>
322-
<ClaudeFieldCopy label={label} help={help} meta={meta} />
323-
<input
324-
className="settings-command-field"
325-
type={type}
326-
value={value}
327-
min={min}
328-
inputMode={inputMode}
329-
onChange={(event) => onChange(event.target.value)}
330-
placeholder={placeholder}
331-
data-testid={testId}
332-
/>
333-
</label>
334-
);
327+
}) => {
328+
const inputId = useId();
329+
const [revealed, setRevealed] = useState(false);
330+
const showSecretToggle = type === "password" && allowSecretReveal;
331+
const effectiveType = showSecretToggle && revealed ? "text" : type;
332+
333+
return (
334+
<div className={`claude-field ${className}`.trim()}>
335+
<label htmlFor={inputId}>
336+
<ClaudeFieldCopy label={label} help={help} meta={meta} />
337+
</label>
338+
<div className={`claude-input-shell ${showSecretToggle ? "with-toggle" : ""}`.trim()}>
339+
<input
340+
id={inputId}
341+
className="settings-command-field"
342+
type={effectiveType}
343+
value={value}
344+
min={min}
345+
inputMode={inputMode}
346+
onChange={(event) => onChange(event.target.value)}
347+
placeholder={placeholder}
348+
data-testid={testId}
349+
/>
350+
{showSecretToggle ? (
351+
<button
352+
type="button"
353+
className="claude-visibility-toggle"
354+
onClick={() => setRevealed((current) => !current)}
355+
aria-label={revealed ? concealLabel : revealLabel}
356+
aria-pressed={revealed}
357+
data-testid={testId ? `${testId}-visibility-toggle` : undefined}
358+
>
359+
{revealed ? <EyeOffIcon /> : <EyeIcon />}
360+
</button>
361+
) : null}
362+
</div>
363+
</div>
364+
);
365+
};
335366

336367
const ClaudeTextareaField = ({
337368
label,
@@ -707,24 +738,32 @@ export const ClaudeSettingsPanel = ({
707738
help={t("claudeApiKeyHelp")}
708739
meta={t("claudeApiKeyMeta")}
709740
type="password"
741+
allowSecretReveal
742+
revealLabel={t("claudeShowSecret")}
743+
concealLabel={t("claudeHideSecret")}
710744
value={scopeProfile.env.ANTHROPIC_API_KEY ?? ""}
711745
onChange={(value) => updateEnv((env) => ({
712746
...env,
713747
ANTHROPIC_API_KEY: value,
714748
}))}
715749
placeholder={t("claudeApiKeyPlaceholder")}
750+
testId="claude-api-key-input"
716751
/>
717752
<ClaudeInputField
718753
label={t("claudeAuthToken")}
719754
help={t("claudeAuthTokenHelp")}
720755
meta={t("claudeAuthTokenMeta")}
721756
type="password"
757+
allowSecretReveal
758+
revealLabel={t("claudeShowSecret")}
759+
concealLabel={t("claudeHideSecret")}
722760
value={scopeProfile.env.ANTHROPIC_AUTH_TOKEN ?? ""}
723761
onChange={(value) => updateEnv((env) => ({
724762
...env,
725763
ANTHROPIC_AUTH_TOKEN: value,
726764
}))}
727765
placeholder={t("claudeAuthTokenPlaceholder")}
766+
testId="claude-auth-token-input"
728767
/>
729768
<ClaudeInputField
730769
label={t("claudeBaseUrl")}

apps/web/src/components/icons.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { LucideIcon, LucideProps } from "lucide-react";
22
import React from "react";
33
import {
4-
Archive, ArrowDownUp, ArrowUp, Blocks, ChevronDown, ChevronLeft, ChevronRight, Code2, Code, File, FileDiff, FileJson, FileText, Folder, FolderOpen, FolderPlus, GitBranch, History, List, Minus, Monitor, MoonStar, Palette, Plus, RefreshCw, Rows3, Search, Settings2, SlidersHorizontal, SquarePlus, SunMedium, Terminal, Undo2, X, Maximize2, Minimize2, PanelRightClose, PanelRightOpen, SquareSplitVertical, SquareSplitHorizontal
4+
Archive, ArrowDownUp, ArrowUp, Blocks, ChevronDown, ChevronLeft, ChevronRight, Code2, Code, Eye, EyeOff, File, FileDiff, FileJson, FileText, Folder, FolderOpen, FolderPlus, GitBranch, History, List, Minus, Monitor, MoonStar, Palette, Plus, RefreshCw, Rows3, Search, Settings2, SlidersHorizontal, SquarePlus, SunMedium, Terminal, Undo2, X, Maximize2, Minimize2, PanelRightClose, PanelRightOpen, SquareSplitVertical, SquareSplitHorizontal
55
} from "lucide-react";
66

77
const createIcon = (Icon: LucideIcon, defaults?: Partial<LucideProps>) => {
@@ -57,6 +57,8 @@ export const AgentSplitHorizontalIcon = createIcon(SquareSplitHorizontal, { size
5757
export const FolderIcon = createIcon(Folder, { size: 14 });
5858
export const FolderOpenIcon = createIcon(FolderOpen, { size: 14 });
5959
export const FileIcon = createIcon(File, { size: 14 });
60+
export const EyeIcon = createIcon(Eye, { size: 14 });
61+
export const EyeOffIcon = createIcon(EyeOff, { size: 14 });
6062

6163
const fileTypeColors: Record<string, string> = {
6264
ts: "#3178c6", tsx: "#3178c6", js: "#f7df1e", jsx: "#f7df1e", json: "#cbcb41", md: "#519aba", html: "#e34c26", css: "#563d7c", scss: "#563d7c", py: "#3572A5", go: "#00ADD8", rs: "#dea584", java: "#b07219", sql: "#e38c00", sh: "#89e051", yml: "#cb171e", yaml: "#cb171e", toml: "#9c4121", svg: "#a0c4e3", png: "#a0c4e3", jpg: "#a0c4e3", pdf: "#b30b00"

apps/web/src/i18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ const messages = {
222222
claudeAuthTokenHelp: "Maps to ANTHROPIC_AUTH_TOKEN. Common for bearer-token or gateway setups, especially when paired with a custom base URL.",
223223
claudeAuthTokenMeta: "Secret string · Example: token-demo-...",
224224
claudeAuthTokenPlaceholder: "token-demo-...",
225+
claudeShowSecret: "Show secret",
226+
claudeHideSecret: "Hide secret",
225227
claudeBaseUrl: "Base URL",
226228
claudeBaseUrlHelp: "Maps to ANTHROPIC_BASE_URL. Point Claude CLI at a proxy, gateway, or compatible endpoint instead of the default Anthropic API.",
227229
claudeBaseUrlMeta: "URL string · Example: https://api.anthropic.com",
@@ -706,6 +708,8 @@ const messages = {
706708
claudeAuthTokenHelp: "对应 ANTHROPIC_AUTH_TOKEN,常见于 bearer token 或网关代理场景,通常会和自定义 Base URL 一起使用。",
707709
claudeAuthTokenMeta: "密文字符串 · 示例:token-demo-...",
708710
claudeAuthTokenPlaceholder: "token-demo-...",
711+
claudeShowSecret: "显示明文",
712+
claudeHideSecret: "隐藏明文",
709713
claudeBaseUrl: "Base URL",
710714
claudeBaseUrlHelp: "对应 ANTHROPIC_BASE_URL,用来把 Claude CLI 指向代理、网关或兼容 Anthropic API 的自定义端点。",
711715
claudeBaseUrlMeta: "URL 字符串 · 示例:https://api.anthropic.com",

apps/web/src/styles/app.css

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10153,7 +10153,8 @@ pre.diff,
1015310153

1015410154
.claude-field > .settings-command-field,
1015510155
.claude-field > .claude-textarea,
10156-
.claude-field > .claude-json-editor {
10156+
.claude-field > .claude-json-editor,
10157+
.claude-input-shell {
1015710158
width: 100%;
1015810159
min-width: 0;
1015910160
border: 1px solid transparent;
@@ -10163,6 +10164,72 @@ pre.diff,
1016310164
box-shadow: none;
1016410165
}
1016510166

10167+
.claude-input-shell {
10168+
display: flex;
10169+
align-items: center;
10170+
gap: 0;
10171+
min-height: 36px;
10172+
transition:
10173+
background-color 140ms ease,
10174+
box-shadow 140ms ease;
10175+
}
10176+
10177+
.claude-input-shell:focus-within {
10178+
box-shadow:
10179+
inset 0 0 0 1px color-mix(in srgb, var(--accent) 54%, transparent),
10180+
0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent);
10181+
}
10182+
10183+
.claude-input-shell > .settings-command-field {
10184+
flex: 1;
10185+
min-width: 0;
10186+
min-height: 36px;
10187+
padding: 0 10px;
10188+
border: none;
10189+
background: transparent;
10190+
box-shadow: none;
10191+
}
10192+
10193+
.claude-input-shell > .settings-command-field:focus,
10194+
.claude-input-shell > .settings-command-field:focus-visible {
10195+
outline: none;
10196+
box-shadow: none;
10197+
}
10198+
10199+
.claude-input-shell.with-toggle > .settings-command-field {
10200+
padding-right: 4px;
10201+
}
10202+
10203+
.claude-visibility-toggle {
10204+
display: inline-flex;
10205+
align-items: center;
10206+
justify-content: center;
10207+
width: 30px;
10208+
height: 30px;
10209+
margin-right: 3px;
10210+
padding: 0;
10211+
border: none;
10212+
border-radius: 4px;
10213+
background: transparent;
10214+
color: var(--text-tertiary);
10215+
cursor: pointer;
10216+
transition: background-color 120ms ease, color 120ms ease;
10217+
}
10218+
10219+
.claude-visibility-toggle:hover {
10220+
background: color-mix(in srgb, var(--surface) 70%, var(--accent) 30%);
10221+
color: var(--text);
10222+
}
10223+
10224+
.claude-visibility-toggle:focus {
10225+
outline: none;
10226+
}
10227+
10228+
.claude-visibility-toggle:focus-visible {
10229+
background: color-mix(in srgb, var(--surface) 74%, var(--accent) 26%);
10230+
color: var(--text);
10231+
}
10232+
1016610233
.claude-select-trigger {
1016710234
display: grid;
1016810235
grid-template-columns: minmax(0, 1fr) 12px;

tests/claude-settings.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ test('translator exposes the new history and Claude settings keys', () => {
243243
assert.equal(en('claudeModelPlaceholder'), 'claude-sonnet-4-5');
244244
assert.equal(en('claudeSelectUnsetOption'), 'Not set');
245245
assert.equal(en('claudeEditorModeVimOption'), 'vim');
246+
assert.equal(en('claudeShowSecret'), 'Show secret');
247+
assert.equal(zh('claudeHideSecret'), '隐藏明文');
246248
assert.match(en('claudeApiKeyHelperHelp'), /take precedence/i);
247249
assert.match(zh('claudeAuthSectionHint'), /Claude /);
248250
assert.match(zh('claudeCleanupDaysMeta'), />= 0/);

tests/settings-layout.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,15 @@ test('claude settings use dedicated inline select rows for enum fields', async (
4242
assert.doesNotMatch(claudeSource, /meta=\{t\("claudeEffortMeta"\)\}/);
4343
assert.doesNotMatch(claudeSource, /meta=\{t\("claudeEditorModeMeta"\)\}/);
4444
});
45+
46+
test('claude secret fields expose a reveal toggle', async () => {
47+
const claudeSource = await fs.readFile(
48+
new URL('../apps/web/src/components/Settings/ClaudeSettingsPanel.tsx', import.meta.url),
49+
'utf8',
50+
);
51+
52+
assert.match(claudeSource, /allowSecretReveal/);
53+
assert.match(claudeSource, /claude-api-key-input/);
54+
assert.match(claudeSource, /claude-auth-token-input/);
55+
assert.match(claudeSource, /visibility-toggle/);
56+
});

0 commit comments

Comments
 (0)