Skip to content

Commit b0677b8

Browse files
committed
feat: add secret input for secret key sense
1 parent cc6a728 commit b0677b8

File tree

3 files changed

+222
-4
lines changed

3 files changed

+222
-4
lines changed

src/client/components/aiGateway/AIGatewayEditForm.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
FormMessage,
1414
} from '@/components/ui/form';
1515
import { Input } from '@/components/ui/input';
16+
import { SecretInput } from '@/components/ui/secret-input';
1617
import { useForm } from 'react-hook-form';
1718
import { zodResolver } from '@hookform/resolvers/zod';
1819
import React from 'react';
@@ -90,10 +91,11 @@ export const AIGatewayEditForm: React.FC<AIGatewayEditFormProps> = React.memo(
9091
<FormItem>
9192
<FormLabel optional={true}>{t('Model API Key')}</FormLabel>
9293
<FormControl>
93-
<Input
94-
type="password"
95-
{...field}
96-
value={field.value ?? ''}
94+
<SecretInput
95+
value={field.value}
96+
onChange={field.onChange}
97+
placeholder="sk-..."
98+
maskPlaceholder="••••••••••••••••"
9799
/>
98100
</FormControl>
99101
<FormDescription>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { fn } from 'storybook/test';
3+
import { SecretInput } from './secret-input';
4+
import React from 'react';
5+
import { useForm } from 'react-hook-form';
6+
import { zodResolver } from '@hookform/resolvers/zod';
7+
import { z } from 'zod';
8+
import {
9+
Form,
10+
FormControl,
11+
FormDescription,
12+
FormField,
13+
FormItem,
14+
FormLabel,
15+
FormMessage,
16+
} from './form';
17+
import { Button } from './button';
18+
19+
const meta = {
20+
title: 'Components/UI/SecretInput',
21+
component: SecretInput,
22+
parameters: {
23+
layout: 'centered',
24+
docs: {
25+
description: {
26+
component:
27+
'A secure input component for sensitive data like API keys. Shows a masked placeholder when there is an existing value, clears on focus, and only submits new values when user types.',
28+
},
29+
},
30+
},
31+
tags: ['autodocs'],
32+
argTypes: {
33+
value: {
34+
control: 'text',
35+
description: 'The existing secret value (will be hidden)',
36+
},
37+
placeholder: {
38+
control: 'text',
39+
description: 'Placeholder text when no value or after focus',
40+
},
41+
maskPlaceholder: {
42+
control: 'text',
43+
description: 'Placeholder text shown when there is an existing value',
44+
},
45+
onChange: {
46+
description: 'Callback when user types a new value',
47+
},
48+
disabled: {
49+
control: 'boolean',
50+
description: 'Whether the input is disabled',
51+
},
52+
},
53+
args: {
54+
onChange: fn(),
55+
},
56+
} satisfies Meta<typeof SecretInput>;
57+
58+
export default meta;
59+
type Story = StoryObj<typeof meta>;
60+
61+
/**
62+
* Default state with no existing value - shows normal placeholder
63+
*/
64+
export const Empty: Story = {
65+
args: {
66+
value: null,
67+
placeholder: 'Enter your API key',
68+
},
69+
};
70+
71+
/**
72+
* With an existing secret value - shows masked placeholder (******)
73+
* Click to focus and the placeholder will change to normal text
74+
*/
75+
export const WithExistingValue: Story = {
76+
args: {
77+
value: 'sk-1234567890abcdef',
78+
placeholder: 'Enter new API key',
79+
},
80+
};
81+
82+
/**
83+
* Custom mask placeholder - use bullet points or other characters
84+
*/
85+
export const CustomMaskPlaceholder: Story = {
86+
args: {
87+
value: 'existing-secret-key-value',
88+
placeholder: 'Enter new value',
89+
maskPlaceholder: '••••••••••••••••',
90+
},
91+
};
92+
93+
/**
94+
* Disabled state
95+
*/
96+
export const Disabled: Story = {
97+
args: {
98+
value: 'existing-key',
99+
placeholder: 'Enter API key',
100+
disabled: true,
101+
},
102+
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import * as React from 'react';
2+
import { cn } from '@/utils/style';
3+
4+
export interface SecretInputProps
5+
extends Omit<
6+
React.InputHTMLAttributes<HTMLInputElement>,
7+
'type' | 'value' | 'onChange'
8+
> {
9+
value?: string | null;
10+
onChange?: (value: string) => void;
11+
/**
12+
* The placeholder to show when the field has an existing value.
13+
* @default "******"
14+
*/
15+
maskPlaceholder?: string;
16+
}
17+
18+
/**
19+
* SecretInput - A secure input component for sensitive data like API keys
20+
*
21+
* Features:
22+
* - Shows masked placeholder (******) when there's an existing value
23+
* - Actual field value is empty until user starts typing
24+
* - Clears the masked placeholder on focus
25+
* - Only submits new value if user has entered something
26+
* - Perfect for edit forms where you don't want to expose existing secrets
27+
*
28+
* @example
29+
* ```tsx
30+
* <SecretInput
31+
* value={existingApiKey}
32+
* onChange={(newValue) => setApiKey(newValue)}
33+
* placeholder="Enter your API key"
34+
* />
35+
* ```
36+
*/
37+
const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
38+
(
39+
{
40+
className,
41+
value,
42+
onChange,
43+
maskPlaceholder = '******',
44+
placeholder,
45+
...props
46+
},
47+
ref
48+
) => {
49+
const [internalValue, setInternalValue] = React.useState('');
50+
const [isFocused, setIsFocused] = React.useState(false);
51+
const [hasBeenTouched, setHasBeenTouched] = React.useState(false);
52+
53+
// Check if there's an existing value (from props)
54+
const hasExistingValue = Boolean(value);
55+
56+
// Determine what placeholder to show
57+
const effectivePlaceholder = React.useMemo(() => {
58+
if (hasBeenTouched) {
59+
// After user has interacted, show normal placeholder
60+
return placeholder;
61+
}
62+
if (hasExistingValue && !isFocused) {
63+
// Show masked placeholder when there's existing value and not focused
64+
return maskPlaceholder;
65+
}
66+
// Show normal placeholder
67+
return placeholder;
68+
}, [
69+
hasBeenTouched,
70+
hasExistingValue,
71+
isFocused,
72+
placeholder,
73+
maskPlaceholder,
74+
]);
75+
76+
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
77+
setIsFocused(true);
78+
setHasBeenTouched(true);
79+
props.onFocus?.(e);
80+
};
81+
82+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
83+
setIsFocused(false);
84+
props.onBlur?.(e);
85+
};
86+
87+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
88+
const newValue = e.target.value;
89+
setInternalValue(newValue);
90+
setHasBeenTouched(true);
91+
onChange?.(newValue);
92+
};
93+
94+
return (
95+
<input
96+
type="password"
97+
className={cn(
98+
'flex h-9 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300',
99+
className
100+
)}
101+
ref={ref}
102+
value={internalValue}
103+
onChange={handleChange}
104+
onFocus={handleFocus}
105+
onBlur={handleBlur}
106+
placeholder={effectivePlaceholder}
107+
{...props}
108+
/>
109+
);
110+
}
111+
);
112+
SecretInput.displayName = 'SecretInput';
113+
114+
export { SecretInput };

0 commit comments

Comments
 (0)