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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' : ''}`}>a–z</span>
|
||||
<span className={`pw-class-hint${classes.upper ? ' active' : ''}`}>A–Z</span>
|
||||
<span className={`pw-class-hint${classes.digit ? ' active' : ''}`}>0–9</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>
|
||||
|
||||
86
decnet_web/src/utils/passwordPolicy.test.ts
Normal file
86
decnet_web/src/utils/passwordPolicy.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateNewPassword, MIN_LENGTH, MAX_BYTES, MIN_CLASSES } from './passwordPolicy';
|
||||
|
||||
describe('validateNewPassword', () => {
|
||||
it('exports the expected constants', () => {
|
||||
expect(MIN_LENGTH).toBe(12);
|
||||
expect(MAX_BYTES).toBe(72);
|
||||
expect(MIN_CLASSES).toBe(3);
|
||||
});
|
||||
|
||||
it('empty string — length and class checks fail, ok=false', () => {
|
||||
const r = validateNewPassword('');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.checks.find((c) => c.id === 'min-length')!.passed).toBe(false);
|
||||
expect(r.checks.find((c) => c.id === 'char-classes')!.passed).toBe(false);
|
||||
// max-bytes trivially passes (0 <= 72), but ok is still false
|
||||
expect(r.checks.find((c) => c.id === 'max-bytes')!.passed).toBe(true);
|
||||
expect(r.classes).toEqual({ lower: false, upper: false, digit: false, special: false });
|
||||
});
|
||||
|
||||
it('11 characters — fails length check', () => {
|
||||
const r = validateNewPassword('aB1!aB1!aB1'); // 11 chars, 3 classes
|
||||
const lengthCheck = r.checks.find((c) => c.id === 'min-length')!;
|
||||
expect(lengthCheck.passed).toBe(false);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('exactly 12 characters — passes length check', () => {
|
||||
const r = validateNewPassword('aB1!aB1!aB1!'); // 12 chars
|
||||
const lengthCheck = r.checks.find((c) => c.id === 'min-length')!;
|
||||
expect(lengthCheck.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('multibyte: <=72 chars but >72 bytes — fails byte check', () => {
|
||||
// Each '€' is 3 bytes in UTF-8. 25 × '€' = 75 bytes but only 25 chars.
|
||||
// Pad with ASCII so length >=12 and classes >=3 to isolate the byte check.
|
||||
const emoji = '€'.repeat(20) + 'aB1!'; // 24 chars, 20×3+4 = 64 bytes → passes
|
||||
const r1 = validateNewPassword(emoji);
|
||||
const byteCheck1 = r1.checks.find((c) => c.id === 'max-bytes')!;
|
||||
expect(byteCheck1.passed).toBe(true);
|
||||
|
||||
// 25 × '€' (75 bytes) + 'aB1!' → 79 bytes → fails
|
||||
const tooBig = '€'.repeat(25) + 'aB1!'; // 29 chars, 79 bytes
|
||||
const r2 = validateNewPassword(tooBig);
|
||||
const byteCheck2 = r2.checks.find((c) => c.id === 'max-bytes')!;
|
||||
expect(byteCheck2.passed).toBe(false);
|
||||
expect(r2.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('1 class (lowercase only) — class check fails', () => {
|
||||
const r = validateNewPassword('abcdefghijkl'); // 12 chars, 1 class
|
||||
const cls = r.checks.find((c) => c.id === 'char-classes')!;
|
||||
expect(cls.passed).toBe(false);
|
||||
expect(r.classes.lower).toBe(true);
|
||||
expect(r.classes.upper).toBe(false);
|
||||
});
|
||||
|
||||
it('2 classes (lower + upper) — class check fails', () => {
|
||||
const r = validateNewPassword('AbcdefGhijkl'); // 12 chars, 2 classes
|
||||
const cls = r.checks.find((c) => c.id === 'char-classes')!;
|
||||
expect(cls.passed).toBe(false);
|
||||
expect(r.classes.lower).toBe(true);
|
||||
expect(r.classes.upper).toBe(true);
|
||||
expect(r.classes.digit).toBe(false);
|
||||
});
|
||||
|
||||
it('3 classes (lower + upper + digit) — class check passes', () => {
|
||||
const r = validateNewPassword('AbcdefGhij12'); // 12 chars, 3 classes
|
||||
const cls = r.checks.find((c) => c.id === 'char-classes')!;
|
||||
expect(cls.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('4 classes — class check passes', () => {
|
||||
const r = validateNewPassword('AbcdefGh1!23'); // 12 chars, all 4 classes
|
||||
const cls = r.checks.find((c) => c.id === 'char-classes')!;
|
||||
expect(cls.passed).toBe(true);
|
||||
expect(r.classes).toEqual({ lower: true, upper: true, digit: true, special: true });
|
||||
});
|
||||
|
||||
it('fully valid strong password — ok=true', () => {
|
||||
const r = validateNewPassword('Tr0ub4dor&3xY'); // long, all classes
|
||||
expect(r.ok).toBe(true);
|
||||
r.checks.forEach((c) => expect(c.passed).toBe(true));
|
||||
});
|
||||
});
|
||||
62
decnet_web/src/utils/passwordPolicy.ts
Normal file
62
decnet_web/src/utils/passwordPolicy.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// Client-side password policy mirror.
|
||||
// NOTE: This is advisory UX only — the API remains the enforcement boundary.
|
||||
// Keep constants in sync with decnet/web/db/models/auth.py ChangePasswordRequest.
|
||||
|
||||
export const MIN_LENGTH = 12;
|
||||
export const MAX_BYTES = 72;
|
||||
export const MIN_CLASSES = 3;
|
||||
|
||||
export interface PasswordCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordResult {
|
||||
checks: PasswordCheck[];
|
||||
/** True when every check passes. */
|
||||
ok: boolean;
|
||||
/** Individual character-class flags exposed for richer UI hints. */
|
||||
classes: {
|
||||
lower: boolean;
|
||||
upper: boolean;
|
||||
digit: boolean;
|
||||
special: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function validateNewPassword(pw: string): PasswordResult {
|
||||
const lower = /[a-z]/.test(pw);
|
||||
const upper = /[A-Z]/.test(pw);
|
||||
const digit = /[0-9]/.test(pw);
|
||||
const special = /[^a-zA-Z0-9]/.test(pw);
|
||||
const classCount = [lower, upper, digit, special].filter(Boolean).length;
|
||||
|
||||
const byteLen = new TextEncoder().encode(pw).length;
|
||||
|
||||
const checks: PasswordCheck[] = [
|
||||
{
|
||||
id: 'min-length',
|
||||
label: `At least ${MIN_LENGTH} characters`,
|
||||
passed: pw.length >= MIN_LENGTH,
|
||||
},
|
||||
{
|
||||
id: 'max-bytes',
|
||||
label: `${MAX_BYTES} bytes or fewer (bcrypt limit)`,
|
||||
passed: byteLen <= MAX_BYTES,
|
||||
},
|
||||
{
|
||||
id: 'char-classes',
|
||||
label: `At least ${MIN_CLASSES} of 4 character classes (lowercase, uppercase, digit, special)`,
|
||||
passed: classCount >= MIN_CLASSES,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
checks,
|
||||
ok: checks.every((c) => c.passed),
|
||||
classes: { lower, upper, digit, special },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user