Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/components/Dynamic/DynamicField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -70,6 +71,10 @@ const DynamicField = ({ onChange, propertyName, propertySchema, value }: Props)
);
}

if (elementType === 'password') {
return <PasswordField id={`dynamic-${propertyName}`} label={label} onChange={onChange} value={value} />;
}

if (elementType === 'code-block') {
const language = (uiDef.codeLanguage ?? 'plaintext').toLowerCase();
return (
Expand Down Expand Up @@ -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)
Expand Down
71 changes: 71 additions & 0 deletions src/components/Dynamic/PasswordField.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Input
id={id}
label={label}
type={showPassword && hasTyped ? 'text' : 'password'}
value={typedValue}
placeholder={placeholder}
onChange={event => onChange(event.target.value)}
endIcons={endIcons}
/>
);
};

export default PasswordField;
Loading