diff --git a/src/components/Dynamic/DynamicField.tsx b/src/components/Dynamic/DynamicField.tsx index c7b9064be..0ed70dac1 100644 --- a/src/components/Dynamic/DynamicField.tsx +++ b/src/components/Dynamic/DynamicField.tsx @@ -2,6 +2,7 @@ import { Suspense, lazy } from 'react'; import { mdiLoading } from '@mdi/js'; import { Icon } from '@mdi/react'; +import PasswordField from '@/components/Dynamic/PasswordField'; import Checkbox from '@/components/Input/Checkbox'; import Input from '@/components/Input/Input'; import InputSmall from '@/components/Input/InputSmall'; @@ -70,6 +71,10 @@ const DynamicField = ({ onChange, propertyName, propertySchema, value }: Props) ); } + if (elementType === 'password') { + return ; + } + if (elementType === 'code-block') { const language = (uiDef.codeLanguage ?? 'plaintext').toLowerCase(); return ( @@ -109,8 +114,6 @@ export default DynamicField; // - list -> DynamicList (add/remove/sort items) // e.g. OfflineImporter.AutoMatchRules, OfflineImporter.ParseRules, // CoreSettings.Import.VideoExtensions, CoreSettings.Image.ImageTemplateUrls -// - password -> Password input (masked text) -// e.g. CoreSettings.AniDb.Password // - record -> DynamicRecord (add/remove/sort key-value entries, IDictionary on backend) // (no live examples found yet) // - select -> Select dropdown with server-provided options (SelectComponent) diff --git a/src/components/Dynamic/PasswordField.tsx b/src/components/Dynamic/PasswordField.tsx new file mode 100644 index 000000000..a82859612 --- /dev/null +++ b/src/components/Dynamic/PasswordField.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { mdiClose, mdiEyeOffOutline, mdiEyeOutline, mdiUndo } from '@mdi/js'; +import { useToggle } from 'usehooks-ts'; + +import Input from '@/components/Input/Input'; + +import type { EndIcon } from '@/components/Input/Input'; + +type Props = { + id: string; + label: string; + value: unknown; + onChange: (value: string) => void; +}; + +/** + * How the server sends a stored secret: `***SECRET-UNCHANGED***`, or with a fingerprint folded in. Sent back as it + * came, it keeps the stored secret. + */ +const MASKED_PATTERN = /^\*\*\*SECRET-UNCHANGED(?::[^*]*)?\*\*\*$/; + +const isMaskedSecret = (value: unknown): value is string => typeof value === 'string' && MASKED_PATTERN.test(value); + +/** + * A secret. A stored one is never shown: the input stays empty until the user types a new one. Left alone, the masked + * value goes back as it came, so the server keeps the secret; cleared, an empty string goes back, which clears it. + */ +const PasswordField = ({ id, label, onChange, value }: Props) => { + const isUnchanged = isMaskedSecret(value); + + // The masked value of the stored secret, to send back when the user undoes their change. + const [storedMask, setStoredMask] = useState(isUnchanged ? value : null); + if (isUnchanged && value !== storedMask) setStoredMask(value); + + const [showPassword, toggleShowPassword] = useToggle(false); + + const typedValue = isUnchanged || typeof value !== 'string' ? '' : value; + const hasTyped = typedValue !== ''; + + let placeholder = ''; + if (isUnchanged) placeholder = 'Unchanged (hidden)'; + else if (storedMask && !hasTyped) placeholder = 'Cleared when saved'; + + const endIcons: EndIcon[] = []; + if (hasTyped) { + endIcons.push({ + icon: showPassword ? mdiEyeOffOutline : mdiEyeOutline, + onClick: toggleShowPassword, + tooltip: showPassword ? 'Hide' : 'Show', + }); + } + if (isUnchanged) { + endIcons.push({ icon: mdiClose, onClick: () => onChange(''), tooltip: 'Clear the stored value' }); + } else if (storedMask) { + endIcons.push({ icon: mdiUndo, onClick: () => onChange(storedMask), tooltip: 'Keep the stored value' }); + } + + return ( + onChange(event.target.value)} + endIcons={endIcons} + /> + ); +}; + +export default PasswordField;