whisper-money/resources/js/components/encrypted-text.tsx

153 lines
4.3 KiB
TypeScript

import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { useEffect, useMemo, useRef, useState } from 'react';
type Length = number | { min: number; max: number } | null;
interface EncryptedTextProps {
encryptedText: string;
iv: string;
className?: string;
length: Length;
}
type DisplayState = 'encrypted' | 'decrypted' | 'loading';
const ENCRYPTED_CHARSET = '0123456789$%&#@!ABCDEFGHIJKLMNOPQRSTUVWXYZ';
function resolveTargetLength(length: Length, fallback: number): number {
if (typeof length === 'number') {
return Math.max(1, length);
}
if (length && typeof length === 'object') {
const min = Math.max(1, length.min);
const max = Math.max(min, length.max);
const clampedFallback = Math.min(Math.max(fallback, min), max);
return clampedFallback;
}
return Math.max(1, fallback);
}
function generateMaskedText(targetLength: number): string {
if (targetLength <= 0) {
return '';
}
let result = '';
for (let index = 0; index < targetLength; index += 1) {
const randomIndex = Math.floor(
Math.random() * ENCRYPTED_CHARSET.length,
);
result += ENCRYPTED_CHARSET.charAt(randomIndex);
}
return result;
}
function getInitialDisplayState(isKeySet: boolean): DisplayState {
if (!isKeySet) {
return 'encrypted';
}
const keyString = getStoredKey();
return keyString ? 'loading' : 'encrypted';
}
export function EncryptedText(props: EncryptedTextProps) {
const { encryptedText, iv, className = '', length = null } = props;
const { isKeySet } = useEncryptionKey();
const targetLength = useMemo(
() => resolveTargetLength(length, encryptedText.length),
[length, encryptedText.length],
);
const maskedValue = useMemo(
() => generateMaskedText(targetLength),
[targetLength],
);
const [cachedDecryption, setCachedDecryption] = useState<{
encryptedText: string;
iv: string;
value: string;
} | null>(null);
const [displayState, setDisplayState] = useState<DisplayState>(() => getInitialDisplayState(isKeySet));
const prevIsKeySetRef = useRef(isKeySet);
useEffect(() => {
const wasKeySet = prevIsKeySetRef.current;
prevIsKeySetRef.current = isKeySet;
if (!isKeySet) {
if (wasKeySet) {
setCachedDecryption(null);
setDisplayState('encrypted');
}
return;
}
const keyString = getStoredKey();
if (!keyString) {
if (wasKeySet !== isKeySet) {
setCachedDecryption(null);
setDisplayState('encrypted');
}
return;
}
if (!wasKeySet && isKeySet) {
setDisplayState('loading');
}
let cancelled = false;
importKey(keyString)
.then((key) => decrypt(encryptedText, key, iv))
.then((value) => {
if (cancelled) {
return;
}
setCachedDecryption({ encryptedText, iv, value });
setDisplayState('decrypted');
})
.catch(() => {
if (cancelled) {
return;
}
setCachedDecryption(null);
setDisplayState('encrypted');
});
return () => {
cancelled = true;
};
}, [encryptedText, iv, isKeySet]);
if (displayState === 'loading') {
const widthInCharacters = Math.max(targetLength, 3) / 2;
const loadingClassName = [
'inline-block animate-pulse rounded bg-muted/60',
className,
]
.filter(Boolean)
.join(' ');
return (
<span
className={loadingClassName}
style={{
width: `${widthInCharacters}ch`,
height: '1em',
}}
/>
);
}
if (displayState === 'decrypted' && cachedDecryption) {
return <span className={className}>{cachedDecryption.value}</span>;
}
return <span className={className}>{maskedValue}</span>;
}