password expiry policy added

This commit is contained in:
suryac 2026-11-11 15:34:22 +05:30
parent 1ed1576101
commit 144f70cd34
15 changed files with 522 additions and 141 deletions

9
.gitignore vendored
View File

@ -26,3 +26,12 @@ dist-ssr
# vite-plugin-pwa dev output # vite-plugin-pwa dev output
dev-dist dev-dist
.agents/ .agents/
# Testing / E2E / API tests
.env.e2e*
e2e/
api-tests/
playwright-report/
test-results/
coverage/

View File

@ -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. - 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. - 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 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. - 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** **Maintenance**

View File

@ -19,4 +19,9 @@ export default defineConfig([
globals: globals.browser, 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' },
},
]) ])

67
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "krishna-sales-pwa", "name": "krishna-sales-pwa",
"version": "1.1.0", "version": "1.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "krishna-sales-pwa", "name": "krishna-sales-pwa",
"version": "1.1.0", "version": "1.0.4",
"dependencies": { "dependencies": {
"@react-google-maps/api": "^2.20.8", "@react-google-maps/api": "^2.20.8",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
@ -20,12 +20,14 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.65.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"dotenv": "^18.0.9",
"eslint": "^10.6.0", "eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
@ -2309,6 +2311,22 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@quansync/fs": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@ -4716,6 +4734,22 @@
"@types/trusted-types": "^2.0.7" "@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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -7107,6 +7141,35 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",

View File

@ -8,7 +8,13 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "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": { "dependencies": {
"@react-google-maps/api": "^2.20.8", "@react-google-maps/api": "^2.20.8",
@ -23,12 +29,14 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.65.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"dotenv": "^18.0.9",
"eslint": "^10.6.0", "eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",

View File

@ -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. * On 200 success, the backend bumps password_changed_at and invalidates the session JWT.
*/ */
export async function changePassword( export async function changePassword(
currentPassword: string, currentPassword: string | undefined | null,
newPassword: string, newPassword: string,
token?: string | null token?: string | null
): Promise<{ message: string }> { ): Promise<{ message: string }> {
@ -97,6 +97,13 @@ export async function changePassword(
throw new AuthApiError('You must be signed in to change your password', 401); throw new AuthApiError('You must be signed in to change your password', 401);
} }
const payload: Record<string, string> = {
new_password: newPassword,
};
if (currentPassword) {
payload.current_password = currentPassword;
}
const res = await fetch(`${BASE_URL}/usr/me/password`, { const res = await fetch(`${BASE_URL}/usr/me/password`, {
method: 'POST', method: 'POST',
credentials: AUTH_CREDENTIALS, credentials: AUTH_CREDENTIALS,
@ -104,10 +111,7 @@ export async function changePassword(
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`, Authorization: `Bearer ${authToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify(payload),
current_password: currentPassword,
new_password: newPassword,
}),
}); });
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));

View File

@ -36,6 +36,7 @@ export async function loginAll(email: string, password: string, orgId?: string)
try { try {
const me = await orderBookingClient.getMe(); const me = await orderBookingClient.getMe();
res.user = { ...res.user, ...me }; res.user = { ...res.user, ...me };
ALL.forEach((c) => c.setUser(res.user));
} catch (e) { } catch (e) {
console.error('Failed to fetch user profile:', e); console.error('Failed to fetch user profile:', e);
} }
@ -55,6 +56,7 @@ export async function completePasswordChangeAll(
try { try {
const me = await orderBookingClient.getMe(); const me = await orderBookingClient.getMe();
res.user = { ...res.user, ...me }; res.user = { ...res.user, ...me };
ALL.forEach((c) => c.setUser(res.user));
} catch (e) { } catch (e) {
console.error('Failed to fetch user profile:', e); console.error('Failed to fetch user profile:', e);
} }

View File

@ -10,6 +10,13 @@ export interface ApiError {
data?: any; data?: any;
} }
export interface PasswordStatus {
must_change?: boolean;
expires_at?: string;
days_left?: number;
warn?: boolean;
}
export interface User { export interface User {
id: string; id: string;
org_id: string; org_id: string;
@ -18,6 +25,8 @@ export interface User {
roles: string[]; roles: string[];
groups: string[]; groups: string[];
is_admin?: boolean; is_admin?: boolean;
provider?: string;
password_status?: PasswordStatus;
} }
export interface LoginResponse { export interface LoginResponse {

View File

@ -1,6 +1,7 @@
import { useState, type ReactNode, useEffect } from 'react'; import { useState, type ReactNode, useEffect } from 'react';
import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients'; 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'; import { sessionClient } from '../api/session';
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
@ -17,12 +18,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}); });
const [passwordStatus, setPasswordStatus] = useState<PasswordStatus | null>(() => { const [passwordStatus, setPasswordStatus] = useState<PasswordStatus | null>(() => {
try { return orderBookingClient.currentUser()?.password_status ?? null;
const raw = localStorage.getItem('krishna_sales_mobile_password_status');
return raw ? (JSON.parse(raw) as PasswordStatus) : null;
} catch {
return null;
}
}); });
// Keep React in step with the session: refresh/idle expiry, a sign-out or a // 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(() => { useEffect(() => {
if (authed && (!isAdmin || roles.length === 0)) { if (authed && (!isAdmin || roles.length === 0 || !passwordStatus)) {
orderBookingClient.getMe().then(me => { orderBookingClient.getMe().then(me => {
if (me.is_admin) setIsAdmin(true); if (me.is_admin) setIsAdmin(true);
if (me.roles) setRoles(me.roles); if (me.roles) setRoles(me.roles);
if (me.password_status) setPasswordStatus(me.password_status);
}).catch(console.error); }).catch(console.error);
} }
}, [authed]); }, [authed]);
@ -68,11 +65,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false'); localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false');
setRoles(userRoles); setRoles(userRoles);
localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles)); localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles));
// Only a due/expiring password is worth remembering; a fresh login replaces it. if (res.user?.password_status) {
const status: PasswordStatus | null = res.user?.password_status?.warn ? res.user.password_status : null; setPasswordStatus(res.user.password_status);
setPasswordStatus(status); } else {
if (status) localStorage.setItem('krishna_sales_mobile_password_status', JSON.stringify(status)); setPasswordStatus(null);
else localStorage.removeItem('krishna_sales_mobile_password_status'); }
}; };
const value: AuthValue = { const value: AuthValue = {

View File

@ -1,4 +1,5 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import type { PasswordStatus } from '../api/types';
export interface PasswordChangeRequiredInfo { export interface PasswordChangeRequiredInfo {
message: string; message: string;
@ -6,19 +7,12 @@ export interface PasswordChangeRequiredInfo {
rules?: string[]; 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 { export interface AuthValue {
authed: boolean; authed: boolean;
passwordStatus: PasswordStatus | null;
isAdmin: boolean; isAdmin: boolean;
roles: string[]; roles: string[];
userEmail: string | null; userEmail: string | null;
passwordStatus: PasswordStatus | null;
login: (email: string, password: string, orgId?: string) => Promise<void>; login: (email: string, password: string, orgId?: string) => Promise<void>;
completePasswordChange: (email: string, currentPassword: string, newPassword: string, orgId?: string) => Promise<void>; completePasswordChange: (email: string, currentPassword: string, newPassword: string, orgId?: string) => Promise<void>;
logout: () => void; logout: () => void;

View File

@ -5,6 +5,7 @@ import { Button } from '../buttons/Button';
import { PasswordRulesChecklist } from './PasswordRulesChecklist'; import { PasswordRulesChecklist } from './PasswordRulesChecklist';
import { changePassword } from '../../api/auth'; import { changePassword } from '../../api/auth';
import { usePasswordPolicy } from '../../hooks/usePasswordPolicy'; import { usePasswordPolicy } from '../../hooks/usePasswordPolicy';
import { cn } from '../../lib/cn';
import { import {
passwordMeetsPolicy, passwordMeetsPolicy,
passwordTooLong, passwordTooLong,
@ -17,6 +18,7 @@ interface ChangePasswordModalProps {
userEmail?: string | null; userEmail?: string | null;
userName?: string | null; userName?: string | null;
onSuccessLogout: () => void; onSuccessLogout: () => void;
required?: boolean;
} }
export function ChangePasswordModal({ export function ChangePasswordModal({
@ -25,6 +27,7 @@ export function ChangePasswordModal({
userEmail, userEmail,
userName, userName,
onSuccessLogout, onSuccessLogout,
required = false,
}: ChangePasswordModalProps) { }: ChangePasswordModalProps) {
const policy = usePasswordPolicy(open); const policy = usePasswordPolicy(open);
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
@ -34,6 +37,12 @@ export function ChangePasswordModal({
const [showNew, setShowNew] = useState(false); const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{
current?: boolean;
new?: boolean;
confirm?: boolean;
}>({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
@ -43,6 +52,7 @@ export function ChangePasswordModal({
setCurrentPassword(''); setCurrentPassword('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
setFieldErrors({});
setError(null); setError(null);
onClose(); onClose();
}; };
@ -50,35 +60,61 @@ export function ChangePasswordModal({
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setFieldErrors({});
if (!currentPassword || !newPassword || !confirmPassword) { const errors: { current?: boolean; new?: boolean; confirm?: boolean } = {};
setError('All fields are required.');
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; return;
} }
if (!passwordMeetsPolicy(policy, newPassword, { email: userEmail, name: userName })) { if (!passwordMeetsPolicy(policy, newPassword, { email: userEmail, name: userName })) {
setFieldErrors({ new: true });
setError('New password does not meet all of the password requirements.'); setError('New password does not meet all of the password requirements.');
return; return;
} }
if (passwordTooLong(newPassword)) { if (passwordTooLong(newPassword)) {
setFieldErrors({ new: true });
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`); setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
return; return;
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setFieldErrors({ confirm: true });
setError('New passwords do not match.'); setError('New passwords do not match.');
return; return;
} }
if (newPassword === currentPassword) { if (!required && currentPassword && newPassword === currentPassword) {
setFieldErrors({ new: true });
setError('New password must be different from your current password.'); setError('New password must be different from your current password.');
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
await changePassword(currentPassword, newPassword); await changePassword(required ? undefined : currentPassword, newPassword);
setDone(true); setDone(true);
setTimeout(() => { setTimeout(() => {
onSuccessLogout(); onSuccessLogout();
@ -94,43 +130,58 @@ export function ChangePasswordModal({
<Modal <Modal
open={open} open={open}
onClose={handleClose} onClose={handleClose}
closable={!required}
width="sm" width="sm"
title={ title={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Lock className="text-emerald-600 size-5" /> <Lock className="text-emerald-600 size-5" />
<span>Change Password</span> <span>{required ? 'Password Expired' : 'Change Password'}</span>
</div> </div>
} }
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."
}
> >
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form noValidate onSubmit={handleSubmit} className="flex flex-col gap-4">
{/* Current Password */} {/* Current Password — only required for self-service change; hidden when must_change is active */}
<div className="flex flex-col gap-1.5"> {!required && (
<label className="text-xs font-semibold text-slate-700"> <div className="flex flex-col gap-1.5">
Current password <span className="text-rose-500">*</span> <label className="text-xs font-semibold text-slate-700">
</label> Current password <span className="text-rose-500">*</span>
<div className="relative flex items-center"> </label>
<input <div className="relative flex items-center">
type={showCurrent ? 'text' : 'password'} <input
autoComplete="current-password" type={showCurrent ? 'text' : 'password'}
value={currentPassword} autoComplete="current-password"
onChange={(e) => setCurrentPassword(e.target.value)} value={currentPassword}
disabled={submitting || done} onChange={(e) => {
required setCurrentPassword(e.target.value);
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" if (fieldErrors.current) setFieldErrors((prev) => ({ ...prev, current: false }));
placeholder="Enter current password" setError(null);
/> }}
<button disabled={submitting || done}
type="button" className={cn(
onClick={() => setShowCurrent((s) => !s)} 'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all',
disabled={submitting || done} fieldErrors.current
tabIndex={-1} ? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500'
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer" : 'border border-slate-200 bg-slate-50/50 text-slate-800 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500'
> )}
{showCurrent ? <EyeOff size={16} /> : <Eye size={16} />} placeholder="Enter current password"
</button> />
<button
type="button"
onClick={() => setShowCurrent((s) => !s)}
disabled={submitting || done}
tabIndex={-1}
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
>
{showCurrent ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div> </div>
</div> )}
{/* New Password */} {/* New Password */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -142,10 +193,19 @@ export function ChangePasswordModal({
type={showNew ? 'text' : 'password'} type={showNew ? 'text' : 'password'}
autoComplete="new-password" autoComplete="new-password"
value={newPassword} 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} disabled={submitting || done}
required autoFocus={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" 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" placeholder="Enter new password"
/> />
<button <button
@ -177,10 +237,18 @@ export function ChangePasswordModal({
type={showConfirm ? 'text' : 'password'} type={showConfirm ? 'text' : 'password'}
autoComplete="new-password" autoComplete="new-password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => {
setConfirmPassword(e.target.value);
if (fieldErrors.confirm) setFieldErrors((prev) => ({ ...prev, confirm: false }));
setError(null);
}}
disabled={submitting || done} disabled={submitting || done}
required className={cn(
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" 'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all',
fieldErrors.confirm
? '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="Re-enter new password" placeholder="Re-enter new password"
/> />
<button <button
@ -210,14 +278,25 @@ export function ChangePasswordModal({
)} )}
<div className="flex items-center justify-end gap-2.5 mt-2"> <div className="flex items-center justify-end gap-2.5 mt-2">
<Button {required ? (
type="button" <Button
variant="outline" type="button"
disabled={submitting || done} variant="outline"
onClick={handleClose} disabled={submitting || done}
> onClick={onSuccessLogout}
Cancel >
</Button> Sign out
</Button>
) : (
<Button
type="button"
variant="outline"
disabled={submitting || done}
onClick={handleClose}
>
Cancel
</Button>
)}
<Button <Button
type="submit" type="submit"
disabled={submitting || done} disabled={submitting || done}

View File

@ -0,0 +1,132 @@
import { useState } from 'react';
import { AlertTriangle, KeyRound, Lock } from 'lucide-react';
import type { PasswordStatus } from '../../api/types';
interface PasswordExpiryWarningBannerProps {
passwordStatus?: PasswordStatus | null;
onChangePassword: () => void;
}
export function PasswordExpiryWarningBanner({
passwordStatus,
onChangePassword,
}: PasswordExpiryWarningBannerProps) {
const expiresAt = passwordStatus?.expires_at || 'session';
const dismissKey = `krishna_sales_mobile_dismissed_pw_warn_${expiresAt}`;
const [dismissed, setDismissed] = useState<boolean>(() => {
try {
return sessionStorage.getItem(dismissKey) === 'true';
} catch {
return false;
}
});
const isExpired = Boolean(
passwordStatus?.must_change ||
(passwordStatus?.days_left !== undefined && passwordStatus.days_left <= 0)
);
// Evaluate warning state: backend flags warn: true, or days_left is within warning window (<= 3)
const isWarnActive = Boolean(
isExpired ||
passwordStatus?.warn ||
(passwordStatus?.days_left !== undefined && passwordStatus.days_left <= 3)
);
// If not warning/expired, or if dismissed while NOT expired, hide banner.
// When expired, dismissal is disabled so the user must take action.
if (!isWarnActive || (dismissed && !isExpired)) return null;
const daysLeft = passwordStatus?.days_left;
let headline = 'Your password will expire soon.';
if (isExpired) {
headline = 'Your password has expired.';
} else if (daysLeft !== undefined) {
if (daysLeft === 1) {
headline = 'Your password will expire tomorrow.';
} else {
headline = `Your password will expire in ${daysLeft} days.`;
}
}
const handleDismiss = () => {
setDismissed(true);
try {
sessionStorage.setItem(dismissKey, 'true');
} catch {
/* storage unavailable */
}
};
if (isExpired) {
return (
<div
role="alert"
className="print:hidden shrink-0 px-3.5 py-2.5 sm:px-4 sm:py-2.5 bg-rose-50 border-b border-rose-200 text-xs text-rose-900 font-sans shadow-xs transition-all"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 sm:gap-4">
<div className="flex items-start sm:items-center gap-2.5 min-w-0 flex-1">
<Lock size={18} className="text-rose-600 shrink-0 mt-0.5 sm:mt-0" />
<div className="flex flex-col sm:flex-row sm:items-center gap-x-2 gap-y-0.5 min-w-0">
<strong className="font-semibold text-rose-950 text-xs sm:text-sm">{headline}</strong>
<span className="text-rose-800 text-2xs sm:text-xs">
You must update your password to continue using your account.
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center ml-7 sm:ml-0">
<button
type="button"
onClick={onChangePassword}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-semibold rounded-md shadow-xs transition-colors cursor-pointer text-xs"
>
<KeyRound size={13} />
<span>Change Password Now</span>
</button>
</div>
</div>
</div>
);
}
return (
<div
role="alert"
className="print:hidden shrink-0 px-3.5 py-2.5 sm:px-4 sm:py-2.5 bg-amber-50 border-b border-amber-200 text-xs text-amber-900 font-sans shadow-xs transition-all"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2.5 sm:gap-4">
<div className="flex items-start sm:items-center gap-2.5 min-w-0 flex-1">
<AlertTriangle size={18} className="text-amber-600 shrink-0 mt-0.5 sm:mt-0" />
<div className="flex flex-col sm:flex-row sm:items-center gap-x-2 gap-y-0.5 min-w-0">
<strong className="font-semibold text-amber-950 text-xs sm:text-sm">{headline}</strong>
<span className="text-amber-800 text-2xs sm:text-xs">
Please update your password to maintain access to your account.
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 self-end sm:self-center ml-7 sm:ml-0">
<button
type="button"
onClick={handleDismiss}
className="px-2.5 py-1.5 text-xs font-semibold text-amber-800 hover:text-amber-950 hover:bg-amber-100/80 active:bg-amber-200/80 rounded-md transition-colors cursor-pointer"
>
Later
</button>
<button
type="button"
onClick={onChangePassword}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-600 hover:bg-amber-700 active:bg-amber-800 text-white font-semibold rounded-md shadow-xs transition-colors cursor-pointer text-xs"
>
<KeyRound size={13} />
<span>Change Password</span>
</button>
</div>
</div>
</div>
);
}

View File

@ -22,12 +22,24 @@ export interface ModalProps {
maxHeight?: string; maxHeight?: string;
actions?: ReactNode; actions?: ReactNode;
children?: 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. */ /** 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(() => { useEffect(() => {
if (!open) return; if (!open || !closable) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
}; };
@ -38,7 +50,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', maxHeight
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev; document.body.style.overflow = prev;
}; };
}, [open, onClose]); }, [open, onClose, closable]);
if (!open) return null; if (!open) return null;
@ -46,7 +58,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', maxHeight
<div <div
className="fixed inset-0 z-[10000] flex items-end sm:items-center justify-center overflow-y-auto bg-black/40 sm:p-8" className="fixed inset-0 z-[10000] flex items-end sm:items-center justify-center overflow-y-auto bg-black/40 sm:p-8"
onMouseDown={(e) => { onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose(); if (closable && e.target === e.currentTarget) onClose();
}} }}
> >
<div <div
@ -69,13 +81,15 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', maxHeight
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{actions && <div className="flex items-center gap-2 mr-2">{actions}</div>} {actions && <div className="flex items-center gap-2 mr-2">{actions}</div>}
<button {closable && (
onClick={onClose} <button
aria-label="Close" onClick={onClose}
className="shrink-0 -mr-1 p-1.5 rounded-md text-slate-800 hover:text-black hover:bg-slate-200 transition-colors" aria-label="Close"
> className="shrink-0 -mr-1 p-1.5 rounded-md text-slate-800 hover:text-black hover:bg-slate-200 transition-colors cursor-pointer"
<X size={22} strokeWidth={3} /> >
</button> <X size={22} strokeWidth={3} />
</button>
)}
</div> </div>
</header> </header>
<div className="shrink min-h-0 overflow-y-auto scrollbar-slim"> <div className="shrink min-h-0 overflow-y-auto scrollbar-slim">

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'; 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 { cn } from '../lib/cn';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients'; import { onAuthErrorAll, orderBookingClient } from '../api/clients';
@ -8,6 +8,7 @@ import { SCREENS } from './tabs';
import { routeConfig } from '../routesConfig'; import { routeConfig } from '../routesConfig';
import { APP_VERSION } from '../api/config'; import { APP_VERSION } from '../api/config';
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal'; import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
import { PasswordExpiryWarningBanner } from '../components/auth/PasswordExpiryWarningBanner';
import { NoticeStrips } from '../components/common/NoticeBanner'; import { NoticeStrips } from '../components/common/NoticeBanner';
import logoUrl from '../assets/logo.png'; import logoUrl from '../assets/logo.png';
import './screen.css'; 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 <Outlet>. */ /** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
export function ConsoleLayout() { export function ConsoleLayout() {
const { authed, logout, isAdmin, roles: userRoles, userEmail, passwordStatus } = useAuth(); const { authed, logout, isAdmin, roles: userRoles, userEmail, passwordStatus } = useAuth();
const [passwordDueDismissed, setPasswordDueDismissed] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const user = orderBookingClient.currentUser(); 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 [isMenuOpen, setIsMenuOpen] = useState(false);
const [isChangePasswordOpen, setIsChangePasswordOpen] = 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. // A 401 from any workflow client bounces back to login.
useEffect(() => { useEffect(() => {
onAuthErrorAll(() => { onAuthErrorAll(() => {
@ -62,38 +75,10 @@ export function ConsoleLayout() {
</header> </header>
<NoticeStrips /> <NoticeStrips />
<PasswordExpiryWarningBanner
{passwordStatus?.warn && !passwordDueDismissed && ( passwordStatus={effectivePasswordStatus}
<div onChangePassword={() => setIsChangePasswordOpen(true)}
role="alert" />
className="print:hidden shrink-0 flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-900"
>
<AlertTriangle size={16} className="shrink-0 text-amber-700" />
<span className="flex-1">
<strong>Password change due.</strong>{' '}
{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.'}
</span>
<button
type="button"
onClick={() => setIsChangePasswordOpen(true)}
className="font-semibold text-amber-900 underline cursor-pointer"
>
Change now
</button>
<button
type="button"
aria-label="Dismiss"
onClick={() => setPasswordDueDismissed(true)}
className="text-amber-700 hover:text-amber-900 cursor-pointer"
>
<X size={14} />
</button>
</div>
)}
<main className="flex-1 overflow-y-auto px-4 pt-4 pb-24"> <main className="flex-1 overflow-y-auto px-4 pt-4 pb-24">
<div className="w-full max-w-[1200px] mx-auto flex flex-col gap-5"> <div className="w-full max-w-[1200px] mx-auto flex flex-col gap-5">
@ -261,7 +246,12 @@ export function ConsoleLayout() {
<ChangePasswordModal <ChangePasswordModal
open={isChangePasswordOpen} open={isChangePasswordOpen}
onClose={() => setIsChangePasswordOpen(false)} onClose={() => {
if (!isExpired) {
setIsChangePasswordOpen(false);
}
}}
required={isExpired}
userEmail={userEmail || user?.email} userEmail={userEmail || user?.email}
userName={user?.name} userName={user?.name}
onSuccessLogout={() => { onSuccessLogout={() => {

View File

@ -6,6 +6,7 @@ import { Button } from '../components/buttons';
import { Card } from '../components/reusable'; import { Card } from '../components/reusable';
import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist'; import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist';
import { usePasswordPolicy } from '../hooks/usePasswordPolicy'; import { usePasswordPolicy } from '../hooks/usePasswordPolicy';
import { cn } from '../lib/cn';
import { import {
passwordMeetsPolicy, passwordMeetsPolicy,
passwordTooLong, passwordTooLong,
@ -30,20 +31,45 @@ export function LoginPage() {
const [showNewPassword, setShowNewPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{
email?: boolean;
password?: boolean;
newPassword?: boolean;
confirmPassword?: boolean;
}>({});
const policy = usePasswordPolicy(!!changeRequired); const policy = usePasswordPolicy(!!changeRequired);
if (authed) return <Navigate to="/daily" replace />; if (authed) return <Navigate to="/daily" replace />;
async function submit(e: FormEvent) { async function submit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setBusy(true);
setError(null); 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 { try {
await login(email, password); await login(email, password);
navigate('/daily', { replace: true }); navigate('/daily', { replace: true });
} catch (err: any) { } catch (err: any) {
// Backend signals that password policy or expiry requires a new password first // 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({ setChangeRequired({
message: err?.message || err?.data?.error || 'A new password policy is in effect. Please choose a new password to continue.', 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, reason: err?.reason || err?.data?.reason,
@ -61,28 +87,44 @@ export function LoginPage() {
async function handleChangePasswordSubmit(e: FormEvent) { async function handleChangePasswordSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setFieldErrors({});
if (!newPassword || !confirmPassword) { const errs: typeof fieldErrors = {};
setError('Please fill in both password fields.'); 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; return;
} }
if (!passwordMeetsPolicy(policy, newPassword, { email })) { if (!passwordMeetsPolicy(policy, newPassword, { email })) {
setFieldErrors({ newPassword: true });
setError('New password does not meet all of the password requirements.'); setError('New password does not meet all of the password requirements.');
return; return;
} }
if (passwordTooLong(newPassword)) { if (passwordTooLong(newPassword)) {
setFieldErrors({ newPassword: true });
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`); setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
return; return;
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setFieldErrors({ confirmPassword: true });
setError('New passwords do not match.'); setError('New passwords do not match.');
return; return;
} }
if (newPassword === password) { if (newPassword === password) {
setFieldErrors({ newPassword: true });
setError('New password must be different from your current password.'); setError('New password must be different from your current password.');
return; return;
} }
@ -102,6 +144,7 @@ export function LoginPage() {
setChangeRequired(null); setChangeRequired(null);
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); setConfirmPassword('');
setFieldErrors({});
setError(null); setError(null);
} }
@ -129,7 +172,7 @@ export function LoginPage() {
{changeRequired ? ( {changeRequired ? (
/* Forced Password Change Form */ /* Forced Password Change Form */
<form onSubmit={handleChangePasswordSubmit} className="flex flex-col gap-4"> <form noValidate onSubmit={handleChangePasswordSubmit} className="flex flex-col gap-4">
<div className="p-3 bg-amber-50/80 border border-amber-200 rounded-lg flex items-start gap-2.5 text-xs text-amber-900"> <div className="p-3 bg-amber-50/80 border border-amber-200 rounded-lg flex items-start gap-2.5 text-xs text-amber-900">
<Lock size={16} className="text-amber-700 shrink-0 mt-0.5" /> <Lock size={16} className="text-amber-700 shrink-0 mt-0.5" />
<span> <span>
@ -147,11 +190,19 @@ export function LoginPage() {
type={showNewPassword ? 'text' : 'password'} type={showNewPassword ? 'text' : 'password'}
autoComplete="new-password" autoComplete="new-password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => {
required setNewPassword(e.target.value);
if (fieldErrors.newPassword) setFieldErrors((prev) => ({ ...prev, newPassword: false }));
setError(null);
}}
autoFocus autoFocus
disabled={busy} 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" placeholder="Enter new password"
/> />
<button <button
@ -182,10 +233,18 @@ export function LoginPage() {
type={showConfirmPassword ? 'text' : 'password'} type={showConfirmPassword ? 'text' : 'password'}
autoComplete="new-password" autoComplete="new-password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => {
required setConfirmPassword(e.target.value);
if (fieldErrors.confirmPassword) setFieldErrors((prev) => ({ ...prev, confirmPassword: false }));
setError(null);
}}
disabled={busy} 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.confirmPassword
? '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="Confirm new password" placeholder="Confirm new password"
/> />
<button <button
@ -221,7 +280,7 @@ export function LoginPage() {
</form> </form>
) : ( ) : (
/* Standard Login Form */ /* Standard Login Form */
<form onSubmit={submit} className="flex flex-col gap-4"> <form noValidate onSubmit={submit} className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5 font-sans"> <label className="flex flex-col gap-1.5 font-sans">
<span className="text-sm font-semibold text-emerald-800"> <span className="text-sm font-semibold text-emerald-800">
Email <span className="text-emerald-500">*</span> Email <span className="text-emerald-500">*</span>
@ -229,11 +288,19 @@ export function LoginPage() {
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
required setEmail(e.target.value);
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: false }));
setError(null);
}}
autoFocus autoFocus
disabled={busy} disabled={busy}
className="h-[42px] px-3 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] px-3 rounded-md outline-none transition-all text-sm',
fieldErrors.email
? '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 your email" placeholder="Enter your email"
/> />
</label> </label>
@ -254,10 +321,18 @@ export function LoginPage() {
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
required setPassword(e.target.value);
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: false }));
setError(null);
}}
disabled={busy} 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.password
? '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 your password" placeholder="Enter your password"
/> />
<button <button