From 6061d4ab94c8255727f1b95702451cba25fc6ead Mon Sep 17 00:00:00 2001 From: tototomate123 Date: Thu, 12 Feb 2026 19:58:13 +0100 Subject: [PATCH] fix(auth): align frontend password validation with production policy --- backend/src/auth/adminRoutes.ts | 10 ++++++++ frontend/src/pages/Admin.tsx | 14 ++++++++++- frontend/src/pages/Login.tsx | 16 ++++++++---- frontend/src/pages/PasswordResetConfirm.tsx | 11 ++++++--- frontend/src/pages/Profile.tsx | 11 ++++++--- frontend/src/pages/Register.tsx | 22 ++++++++--------- frontend/src/utils/passwordPolicy.ts | 27 +++++++++++++++++++++ 7 files changed, 87 insertions(+), 24 deletions(-) create mode 100644 frontend/src/utils/passwordPolicy.ts diff --git a/backend/src/auth/adminRoutes.ts b/backend/src/auth/adminRoutes.ts index bed8691..89a6dd4 100644 --- a/backend/src/auth/adminRoutes.ts +++ b/backend/src/auth/adminRoutes.ts @@ -397,6 +397,16 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { const parsed = adminCreateUserSchema.safeParse(req.body); if (!parsed.success) { + const summarizedIssues = parsed.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path.join("."), + message: issue.message, + })); + console.warn("[auth/users] validation failed", { + issues: summarizedIssues, + requestId: req.headers["x-request-id"], + ip: req.ip || req.connection.remoteAddress || "unknown", + }); return res.status(400).json({ error: "Validation error", message: "Invalid user payload", diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index ea5e07c..f12da80 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -12,6 +12,11 @@ import { readImpersonationState, USER_KEY, } from '../utils/impersonation'; +import { + getPasswordMinLength, + getPasswordRequirementsLabel, + validatePasswordForCurrentEnv, +} from '../utils/passwordPolicy'; type AdminUser = { id: string; @@ -226,6 +231,12 @@ export const Admin: React.FC = () => { setError(''); setSuccess(''); + const passwordError = validatePasswordForCurrentEnv(createPassword, 'Temporary password'); + if (passwordError) { + setError(passwordError); + return; + } + try { const payload = { email: createEmail.trim().toLowerCase(), @@ -430,8 +441,9 @@ export const Admin: React.FC = () => { type="password" value={createPassword} onChange={e => setCreatePassword(e.target.value)} - minLength={8} + minLength={getPasswordMinLength()} required + placeholder={`Temporary password (${getPasswordRequirementsLabel()})`} className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" /> diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 36064d6..bdf2a1d 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -4,6 +4,11 @@ import { useAuth } from '../context/AuthContext'; import { Logo } from '../components/Logo'; import * as api from '../api'; import { USER_KEY } from '../utils/impersonation'; +import { + getPasswordMinLength, + getPasswordRequirementsLabel, + validatePasswordForCurrentEnv, +} from '../utils/passwordPolicy'; export const Login: React.FC = () => { const [email, setEmail] = useState(''); @@ -105,8 +110,9 @@ export const Login: React.FC = () => { setError('Please enter and confirm a new password'); return; } - if (newPassword.length < 8) { - setError('New password must be at least 8 characters long'); + const passwordError = validatePasswordForCurrentEnv(newPassword, 'New password'); + if (passwordError) { + setError(passwordError); return; } if (newPassword !== confirmNewPassword) { @@ -233,9 +239,9 @@ export const Login: React.FC = () => { type="password" autoComplete="new-password" required - minLength={8} + minLength={getPasswordMinLength()} className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" - placeholder="New password (min 8 characters)" + placeholder={`New password (${getPasswordRequirementsLabel()})`} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} /> @@ -250,7 +256,7 @@ export const Login: React.FC = () => { type="password" autoComplete="new-password" required - minLength={8} + minLength={getPasswordMinLength()} className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" placeholder="Confirm new password" value={confirmNewPassword} diff --git a/frontend/src/pages/PasswordResetConfirm.tsx b/frontend/src/pages/PasswordResetConfirm.tsx index 379a075..66729b2 100644 --- a/frontend/src/pages/PasswordResetConfirm.tsx +++ b/frontend/src/pages/PasswordResetConfirm.tsx @@ -2,6 +2,10 @@ import React, { useState, useEffect } from 'react'; import { useSearchParams, useNavigate, Link } from 'react-router-dom'; import { Logo } from '../components/Logo'; import { authPasswordResetConfirm, isAxiosError } from '../api'; +import { + getPasswordRequirementsLabel, + validatePasswordForCurrentEnv, +} from '../utils/passwordPolicy'; export const PasswordResetConfirm: React.FC = () => { const [searchParams] = useSearchParams(); @@ -29,8 +33,9 @@ export const PasswordResetConfirm: React.FC = () => { return; } - if (password.length < 8) { - setError('Password must be at least 8 characters long'); + const passwordError = validatePasswordForCurrentEnv(password); + if (passwordError) { + setError(passwordError); return; } @@ -124,7 +129,7 @@ export const PasswordResetConfirm: React.FC = () => { autoComplete="new-password" required className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" - placeholder="New password (min 8 characters)" + placeholder={`New password (${getPasswordRequirementsLabel()})`} value={password} onChange={(e) => setPassword(e.target.value)} /> diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index cfc9dd6..8c4fb46 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -6,6 +6,10 @@ import * as api from '../api'; import type { Collection } from '../types'; import { User, Lock, Save, X, Shield } from 'lucide-react'; import { USER_KEY } from '../utils/impersonation'; +import { + getPasswordRequirementsLabel, + validatePasswordForCurrentEnv, +} from '../utils/passwordPolicy'; export const Profile: React.FC = () => { const { user: authUser, logout, authEnabled } = useAuth(); @@ -162,8 +166,9 @@ export const Profile: React.FC = () => { return; } - if (newPassword.length < 8) { - setError('New password must be at least 8 characters long'); + const passwordError = validatePasswordForCurrentEnv(newPassword, 'New password'); + if (passwordError) { + setError(passwordError); return; } @@ -488,7 +493,7 @@ export const Profile: React.FC = () => { value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium" - placeholder="Enter new password (min 8 characters)" + placeholder={`Enter new password (${getPasswordRequirementsLabel()})`} /> diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 706c9db..595ee3e 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -2,9 +2,13 @@ import React, { useEffect, useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { Logo } from '../components/Logo'; +import { + getPasswordMinLength, + getPasswordRequirementsLabel, + validatePasswordForCurrentEnv, +} from '../utils/passwordPolicy'; export const Register: React.FC = () => { - const strongPasswordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,100}$/; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); @@ -44,13 +48,9 @@ export const Register: React.FC = () => { e.preventDefault(); setError(''); - if (import.meta.env.PROD) { - if (!strongPasswordPattern.test(password)) { - setError('Password must be 12+ chars and include upper, lower, number, and symbol'); - return; - } - } else if (password.length < 8) { - setError('Password must be at least 8 characters long'); + const passwordError = validatePasswordForCurrentEnv(password); + if (passwordError) { + setError(passwordError); return; } @@ -140,11 +140,9 @@ export const Register: React.FC = () => { type="password" autoComplete="new-password" required - minLength={import.meta.env.PROD ? 12 : 8} + minLength={getPasswordMinLength()} className="appearance-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" - placeholder={import.meta.env.PROD - ? "Password (12+, upper/lower/number/symbol)" - : "Password (min 8 characters)"} + placeholder={`Password (${getPasswordRequirementsLabel()})`} value={password} onChange={(e) => setPassword(e.target.value)} /> diff --git a/frontend/src/utils/passwordPolicy.ts b/frontend/src/utils/passwordPolicy.ts new file mode 100644 index 0000000..a5bd72f --- /dev/null +++ b/frontend/src/utils/passwordPolicy.ts @@ -0,0 +1,27 @@ +const strongPasswordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,100}$/; + +export const getPasswordMinLength = (): number => (import.meta.env.PROD ? 12 : 8); + +export const getPasswordRequirementsLabel = (): string => + import.meta.env.PROD + ? "12+ chars with upper/lowercase, number, and symbol" + : "at least 8 characters"; + +export const validatePasswordForCurrentEnv = ( + password: string, + fieldLabel = "Password" +): string | null => { + if (import.meta.env.PROD) { + if (!strongPasswordPattern.test(password)) { + return `${fieldLabel} must be 12+ chars and include upper, lower, number, and symbol`; + } + return null; + } + + if (password.length < 8) { + return `${fieldLabel} must be at least 8 characters long`; + } + + return null; +}; +