diff --git a/README.md b/README.md index 2c30650..40f33e5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This is a Progressive Web App (Pwa) built for the Krishna Sales team. - **Platform maintenance notices** (`src/api/notices.ts`): polls `GET /usr/notices` every 30s. `announced` shows a dismissible strip, `draining` keeps the strip and refuses writes (`PLATFORM_MAINTENANCE` 503), `closed` shows a full-screen block that lifts on its own. - Other 503s show an "unavailable" strip without blocking writes. - Logout now clears per-user data but keeps session-protocol keys instead of `localStorage.clear()`. +- **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. ## Sitemap 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 c1e9819..f748318 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); } diff --git a/src/api/types.ts b/src/api/types.ts index c8793e8..d4f2a46 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; @@ -19,6 +26,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 86f9337..2104b7f 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 } from './context'; +import type { PasswordStatus } from '../api/types'; import { sessionClient } from '../api/session'; export function AuthProvider({ children }: { children: ReactNode }) { @@ -14,6 +15,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [roles, setRoles] = useState(() => { return orderBookingClient.currentUser()?.roles ?? []; }); + const [passwordStatus, setPasswordStatus] = useState(() => { + return orderBookingClient.currentUser()?.password_status ?? null; + }); // Keep React in step with the session: refresh/idle expiry, a sign-out or a // different user signing in from another tab. @@ -25,6 +29,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUserEmail(null); setIsAdmin(false); setRoles([]); + setPasswordStatus(null); } else if (type === 'user-switched') { // This tab's state belongs to the previous user. window.location.reload(); @@ -38,10 +43,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { // If we are authenticated but don't have isAdmin from cache, we might want to fetch it 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]); @@ -58,6 +64,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.setItem('krishna_sales_is_admin', userIsAdmin ? 'true' : 'false'); setRoles(userRoles); localStorage.setItem('krishna_sales_roles', JSON.stringify(userRoles)); + if (res.user?.password_status) { + setPasswordStatus(res.user.password_status); + } else { + setPasswordStatus(null); + } }; const value: AuthValue = { @@ -65,6 +76,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAdmin, roles, userEmail, + passwordStatus, login: async (email, password, orgId) => { const res = await loginAll(email, password, orgId); applyUserSession(res); @@ -79,6 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUserEmail(null); setIsAdmin(false); setRoles([]); + setPasswordStatus(null); // Drop this user's cached data (incl. form drafts) but keep the session // protocol keys (sm_*): the pending-logout marker must survive until the server hears it. try { diff --git a/src/auth/context.ts b/src/auth/context.ts index 6f4f5d3..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; @@ -11,6 +12,7 @@ export interface AuthValue { 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 65adf77..5ef65f8 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 f3eb402..ab0cca3 100644 --- a/src/components/reusable/Modal.tsx +++ b/src/components/reusable/Modal.tsx @@ -15,6 +15,8 @@ export interface ModalProps { width?: ModalWidth; actions?: ReactNode; children?: ReactNode; + /** Whether the modal can be dismissed via Esc, backdrop, or close button. @default true */ + closable?: boolean; } const WIDTH_CLASSES: Record = { @@ -25,9 +27,18 @@ const WIDTH_CLASSES: Record = { }; /** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */ -export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) { +export function Modal({ + open, + onClose, + title, + subtitle, + width = 'md', + actions, + children, + closable = true, +}: ModalProps) { useEffect(() => { - if (!open) return; + if (!open || !closable) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; @@ -38,7 +49,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', actions, c document.removeEventListener('keydown', onKey); document.body.style.overflow = prev; }; - }, [open, onClose]); + }, [open, onClose, closable]); if (!open) return null; @@ -46,7 +57,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', actions, c
{ - if (e.target === e.currentTarget) onClose(); + if (closable && e.target === e.currentTarget) onClose(); }} >