diff --git a/.gitignore b/.gitignore index 3e00084..7a1b2b0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,12 @@ dist-ssr # vite-plugin-pwa dev output dev-dist .agents/ + +# Testing / E2E / API tests +.env.e2e* +e2e/ +api-tests/ +playwright-report/ +test-results/ +coverage/ + diff --git a/README.md b/README.md index e2f9db2..7e133a3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This is a Progressive Web App (Pwa) built for the Krishna Sales team. - Logout now calls `POST /usr/logout` to revoke the session and cookie on the server (retried if the call fails). Sign-out and sign-in in one tab are reflected in the others; a different user signing in elsewhere reloads the tab. - One shared session for all four workflow clients (`src/api/session.ts`) replaces the per-client token copies. Existing sign-ins are cleared once, so everyone logs in again after this update. - Password policy: live rules checklist, forced change on login when the password is expired, temporary or predates the policy, forgot / reset password, and change password. -- "Password change due" banner when the password is about to expire (from `user.password_status` at login), with a **Change now** shortcut. +- Password expiry warning & enforcement: Evaluates `user.password_status` (`warn`, `days_left`, `must_change`). Shows a top banner in `ConsoleLayout` with "Change Password" and "Later" skip options during the warning window; automatically launches the mandatory password change dialog when the password has expired. - Note: the refresh cookie is only set when the app and the API share one origin (`VITE_ZINO_API_URL` = `SAME_ORIGIN`, or the app's own host). On a different origin the server returns the token but no cookie, and the session ends when the access token expires. **Maintenance** diff --git a/eslint.config.js b/eslint.config.js index ef614d2..65c6885 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,4 +19,9 @@ export default defineConfig([ globals: globals.browser, }, }, + { + // Playwright fixtures take a `use` callback that the React hooks rule mistakes for a hook. + files: ['e2e/**/*.ts'], + rules: { 'react-hooks/rules-of-hooks': 'off' }, + }, ]) diff --git a/package-lock.json b/package-lock.json index ddf89b9..23bac6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "krishna-sales-pwa", - "version": "1.1.0", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "krishna-sales-pwa", - "version": "1.1.0", + "version": "1.0.4", "dependencies": { "@react-google-maps/api": "^2.20.8", "jspdf": "^4.2.1", @@ -20,12 +20,14 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.65.0", "@tailwindcss/vite": "^4.0.0", "@types/node": "^24.13.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vite-pwa/assets-generator": "^1.0.2", "@vitejs/plugin-react": "^6.0.3", + "dotenv": "^18.0.9", "eslint": "^10.6.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", @@ -2309,6 +2311,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@quansync/fs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", @@ -4716,6 +4734,22 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/dotenv": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz", + "integrity": "sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -7107,6 +7141,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index affc783..f85a14d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,13 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "generate-pwa-assets": "pwa-assets-generator" + "generate-pwa-assets": "pwa-assets-generator", + "e2e": "playwright test -c e2e/playwright.config.ts", + "e2e:mocked": "playwright test -c e2e/playwright.config.ts --project=mocked", + "e2e:live": "playwright test -c e2e/playwright.config.ts --project=setup --project=live", + "e2e:report": "playwright show-report e2e/playwright-report", + "e2e:typecheck": "tsc -p e2e --noEmit", + "api:test": "node api-tests/run.mjs" }, "dependencies": { "@react-google-maps/api": "^2.20.8", @@ -23,12 +29,14 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.65.0", "@tailwindcss/vite": "^4.0.0", "@types/node": "^24.13.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vite-pwa/assets-generator": "^1.0.2", "@vitejs/plugin-react": "^6.0.3", + "dotenv": "^18.0.9", "eslint": "^10.6.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", diff --git a/src/api/auth.ts b/src/api/auth.ts index 9236b89..a5ca95a 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -88,7 +88,7 @@ export async function resetPassword(resetToken: string, newPassword: string): Pr * On 200 success, the backend bumps password_changed_at and invalidates the session JWT. */ export async function changePassword( - currentPassword: string, + currentPassword: string | undefined | null, newPassword: string, token?: string | null ): Promise<{ message: string }> { @@ -97,6 +97,13 @@ export async function changePassword( throw new AuthApiError('You must be signed in to change your password', 401); } + const payload: Record = { + new_password: newPassword, + }; + if (currentPassword) { + payload.current_password = currentPassword; + } + const res = await fetch(`${BASE_URL}/usr/me/password`, { method: 'POST', credentials: AUTH_CREDENTIALS, @@ -104,10 +111,7 @@ export async function changePassword( 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, - body: JSON.stringify({ - current_password: currentPassword, - new_password: newPassword, - }), + body: JSON.stringify(payload), }); const body = await res.json().catch(() => ({})); diff --git a/src/api/clients.ts b/src/api/clients.ts index c0e2891..c01d98f 100644 --- a/src/api/clients.ts +++ b/src/api/clients.ts @@ -36,6 +36,7 @@ export async function loginAll(email: string, password: string, orgId?: string) try { const me = await orderBookingClient.getMe(); res.user = { ...res.user, ...me }; + ALL.forEach((c) => c.setUser(res.user)); } catch (e) { console.error('Failed to fetch user profile:', e); } @@ -55,6 +56,7 @@ export async function completePasswordChangeAll( try { const me = await orderBookingClient.getMe(); res.user = { ...res.user, ...me }; + ALL.forEach((c) => c.setUser(res.user)); } catch (e) { console.error('Failed to fetch user profile:', e); } diff --git a/src/api/types.ts b/src/api/types.ts index ff81ed6..0f6f2e3 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -10,6 +10,13 @@ export interface ApiError { data?: any; } +export interface PasswordStatus { + must_change?: boolean; + expires_at?: string; + days_left?: number; + warn?: boolean; +} + export interface User { id: string; org_id: string; @@ -18,6 +25,8 @@ export interface User { roles: string[]; groups: string[]; is_admin?: boolean; + provider?: string; + password_status?: PasswordStatus; } export interface LoginResponse { diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index c651f2d..a20446e 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -1,6 +1,7 @@ import { useState, type ReactNode, useEffect } from 'react'; import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients'; -import { AuthCtx, type AuthValue, type PasswordStatus } from './context'; +import { AuthCtx, type AuthValue } from './context'; +import type { PasswordStatus } from '../api/types'; import { sessionClient } from '../api/session'; export function AuthProvider({ children }: { children: ReactNode }) { @@ -17,12 +18,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); const [passwordStatus, setPasswordStatus] = useState(() => { - try { - const raw = localStorage.getItem('krishna_sales_mobile_password_status'); - return raw ? (JSON.parse(raw) as PasswordStatus) : null; - } catch { - return null; - } + return orderBookingClient.currentUser()?.password_status ?? null; }); // Keep React in step with the session: refresh/idle expiry, a sign-out or a @@ -48,10 +44,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { - if (authed && (!isAdmin || roles.length === 0)) { + if (authed && (!isAdmin || roles.length === 0 || !passwordStatus)) { orderBookingClient.getMe().then(me => { if (me.is_admin) setIsAdmin(true); if (me.roles) setRoles(me.roles); + if (me.password_status) setPasswordStatus(me.password_status); }).catch(console.error); } }, [authed]); @@ -68,11 +65,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false'); setRoles(userRoles); localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles)); - // Only a due/expiring password is worth remembering; a fresh login replaces it. - const status: PasswordStatus | null = res.user?.password_status?.warn ? res.user.password_status : null; - setPasswordStatus(status); - if (status) localStorage.setItem('krishna_sales_mobile_password_status', JSON.stringify(status)); - else localStorage.removeItem('krishna_sales_mobile_password_status'); + if (res.user?.password_status) { + setPasswordStatus(res.user.password_status); + } else { + setPasswordStatus(null); + } }; const value: AuthValue = { diff --git a/src/auth/context.ts b/src/auth/context.ts index 1ef3818..a9ef4bb 100644 --- a/src/auth/context.ts +++ b/src/auth/context.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from 'react'; +import type { PasswordStatus } from '../api/types'; export interface PasswordChangeRequiredInfo { message: string; @@ -6,19 +7,12 @@ export interface PasswordChangeRequiredInfo { rules?: string[]; } -/** `user.password_status` from /usr/login: set when a password change is coming due. */ -export interface PasswordStatus { - warn?: boolean; - days_left?: number; - change_required_from?: string; -} - export interface AuthValue { authed: boolean; - passwordStatus: PasswordStatus | null; isAdmin: boolean; roles: string[]; userEmail: string | null; + passwordStatus: PasswordStatus | null; login: (email: string, password: string, orgId?: string) => Promise; completePasswordChange: (email: string, currentPassword: string, newPassword: string, orgId?: string) => Promise; logout: () => void; diff --git a/src/components/auth/ChangePasswordModal.tsx b/src/components/auth/ChangePasswordModal.tsx index 8d1db19..14aaeb3 100644 --- a/src/components/auth/ChangePasswordModal.tsx +++ b/src/components/auth/ChangePasswordModal.tsx @@ -5,6 +5,7 @@ import { Button } from '../buttons/Button'; import { PasswordRulesChecklist } from './PasswordRulesChecklist'; import { changePassword } from '../../api/auth'; import { usePasswordPolicy } from '../../hooks/usePasswordPolicy'; +import { cn } from '../../lib/cn'; import { passwordMeetsPolicy, passwordTooLong, @@ -17,6 +18,7 @@ interface ChangePasswordModalProps { userEmail?: string | null; userName?: string | null; onSuccessLogout: () => void; + required?: boolean; } export function ChangePasswordModal({ @@ -25,6 +27,7 @@ export function ChangePasswordModal({ userEmail, userName, onSuccessLogout, + required = false, }: ChangePasswordModalProps) { const policy = usePasswordPolicy(open); const [currentPassword, setCurrentPassword] = useState(''); @@ -34,6 +37,12 @@ export function ChangePasswordModal({ const [showNew, setShowNew] = useState(false); const [showConfirm, setShowConfirm] = useState(false); + const [fieldErrors, setFieldErrors] = useState<{ + current?: boolean; + new?: boolean; + confirm?: boolean; + }>({}); + const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [done, setDone] = useState(false); @@ -43,6 +52,7 @@ export function ChangePasswordModal({ setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); + setFieldErrors({}); setError(null); onClose(); }; @@ -50,35 +60,61 @@ export function ChangePasswordModal({ const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(null); + setFieldErrors({}); - if (!currentPassword || !newPassword || !confirmPassword) { - setError('All fields are required.'); + const errors: { current?: boolean; new?: boolean; confirm?: boolean } = {}; + + if (!required && !currentPassword) { + errors.current = true; + } + if (!newPassword) { + errors.new = true; + } + if (!confirmPassword) { + errors.confirm = true; + } + + if (Object.keys(errors).length > 0) { + setFieldErrors(errors); + if (!required && errors.current) { + setError('Current password is required.'); + } else if (errors.new && errors.confirm) { + setError('Please enter and confirm your new password.'); + } else if (errors.new) { + setError('New password is required.'); + } else { + setError('Please confirm your new password.'); + } return; } if (!passwordMeetsPolicy(policy, newPassword, { email: userEmail, name: userName })) { + setFieldErrors({ new: true }); setError('New password does not meet all of the password requirements.'); return; } if (passwordTooLong(newPassword)) { + setFieldErrors({ new: true }); setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`); return; } if (newPassword !== confirmPassword) { + setFieldErrors({ confirm: true }); setError('New passwords do not match.'); return; } - if (newPassword === currentPassword) { + if (!required && currentPassword && newPassword === currentPassword) { + setFieldErrors({ new: true }); setError('New password must be different from your current password.'); return; } setSubmitting(true); try { - await changePassword(currentPassword, newPassword); + await changePassword(required ? undefined : currentPassword, newPassword); setDone(true); setTimeout(() => { onSuccessLogout(); @@ -94,43 +130,58 @@ export function ChangePasswordModal({ - Change Password + {required ? 'Password Expired' : 'Change Password'} } - subtitle="Update your account password. You'll need to sign in again after." + subtitle={ + required + ? 'Your password has expired. Please set a new password to continue.' + : "Update your account password. You'll need to sign in again after." + } > -
- {/* Current Password */} -
- -
- setCurrentPassword(e.target.value)} - disabled={submitting || done} - required - className="h-[42px] w-full px-3 pr-10 text-sm border border-slate-200 bg-slate-50/50 rounded-lg outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all text-slate-800" - placeholder="Enter current password" - /> - + + {/* Current Password — only required for self-service change; hidden when must_change is active */} + {!required && ( +
+ +
+ { + setCurrentPassword(e.target.value); + if (fieldErrors.current) setFieldErrors((prev) => ({ ...prev, current: false })); + setError(null); + }} + disabled={submitting || done} + className={cn( + 'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all', + fieldErrors.current + ? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500' + : 'border border-slate-200 bg-slate-50/50 text-slate-800 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500' + )} + placeholder="Enter current password" + /> + +
-
+ )} {/* New Password */}
@@ -142,10 +193,19 @@ export function ChangePasswordModal({ type={showNew ? 'text' : 'password'} autoComplete="new-password" value={newPassword} - onChange={(e) => setNewPassword(e.target.value)} + onChange={(e) => { + setNewPassword(e.target.value); + if (fieldErrors.new) setFieldErrors((prev) => ({ ...prev, new: false })); + setError(null); + }} disabled={submitting || done} - required - className="h-[42px] w-full px-3 pr-10 text-sm border border-slate-200 bg-slate-50/50 rounded-lg outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all text-slate-800" + autoFocus={required} + className={cn( + 'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all', + fieldErrors.new + ? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500' + : 'border border-slate-200 bg-slate-50/50 text-slate-800 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500' + )} placeholder="Enter new password" /> + {required ? ( + + ) : ( + + )} +
+
+ + ); + } + + return ( +
+
+
+ +
+ {headline} + + Please update your password to maintain access to your account. + +
+
+ +
+ + + +
+
+
+ ); +} + diff --git a/src/components/reusable/Modal.tsx b/src/components/reusable/Modal.tsx index 2060679..433dd3e 100644 --- a/src/components/reusable/Modal.tsx +++ b/src/components/reusable/Modal.tsx @@ -22,12 +22,24 @@ export interface ModalProps { maxHeight?: string; actions?: ReactNode; children?: ReactNode; + /** Whether the modal can be dismissed via Esc, backdrop, or close button. @default true */ + closable?: boolean; } /** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */ -export function Modal({ open, onClose, title, subtitle, width = 'md', maxHeight = 'max-h-[80dvh]', actions, children }: ModalProps) { +export function Modal({ + open, + onClose, + title, + subtitle, + width = 'md', + maxHeight = 'max-h-[80dvh]', + actions, + children, + closable = true, +}: ModalProps) { useEffect(() => { - if (!open) return; + if (!open || !closable) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; @@ -38,7 +50,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', maxHeight document.removeEventListener('keydown', onKey); document.body.style.overflow = prev; }; - }, [open, onClose]); + }, [open, onClose, closable]); if (!open) return null; @@ -46,7 +58,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', maxHeight
{ - if (e.target === e.currentTarget) onClose(); + if (closable && e.target === e.currentTarget) onClose(); }} >
{actions &&
{actions}
} - + {closable && ( + + )}
diff --git a/src/screens/ConsoleLayout.tsx b/src/screens/ConsoleLayout.tsx index 2e506ac..f8a3d21 100644 --- a/src/screens/ConsoleLayout.tsx +++ b/src/screens/ConsoleLayout.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'; -import { LogOut, Menu, X, Database, Users, KeyRound, AlertTriangle } from 'lucide-react'; +import { LogOut, Menu, X, Database, Users, KeyRound } from 'lucide-react'; import { cn } from '../lib/cn'; import { useAuth } from '../auth/context'; import { onAuthErrorAll, orderBookingClient } from '../api/clients'; @@ -8,6 +8,7 @@ import { SCREENS } from './tabs'; import { routeConfig } from '../routesConfig'; import { APP_VERSION } from '../api/config'; import { ChangePasswordModal } from '../components/auth/ChangePasswordModal'; +import { PasswordExpiryWarningBanner } from '../components/auth/PasswordExpiryWarningBanner'; import { NoticeStrips } from '../components/common/NoticeBanner'; import logoUrl from '../assets/logo.png'; import './screen.css'; @@ -25,13 +26,25 @@ const canAccessScreen = (key: string, isAdmin: boolean, roles: string[]) => { /** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed . */ export function ConsoleLayout() { const { authed, logout, isAdmin, roles: userRoles, userEmail, passwordStatus } = useAuth(); - const [passwordDueDismissed, setPasswordDueDismissed] = useState(false); const navigate = useNavigate(); const user = orderBookingClient.currentUser(); + const effectivePasswordStatus = passwordStatus ?? user?.password_status; + + const isExpired = Boolean( + effectivePasswordStatus?.must_change || + (effectivePasswordStatus?.days_left !== undefined && effectivePasswordStatus.days_left <= 0) + ); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); + // If password has expired, automatically move the user directly to change password + useEffect(() => { + if (isExpired) { + setIsChangePasswordOpen(true); + } + }, [isExpired]); + // A 401 from any workflow client bounces back to login. useEffect(() => { onAuthErrorAll(() => { @@ -62,38 +75,10 @@ export function ConsoleLayout() { - - {passwordStatus?.warn && !passwordDueDismissed && ( -
- - - Password change due.{' '} - {typeof passwordStatus.days_left === 'number' - ? passwordStatus.days_left <= 0 - ? 'Your password expires today.' - : `Your password expires in ${passwordStatus.days_left} day${passwordStatus.days_left === 1 ? '' : 's'}.` - : 'Please update it soon.'} - - - -
- )} + setIsChangePasswordOpen(true)} + />
@@ -261,7 +246,12 @@ export function ConsoleLayout() { setIsChangePasswordOpen(false)} + onClose={() => { + if (!isExpired) { + setIsChangePasswordOpen(false); + } + }} + required={isExpired} userEmail={userEmail || user?.email} userName={user?.name} onSuccessLogout={() => { diff --git a/src/screens/LoginPage.tsx b/src/screens/LoginPage.tsx index 1d8b81c..da8ef61 100644 --- a/src/screens/LoginPage.tsx +++ b/src/screens/LoginPage.tsx @@ -6,6 +6,7 @@ import { Button } from '../components/buttons'; import { Card } from '../components/reusable'; import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist'; import { usePasswordPolicy } from '../hooks/usePasswordPolicy'; +import { cn } from '../lib/cn'; import { passwordMeetsPolicy, passwordTooLong, @@ -30,20 +31,45 @@ export function LoginPage() { const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [fieldErrors, setFieldErrors] = useState<{ + email?: boolean; + password?: boolean; + newPassword?: boolean; + confirmPassword?: boolean; + }>({}); + const policy = usePasswordPolicy(!!changeRequired); if (authed) return ; async function submit(e: FormEvent) { e.preventDefault(); - setBusy(true); setError(null); + setFieldErrors({}); + + const errs: typeof fieldErrors = {}; + if (!email) errs.email = true; + if (!password) errs.password = true; + + if (Object.keys(errs).length > 0) { + setFieldErrors(errs); + setError( + errs.email && errs.password + ? 'Please enter both your email and password.' + : errs.email + ? 'Email address is required.' + : 'Password is required.' + ); + return; + } + + setBusy(true); try { await login(email, password); navigate('/daily', { replace: true }); } catch (err: any) { // Backend signals that password policy or expiry requires a new password first - if (err?.code === 'password_change_required' || err?.data?.code === 'password_change_required' || err?.status === 403 && err?.reason === 'policy') { + if (err?.code === 'password_change_required' || err?.data?.code === 'password_change_required' || (err?.status === 403 && err?.reason === 'policy')) { setChangeRequired({ message: err?.message || err?.data?.error || 'A new password policy is in effect. Please choose a new password to continue.', reason: err?.reason || err?.data?.reason, @@ -61,28 +87,44 @@ export function LoginPage() { async function handleChangePasswordSubmit(e: FormEvent) { e.preventDefault(); setError(null); + setFieldErrors({}); - if (!newPassword || !confirmPassword) { - setError('Please fill in both password fields.'); + const errs: typeof fieldErrors = {}; + if (!newPassword) errs.newPassword = true; + if (!confirmPassword) errs.confirmPassword = true; + + if (Object.keys(errs).length > 0) { + setFieldErrors(errs); + setError( + errs.newPassword && errs.confirmPassword + ? 'Please enter and confirm your new password.' + : errs.newPassword + ? 'New password is required.' + : 'Please confirm your new password.' + ); return; } if (!passwordMeetsPolicy(policy, newPassword, { email })) { + setFieldErrors({ newPassword: true }); setError('New password does not meet all of the password requirements.'); return; } if (passwordTooLong(newPassword)) { + setFieldErrors({ newPassword: true }); setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`); return; } if (newPassword !== confirmPassword) { + setFieldErrors({ confirmPassword: true }); setError('New passwords do not match.'); return; } if (newPassword === password) { + setFieldErrors({ newPassword: true }); setError('New password must be different from your current password.'); return; } @@ -102,6 +144,7 @@ export function LoginPage() { setChangeRequired(null); setNewPassword(''); setConfirmPassword(''); + setFieldErrors({}); setError(null); } @@ -129,7 +172,7 @@ export function LoginPage() { {changeRequired ? ( /* Forced Password Change Form */ - +
@@ -147,11 +190,19 @@ export function LoginPage() { type={showNewPassword ? 'text' : 'password'} autoComplete="new-password" value={newPassword} - onChange={(e) => setNewPassword(e.target.value)} - required + onChange={(e) => { + setNewPassword(e.target.value); + if (fieldErrors.newPassword) setFieldErrors((prev) => ({ ...prev, newPassword: false })); + setError(null); + }} autoFocus disabled={busy} - className="h-[42px] w-full px-3 pr-10 border border-emerald-200 bg-emerald-50/50 rounded-md outline-none focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400 transition-all text-emerald-900 placeholder:text-slate-400 text-sm" + className={cn( + 'h-[42px] w-full px-3 pr-10 rounded-md outline-none transition-all text-sm', + fieldErrors.newPassword + ? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500' + : 'border border-emerald-200 bg-emerald-50/50 text-emerald-900 placeholder:text-slate-400 focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400' + )} placeholder="Enter new password" />