FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
77 lines
2.7 KiB
TypeScript
77 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import { type InputHTMLAttributes, forwardRef, useState } from "react";
|
|
import { Eye, EyeOff } from "lucide-react";
|
|
import clsx from "clsx";
|
|
|
|
interface PasswordFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
label: string;
|
|
error?: string;
|
|
hint?: string;
|
|
}
|
|
|
|
/** Same visual language as `FormField`, plus a show/hide toggle - same
|
|
* crossfade-icon-swap technique as `ApiKeyRow`'s Show/Hide button. */
|
|
export const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(
|
|
({ label, error, hint, id, className, ...props }, ref) => {
|
|
const [revealed, setRevealed] = useState(false);
|
|
const fieldId = id ?? props.name;
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={fieldId} className="block text-sm font-medium text-slate-700">
|
|
{label}
|
|
</label>
|
|
<div className="relative mt-1">
|
|
<input
|
|
ref={ref}
|
|
id={fieldId}
|
|
type={revealed ? "text" : "password"}
|
|
className={clsx(
|
|
"focus-ring block w-full rounded-md border border-slate-300 px-3 py-2 pr-10 text-sm shadow-sm transition-colors duration-150",
|
|
error && "border-red-400",
|
|
className,
|
|
)}
|
|
aria-invalid={error ? "true" : "false"}
|
|
aria-describedby={error ? `${fieldId}-error` : hint ? `${fieldId}-hint` : undefined}
|
|
{...props}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setRevealed((r) => !r)}
|
|
title={revealed ? "Hide password" : "Show password"}
|
|
tabIndex={-1}
|
|
className="focus-ring absolute inset-y-0 right-0 flex w-9 items-center justify-center text-slate-400 transition-colors duration-150 hover:text-slate-600"
|
|
>
|
|
<span className="relative block h-4 w-4">
|
|
<Eye
|
|
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-0" : "opacity-100"}`}
|
|
aria-hidden
|
|
/>
|
|
<EyeOff
|
|
className={`absolute inset-0 h-4 w-4 transition-opacity duration-200 ${revealed ? "opacity-100" : "opacity-0"}`}
|
|
aria-hidden
|
|
/>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{hint && !error && (
|
|
<p id={`${fieldId}-hint`} className="mt-1 text-xs text-slate-500">
|
|
{hint}
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p
|
|
id={`${fieldId}-error`}
|
|
className="mt-1 animate-fade-in text-xs text-red-600"
|
|
role="alert"
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
PasswordField.displayName = "PasswordField";
|