feat(web): live password-strength checklist on change-password

The change-password form let the browser submit short passwords the API
then rejected with an opaque 'Schema structural violation' 400. Add a pure
validateNewPassword() util (>=12 chars, <=72 bytes, >=3 of 4 character
classes — constants tweakable) and a live ✓/✗ checklist above the submit
button so the user sees exactly what's missing. Submit is gated on
validity + confirm-match, so the form can no longer reach that 400.

- Fix minLength 8->12 on the Login change-password inputs and the UsersTab
  admin-reset guard (both lagged the API's min_length=12).
- Light-mode: render the checklist box fully white with black text (the
  neon-on-dark styling read as muddy grey); ✓/✗ icons keep a green/red cue.
- Advisory UX only — the API min_length=12 remains the enforcement boundary;
  character-class complexity is not server-enforced.
This commit is contained in:
2026-06-12 18:59:46 -04:00
parent 721122a7ef
commit 593492411c
5 changed files with 277 additions and 17 deletions

View File

@@ -54,8 +54,8 @@ export const UsersTab: React.FC<Props> = ({
};
const handleResetPassword = async (uuid: string) => {
if (!resetPassword.trim() || resetPassword.length < 8) {
alert('Password must be at least 8 characters');
if (!resetPassword.trim() || resetPassword.length < 12) {
alert('Password must be at least 12 characters');
return;
}
const r = await onResetUserPassword(uuid, resetPassword);
@@ -193,7 +193,7 @@ export const UsersTab: React.FC<Props> = ({
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
minLength={12}
maxLength={72}
/>
</div>

View File

@@ -95,3 +95,83 @@ html[data-theme="light"] .login-form input {
opacity: 0.4;
letter-spacing: 1px;
}
/* Password strength checklist — shown during must_change_password flow */
.pw-checklist {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border: 1px solid var(--border-color);
background-color: rgba(0, 0, 0, 0.25);
}
.pw-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.65rem;
letter-spacing: 0.5px;
opacity: 0.55;
transition: color 0.15s, opacity 0.15s;
}
.pw-check.passed {
color: var(--matrix, #00ff41);
opacity: 0.9;
}
.pw-check.failed {
color: var(--alert, #ff4141);
opacity: 0.75;
}
.pw-check-icon {
font-size: 0.6rem;
width: 10px;
flex-shrink: 0;
}
/* Class sub-hints shown beneath the char-classes check */
.pw-class-hints {
display: flex;
gap: 8px;
padding-left: 16px;
flex-wrap: wrap;
}
.pw-class-hint {
font-size: 0.6rem;
opacity: 0.4;
letter-spacing: 0.3px;
}
.pw-class-hint.active {
color: var(--matrix, #00ff41);
opacity: 0.8;
}
/* Light mode: the neon-on-dark box reads as muddy grey, so go fully white
with black text. Pass/fail stays legible via the ✓/✗ icon colour only. */
html[data-theme="light"] .pw-checklist {
background-color: #ffffff;
border-color: rgba(0, 0, 0, 0.18);
}
html[data-theme="light"] .pw-check,
html[data-theme="light"] .pw-check.passed,
html[data-theme="light"] .pw-check.failed,
html[data-theme="light"] .pw-class-hint,
html[data-theme="light"] .pw-class-hint.active {
color: var(--ink, #1a1a1a);
opacity: 0.85;
}
html[data-theme="light"] .pw-check.passed .pw-check-icon {
color: #0a7d2c;
}
html[data-theme="light"] .pw-check.failed .pw-check-icon {
color: #c62828;
opacity: 1;
}

View File

@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import React, { useState } from 'react';
import api from '../utils/api';
import { validateNewPassword } from '../utils/passwordPolicy';
import './Login.css';
import { Activity } from '../icons';
@@ -47,6 +48,12 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
setError('Passwords do not match');
return;
}
// Client-side policy guard — API is the enforcement boundary; this prevents
// the opaque 400 "Schema structural violation" from reaching the user.
if (!validateNewPassword(newPassword).ok) {
setError('Password does not meet the requirements below');
return;
}
setLoading(true);
setError('');
@@ -113,32 +120,57 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
<p className="violet-accent">MANDATORY SECURITY UPDATE</p>
<p style={{ fontSize: '0.8rem', opacity: 0.7 }}>Please establish a new access key</p>
</div>
<div className="form-group">
<label>NEW ACCESS KEY</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={12}
/>
</div>
<div className="form-group">
<label>CONFIRM KEY</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={12}
/>
</div>
{(() => {
const { checks, classes } = validateNewPassword(newPassword);
return (
<div className="pw-checklist" aria-label="Password requirements">
{checks.map((chk) => (
<div key={chk.id} className={`pw-check ${chk.passed ? 'passed' : 'failed'}`}>
<span className="pw-check-icon">{chk.passed ? '✓' : '✗'}</span>
{chk.label}
{chk.id === 'char-classes' && (
<span className="pw-class-hints">
<span className={`pw-class-hint${classes.lower ? ' active' : ''}`}>az</span>
<span className={`pw-class-hint${classes.upper ? ' active' : ''}`}>AZ</span>
<span className={`pw-class-hint${classes.digit ? ' active' : ''}`}>09</span>
<span className={`pw-class-hint${classes.special ? ' active' : ''}`}>#!@</span>
</span>
)}
</div>
))}
</div>
);
})()}
{error && <div className="error-msg">{error}</div>}
<button type="submit" disabled={loading}>
<button
type="submit"
disabled={loading || !validateNewPassword(newPassword).ok || newPassword !== confirmPassword}
>
{loading ? 'UPDATING...' : 'UPDATE SECURE KEY'}
</button>
</form>