password validation meter

This commit is contained in:
Michael Freno
2026-01-01 14:51:23 -05:00
parent 658cf98b7b
commit 0fb071a5d7
6 changed files with 271 additions and 100 deletions

View File

@@ -0,0 +1,151 @@
import { createMemo, For, Show } from "solid-js";
import { validatePassword, type PasswordStrength } from "~/lib/validation";
import { VALIDATION_CONFIG } from "~/config";
import CheckCircle from "./icons/CheckCircle";
interface PasswordStrengthMeterProps {
password: string;
showRequirements?: boolean;
}
interface Requirement {
label: string;
test: (password: string) => boolean;
optional?: boolean;
}
export default function PasswordStrengthMeter(
props: PasswordStrengthMeterProps
) {
const validation = createMemo(() => validatePassword(props.password));
const strengthConfig = {
weak: {
color: "bg-red",
textColor: "text-red",
label: "Weak",
width: "25%"
},
fair: {
color: "bg-yellow",
textColor: "text-yellow",
label: "Fair",
width: "50%"
},
good: {
color: "bg-blue",
textColor: "text-blue",
label: "Good",
width: "75%"
},
strong: {
color: "bg-green",
textColor: "text-green",
label: "Strong",
width: "100%"
}
};
const requirements = createMemo(() => {
const reqs: Requirement[] = [
{
label: `At least ${VALIDATION_CONFIG.MIN_PASSWORD_LENGTH} characters`,
test: (pwd) => pwd.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH
}
];
if (VALIDATION_CONFIG.PASSWORD_REQUIRE_UPPERCASE) {
reqs.push({
label: "One uppercase letter",
test: (pwd) => /[A-Z]/.test(pwd)
});
}
if (VALIDATION_CONFIG.PASSWORD_REQUIRE_NUMBER) {
reqs.push({
label: "One number",
test: (pwd) => /[0-9]/.test(pwd)
});
}
// Always show special character as optional/recommended
reqs.push({
label: "One special character\n(recommended)",
test: (pwd) => /[^A-Za-z0-9]/.test(pwd),
optional: true
});
return reqs;
});
const strength = createMemo(() => validation().strength);
const config = createMemo(() => strengthConfig[strength()]);
return (
<div class="w-3/4 space-y-2">
{/* Strength bar */}
<Show when={props.password.length > 0}>
<div class="space-y-1">
<div class="bg-surface h-2 w-full overflow-hidden rounded-full">
<div
class={`${config().color} h-full transition-all duration-300 ease-out`}
style={{ width: config().width }}
/>
</div>
<div class="flex justify-between text-xs">
<span class={config().textColor}>{config().label}</span>
<Show when={validation().isValid}>
<span class="text-green flex items-center gap-1">
<CheckCircle height={14} width={14} />
Valid
</span>
</Show>
</div>
</div>
</Show>
{/* Requirements checklist */}
<Show when={props.showRequirements !== false}>
<div class="space-y-1 text-sm">
<div class="text-subtext1 text-xs font-medium">
Password Requirements:
</div>
<For each={requirements()}>
{(req) => {
const isMet = createMemo(() => req.test(props.password));
return (
<div
class={`flex items-center gap-2 transition-colors ${
isMet()
? "text-green"
: req.optional
? "text-blue opacity-70"
: props.password.length > 0
? "text-red"
: "text-subtext0"
}`}
>
<Show
when={isMet()}
fallback={
<div
class={`h-4 w-4 rounded-full border-2 ${
req.optional
? "border-blue border-dashed"
: "border-subtext0"
}`}
/>
}
>
<CheckCircle height={16} width={16} />
</Show>
<span class="max-w-3/4">{req.label}</span>
</div>
);
}}
</For>
</div>
</Show>
</div>
);
}