integratedhub/islands/WelcomeBar/EncryptionModal.tsx
2026-01-20 09:02:26 -06:00

277 lines
9.7 KiB
TypeScript

import { TargetedEvent, TargetedKeyboardEvent } from "preact";
import { useEffect, useRef } from "preact/hooks";
import { EncryptionModalProps } from "./welcome-types.ts";
/**
* EncryptionModal Component
* Modal banner for setting up encryption password or changing PIN
* Appears at top of screen until password is set or skipped
*
* @param showModal - Whether modal is visible
* @param tempPassword - Current password input value
* @param passwordError - Error message to display
* @param isChangeMode - Whether modal is in change PIN mode
* @param onPasswordChange - Handler for password input changes
* @param onSetPassword - Handler to validate and set password
* @param onSkip - Handler to skip encryption setup
* @param onClose - Handler to close change PIN modal
*
* TODO: Future enhancements
* - Add password strength indicator
* - Add password visibility toggle
* - Add "Remember this device" checkbox
* - Add password requirements tooltip
* - Add biometric authentication option
* - Add password confirmation field
*/
export default function EncryptionModal({
showModal,
tempPassword,
passwordError,
isChangeMode = false,
showRemovePinConfirm = false,
persistAcrossDevices,
onPasswordChange,
onSetPassword,
onSkip,
onClose,
onRemovePinConfirm,
onPersistAcrossDevicesChange,
}: EncryptionModalProps) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (showModal && inputRef.current) {
inputRef.current.focus();
}
}, [showModal]);
if (!showModal) return null;
const handleCheckboxChange = (e: TargetedEvent<HTMLInputElement>) => {
onPersistAcrossDevicesChange(e.currentTarget.checked);
};
const handlePasswordInput = (
e: TargetedEvent<HTMLInputElement, InputEvent>,
) => {
onPasswordChange(e.currentTarget.value);
};
const handleKeyDown = (e: TargetedKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
if (tempPassword.length >= 4) {
onSetPassword();
}
}
};
const handleSubmit = (e: TargetedEvent<HTMLFormElement>) => {
e.preventDefault();
if (tempPassword.length >= 4) {
onSetPassword();
}
};
const handleCancel = () => {
if (isChangeMode) {
onClose?.();
} else {
// Clear the input when skipping initial encryption setup
onPasswordChange("");
onSkip();
}
};
const handleBackdropClick = (
e: TargetedEvent<HTMLDivElement, MouseEvent>,
) => {
if (e.currentTarget === e.target) {
if (isChangeMode) {
onClose?.();
} else {
onSkip();
}
}
};
const handleInputBlur = () => {
if (tempPassword.length === 0 && !isChangeMode) {
onSkip();
}
};
const isPasswordValid = tempPassword.length >= 4;
return (
<>
{/* Main Change PIN Modal */}
<div
class="fixed inset-0 z-50 flex items-start justify-center pt-2 p-4 bg-transparent"
role="dialog"
aria-labelledby="encryption-modal-title"
onClick={handleBackdropClick}
>
<div class="bg-white/65 rounded-2xl shadow-2xl p-6 w-full max-w-md mx-auto transform transition-all duration-200 scale-100">
<div class="flex flex-col bg-opacity-10 items-center text-center space-y-4">
<div class="flex-1 bg-opacity-10 min-w-0">
<h3
id="encryption-modal-title"
class="text-lg font-semibold text-gray-800"
>
{isChangeMode ? "Change PIN" : "Enter PIN"}
</h3>
<p class="text-sm text-gray-700 mt-1">
{isChangeMode
? "Enter your new PIN to update your encryption"
: "Enter your PIN to access your encrypted data"}
</p>
</div>
</div>
<div class="mt-6 space-y-4">
<form onSubmit={handleSubmit} class="flex flex-col space-y-2">
<input
ref={inputRef}
id="encryption-password"
name="encryption-password"
type="password"
value={tempPassword}
onInput={handlePasswordInput}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
placeholder={isChangeMode
? "Enter new PIN (min 4 digits)"
: "Enter PIN (min 4 digits)"}
autocomplete="current-password"
aria-invalid={!!passwordError}
aria-describedby={passwordError
? "password-error"
: "password-hint"}
class={`w-full px-4 py-3 border rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:border-blue-500 text-base text-gray-800 placeholder-gray-600 text-center tracking-widest bg-white ${
passwordError
? "border-red-300 focus:ring-red-500"
: "border-gray-300 focus:ring-blue-500"
}`}
/>
{passwordError && (
<div
id="password-error"
class="text-red-600 text-base font-medium"
role="alert"
>
{passwordError}
</div>
)}
{tempPassword.length > 0 && tempPassword.length < 4 && (
<p id="password-hint" class="text-sm text-red-700">
PIN must be at least 4 characters
</p>
)}
{isPasswordValid && (
<p id="password-hint" class="text-sm text-green-700">
✓ PIN valid - press Enter or click{" "}
{isChangeMode ? "Update" : "Continue"}
</p>
)}
{/* Persist across devices checkbox - only show in initial setup, not change mode */}
{!isChangeMode && (
<div class="flex items-center space-x-2 mt-3">
<input
id="persist-across-devices"
type="checkbox"
checked={persistAcrossDevices}
onChange={handleCheckboxChange}
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
/>
<label
htmlFor="persist-across-devices"
class="text-sm text-gray-700 cursor-pointer select-none"
>
Persist across devices
</label>
</div>
)}
</form>
<div class="flex gap-3">
<button
type="button"
onClick={onSetPassword}
disabled={!isPasswordValid && !isChangeMode}
aria-label={isChangeMode
? "Update encryption PIN"
: "Set encryption PIN"}
class={`flex-1 px-4 py-3 rounded-xl text-base font-medium transition-colors duration-200 ${
(isPasswordValid ||
(isChangeMode && tempPassword.length === 0))
? "bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 hover:bg-gray-100"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
>
{isChangeMode ? "Update" : "Continue"}
</button>
<button
type="button"
onClick={handleCancel}
class="px-6 py-3 text-base bg-gray-200 text-gray-500 hover:text-gray-700 font-medium transition-colors duration-200 rounded-xl hover:bg-gray-100"
aria-label={isChangeMode
? "Cancel PIN change"
: "Skip encryption setup"}
>
{isChangeMode ? "Cancel" : "Skip"}
</button>
</div>
</div>
</div>
</div>
{/* Confirmation Dialog for PIN Removal */}
{showRemovePinConfirm && onRemovePinConfirm && onSkip && (
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
role="dialog"
aria-labelledby="remove-pin-confirm-title"
>
<div class="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-sm mx-auto transform transition-all duration-200 scale-100">
<div class="flex flex-col items-center text-center space-y-4">
<div class="flex-1 min-w-0">
<h3
id="remove-pin-confirm-title"
class="text-lg font-semibold text-gray-800"
>
Remove PIN?
</h3>
<p class="text-sm text-gray-700 mt-2">
This will remove the previously saved PIN
</p>
</div>
</div>
<div class="mt-6 flex gap-3">
<button
type="button"
onClick={onRemovePinConfirm}
class="flex-1 px-4 py-3 bg-red-600 text-white rounded-xl text-base font-medium transition-colors duration-200 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
aria-label="Remove PIN"
>
OK
</button>
<button
type="button"
onClick={onSkip}
class="flex-1 px-4 py-3 bg-gray-200 text-gray-700 rounded-xl text-base font-medium transition-colors duration-200 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500"
aria-label="Cancel PIN removal"
>
Cancel
</button>
</div>
</div>
</div>
)}
</>
);
}