diff --git a/src/App.tsx b/src/App.tsx index 7566f61..4654e99 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { AuthProvider } from './auth/AuthProvider' import { LoginPage } from './screens/LoginPage' +import { ForgotPasswordPage } from './screens/ForgotPasswordPage' import { ConsoleLayout } from './screens/ConsoleLayout' import { DatasetItemsPage } from './screens/admin/DatasetItemsPage' @@ -26,6 +27,7 @@ function App() { } /> + } /> }> {routeConfig.map((route, i) => { const path = route.path.startsWith('/') ? route.path.substring(1) : route.path; diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..4411d7c --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,146 @@ +import { BASE_URL } from './config'; +import { currentToken } from './clients'; +import { passwordPolicyMessage, type PasswordPolicy } from '../utils/passwordPolicy'; + +export class AuthApiError extends Error { + status: number; + code?: string; + violations?: string[]; + + constructor(message: string, status: number, code?: string, violations?: string[]) { + super(message); + this.name = 'AuthApiError'; + this.status = status; + this.code = code; + this.violations = violations; + } +} + +/** + * GET /usr/password-policy — public endpoint to fetch the app-user password rules. + */ +export async function fetchPasswordPolicy(): Promise { + const res = await fetch(`${BASE_URL}/usr/password-policy`); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + throw new AuthApiError(body.error || `Failed to load password policy: ${res.status}`, res.status); + } + return body; +} + +/** + * POST /usr/forgot-password — request a one-time 6-digit reset code to email. + * The backend ALWAYS responds 200 with a neutral message to prevent account enumeration. + */ +export async function forgotPassword(email: string): Promise<{ message: string }> { + const res = await fetch(`${BASE_URL}/usr/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email.trim() }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + throw new AuthApiError(body.error || `Failed to request password reset: ${res.status}`, res.status); + } + return body; +} + +/** + * POST /usr/verify-reset-code — exchange the emailed OTP code for a short-lived reset token (JWT). + */ +export async function verifyResetCode(email: string, code: string): Promise<{ reset_token: string }> { + const res = await fetch(`${BASE_URL}/usr/verify-reset-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email.trim(), code: code.trim() }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || !body?.reset_token) { + throw new AuthApiError(body.error || 'The code is invalid or has expired.', res.status); + } + return body; +} + +/** + * POST /usr/reset-password — set the new password using the reset token from verify-reset-code. + */ +export async function resetPassword(resetToken: string, newPassword: string): Promise<{ message: string }> { + const res = await fetch(`${BASE_URL}/usr/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reset_token: resetToken, new_password: newPassword }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = passwordPolicyMessage(body) || body.message || `Failed to reset password: ${res.status}`; + throw new AuthApiError(msg, res.status, body?.code, body?.violations); + } + return body; +} + +/** + * POST /usr/me/password — authenticated self-service password change. + * Wrong current password returns 403 (inline error, not 401). + * On 200 success, the backend bumps password_changed_at and invalidates the session JWT. + */ +export async function changePassword( + currentPassword: string, + newPassword: string, + token?: string | null +): Promise<{ message: string }> { + const authToken = token ?? currentToken(); + if (!authToken) { + throw new AuthApiError('You must be signed in to change your password', 401); + } + + const res = await fetch(`${BASE_URL}/usr/me/password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); + + const body = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = passwordPolicyMessage(body) || body.error || `Failed to change password: ${res.status}`; + throw new AuthApiError(msg, res.status, body?.code, body?.violations); + } + return body; +} + +/** + * POST /usr/change-expired-password — completes a login that answered 403 password_change_required. + * It re-proves the credentials the user just typed and sets the new password. + * A 200 is the SAME body as /usr/login ({ token, user }). + */ +export async function changeExpiredPassword( + email: string, + currentPassword: string, + newPassword: string, + orgId?: string | number +): Promise<{ token: string; user: any }> { + const body: Record = { + email: email.trim(), + current_password: currentPassword, + new_password: newPassword, + }; + if (orgId) body.org_id = Number(orgId); + + const res = await fetch(`${BASE_URL}/usr/change-expired-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = passwordPolicyMessage(data) || data.error || data.message || `Failed to set new password: ${res.status}`; + throw new AuthApiError(msg, res.status, data.code, data.violations); + } + return data; +} diff --git a/src/api/client.ts b/src/api/client.ts index 93eeab6..931520c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -47,11 +47,15 @@ export class ZinoClient { this.onAuthError = fn; } - setToken(token: string | null): void { + setToken(token: string | null, user?: User | null): void { this.token = token; if (typeof window === 'undefined') return; if (token) { localStorage.setItem(TOKEN_KEY, token); + if (user) { + this.user = user; + localStorage.setItem(USER_KEY, JSON.stringify(user)); + } } else { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); @@ -90,13 +94,26 @@ export class ZinoClient { } if (!res.ok) { let message = res.statusText; + let field_errors: any[] | undefined; + let code: string | undefined; + let j: any = null; try { - const j = (await res.json()) as { error?: string }; + j = (await res.json()) as any; if (j.error) message = j.error; + if (j.field_errors) field_errors = j.field_errors; + if (j.code) code = j.code; } catch { /* non-JSON body */ } - throw { status: res.status, message } as ApiError; + throw { + status: res.status, + message, + field_errors, + code: code || j?.code, + reason: j?.reason, + rules: j?.rules, + data: j, + } as ApiError; } if (res.status === 204) return undefined as T; return (await res.json()) as T; @@ -110,11 +127,7 @@ export class ZinoClient { password, ...(orgId ? { org_id: Number(orgId) } : {}), }); - this.setToken(res.token); - if (typeof window !== 'undefined' && res.user) { - this.user = res.user; - localStorage.setItem(USER_KEY, JSON.stringify(res.user)); - } + this.setToken(res.token, res.user); return res; } diff --git a/src/api/clients.ts b/src/api/clients.ts index b426984..43a89d7 100644 --- a/src/api/clients.ts +++ b/src/api/clients.ts @@ -27,10 +27,30 @@ export const userClient = clientFor('user'); // client holds its own copy — so auth helpers fan out to all of them. const ALL = [orderBookingClient, storeClient, dailyReportsClient, userClient]; +import { changeExpiredPassword } from './auth'; + /** Log in once and share the JWT with every workflow client. */ export async function loginAll(email: string, password: string, orgId?: string) { const res = await orderBookingClient.login(email, password, orgId); - ALL.forEach((c) => c.setToken(res.token)); + ALL.forEach((c) => c.setToken(res.token, res.user)); + try { + const me = await orderBookingClient.getMe(); + res.user = { ...res.user, ...me }; + } catch (e) { + console.error('Failed to fetch user profile:', e); + } + return res; +} + +/** Complete a forced password change during login and seed all clients. */ +export async function completePasswordChangeAll( + email: string, + currentPassword: string, + newPassword: string, + orgId?: string +) { + const res = await changeExpiredPassword(email, currentPassword, newPassword, orgId); + ALL.forEach((c) => c.setToken(res.token, res.user)); try { const me = await orderBookingClient.getMe(); res.user = { ...res.user, ...me }; diff --git a/src/api/config.ts b/src/api/config.ts index 898bd12..8b17f82 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -5,7 +5,7 @@ // docs under src/docs/api. export const APP_ID = '434'; -export const APP_VERSION = '1.0.1'; +export const APP_VERSION = '1.0.3'; export const FORM_DRAFT_EXPIRY_MS = 24 * 60 * 60 * 1000; // Backend URL comes from the runtime config.js the frontgen server writes at diff --git a/src/api/types.ts b/src/api/types.ts index f7e0290..c8793e8 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -3,6 +3,11 @@ export interface ApiError { status: number; message: string; + code?: string; + reason?: string; + rules?: string[]; + field_errors?: any[]; + data?: any; } export interface User { diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index ae80f82..059b422 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode, useEffect } from 'react'; -import { loginAll, logoutAll, currentToken, orderBookingClient } from '../api/clients'; +import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients'; import { AuthCtx, type AuthValue } from './context'; export function AuthProvider({ children }: { children: ReactNode }) { @@ -24,6 +24,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [authed]); + const applyUserSession = (res: any) => { + setAuthed(true); + if (res.user?.email) { + setUserEmail(res.user.email); + localStorage.setItem('krishna_sales_user_email', res.user.email); + } + const userRoles = res.user?.roles ?? []; + const userIsAdmin = !!res.user?.is_admin; + setIsAdmin(userIsAdmin); + localStorage.setItem('krishna_sales_is_admin', userIsAdmin ? 'true' : 'false'); + setRoles(userRoles); + localStorage.setItem('krishna_sales_roles', JSON.stringify(userRoles)); + }; + const value: AuthValue = { authed, isAdmin, @@ -31,13 +45,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { userEmail, login: async (email, password, orgId) => { const res = await loginAll(email, password, orgId); - setAuthed(true); - if (res.user?.email) { - setUserEmail(res.user.email); - localStorage.setItem('krishna_sales_user_email', res.user.email); - } - setIsAdmin(!!res.user?.is_admin); - setRoles(res.user?.roles ?? []); + applyUserSession(res); + }, + completePasswordChange: async (email, currentPassword, newPassword, orgId) => { + const res = await completePasswordChangeAll(email, currentPassword, newPassword, orgId); + applyUserSession(res); }, logout: () => { logoutAll(); diff --git a/src/auth/context.ts b/src/auth/context.ts index 4ebfc09..6f4f5d3 100644 --- a/src/auth/context.ts +++ b/src/auth/context.ts @@ -1,11 +1,18 @@ import { createContext, useContext } from 'react'; +export interface PasswordChangeRequiredInfo { + message: string; + reason?: string; + rules?: string[]; +} + export interface AuthValue { authed: boolean; isAdmin: boolean; roles: string[]; userEmail: string | 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 new file mode 100644 index 0000000..65adf77 --- /dev/null +++ b/src/components/auth/ChangePasswordModal.tsx @@ -0,0 +1,232 @@ +import { useState, type FormEvent } from 'react'; +import { Eye, EyeOff, Lock, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { Modal } from '../reusable/Modal'; +import { Button } from '../buttons/Button'; +import { PasswordRulesChecklist } from './PasswordRulesChecklist'; +import { changePassword } from '../../api/auth'; +import { usePasswordPolicy } from '../../hooks/usePasswordPolicy'; +import { + passwordMeetsPolicy, + passwordTooLong, + PASSWORD_MAX_CHARS, +} from '../../utils/passwordPolicy'; + +interface ChangePasswordModalProps { + open: boolean; + onClose: () => void; + userEmail?: string | null; + userName?: string | null; + onSuccessLogout: () => void; +} + +export function ChangePasswordModal({ + open, + onClose, + userEmail, + userName, + onSuccessLogout, +}: ChangePasswordModalProps) { + const policy = usePasswordPolicy(open); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showCurrent, setShowCurrent] = useState(false); + const [showNew, setShowNew] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + const handleClose = () => { + if (submitting || done) return; + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setError(null); + onClose(); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + + if (!currentPassword || !newPassword || !confirmPassword) { + setError('All fields are required.'); + return; + } + + if (!passwordMeetsPolicy(policy, newPassword, { email: userEmail, name: userName })) { + setError('New password does not meet all of the password requirements.'); + return; + } + + if (passwordTooLong(newPassword)) { + setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`); + return; + } + + if (newPassword !== confirmPassword) { + setError('New passwords do not match.'); + return; + } + + if (newPassword === currentPassword) { + setError('New password must be different from your current password.'); + return; + } + + setSubmitting(true); + try { + await changePassword(currentPassword, newPassword); + setDone(true); + setTimeout(() => { + onSuccessLogout(); + }, 1500); + } catch (err) { + const msg = (err as { message?: string })?.message; + setError(msg || 'Could not change your password. Please try again.'); + setSubmitting(false); + } + }; + + return ( + + + Change Password + + } + subtitle="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" + /> + +
+
+ + {/* New Password */} +
+ +
+ setNewPassword(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 new password" + /> + +
+ +
+ + {/* Confirm Password */} +
+ +
+ setConfirmPassword(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="Re-enter new password" + /> + +
+
+ + {error && ( +
+ + {error} +
+ )} + + {done && ( +
+ + Password updated successfully. Signing you out… +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/src/components/auth/PasswordRulesChecklist.tsx b/src/components/auth/PasswordRulesChecklist.tsx new file mode 100644 index 0000000..12adc5b --- /dev/null +++ b/src/components/auth/PasswordRulesChecklist.tsx @@ -0,0 +1,51 @@ +import { Check, Dot } from 'lucide-react'; +import { passwordRuleChecks, type PasswordPolicy } from '../../utils/passwordPolicy'; + +interface PasswordRulesChecklistProps { + policy: PasswordPolicy | null | undefined; + password: string; + email?: string | null; + name?: string | null; + className?: string; +} + +export function PasswordRulesChecklist({ + policy, + password, + email, + name, + className = '', +}: PasswordRulesChecklistProps) { + const rows = passwordRuleChecks(policy, password, { email, name }); + if (!rows.length) return null; + + return ( +
+ Password requirements: + {rows.map((r) => ( +
+ {r.met ? ( + + + + ) : ( + + + + )} + {r.label} +
+ ))} +
+ ); +} diff --git a/src/hooks/usePasswordPolicy.ts b/src/hooks/usePasswordPolicy.ts new file mode 100644 index 0000000..7a8762e --- /dev/null +++ b/src/hooks/usePasswordPolicy.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; +import { fetchPasswordPolicy } from '../api/auth'; +import type { PasswordPolicy } from '../utils/passwordPolicy'; + +let cachedPolicy: PasswordPolicy | null = null; + +export function usePasswordPolicy(enabled = true): PasswordPolicy | null { + const [policy, setPolicy] = useState(cachedPolicy); + + useEffect(() => { + if (!enabled) return; + if (cachedPolicy) { + setPolicy(cachedPolicy); + return; + } + + let alive = true; + fetchPasswordPolicy() + .then((p) => { + cachedPolicy = p; + if (alive) setPolicy(p); + }) + .catch((err) => { + console.warn('Could not fetch password policy, using defaults:', err); + // Sensible fallback if offline or service temporarily down + if (alive && !cachedPolicy) { + setPolicy({ + min_length: 8, + require_upper: true, + require_lower: true, + require_digit: true, + require_special: true, + }); + } + }); + + return () => { + alive = false; + }; + }, [enabled]); + + return policy; +} diff --git a/src/screens/ConsoleLayout.tsx b/src/screens/ConsoleLayout.tsx index 0979868..b0e7854 100644 --- a/src/screens/ConsoleLayout.tsx +++ b/src/screens/ConsoleLayout.tsx @@ -1,12 +1,11 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { LogOut, ChevronDown, Database, Users } from 'lucide-react'; +import { LogOut, ChevronDown, Database, Users, KeyRound } from 'lucide-react'; import { cn } from '../lib/cn'; import { useAuth } from '../auth/context'; import { onAuthErrorAll, orderBookingClient } from '../api/clients'; import { APP_VERSION } from '../api/config'; - - +import { ChangePasswordModal } from '../components/auth/ChangePasswordModal'; import { REPORT_MAP } from './ReportPage'; @@ -26,11 +25,13 @@ const PATH_LABELS: Record = { /** Auth-guarded shell: navy top bar + tab nav + routed . */ export function ConsoleLayout() { - const { authed, logout, isAdmin, roles: userRoles } = useAuth(); + const { authed, logout, isAdmin, roles: userRoles, userEmail } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const user = orderBookingClient.currentUser(); + const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); + const isReportsActive = location.pathname.startsWith('/reports'); // A 401 from any workflow client bounces back to login. @@ -155,7 +156,17 @@ export function ConsoleLayout() { )} -
+
+
+ + setIsChangePasswordOpen(false)} + userEmail={userEmail || user?.email} + userName={user?.name} + onSuccessLogout={() => { + setIsChangePasswordOpen(false); + logout(); + navigate('/login', { replace: true }); + }} + />
); } diff --git a/src/screens/ForgotPasswordPage.tsx b/src/screens/ForgotPasswordPage.tsx new file mode 100644 index 0000000..59f4fa8 --- /dev/null +++ b/src/screens/ForgotPasswordPage.tsx @@ -0,0 +1,357 @@ +import { useState, useEffect, type FormEvent } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { Eye, EyeOff, ArrowLeft, Mail, KeyRound, CheckCircle2, AlertCircle } from 'lucide-react'; +import { Card } from '../components/reusable'; +import { Button } from '../components/buttons'; +import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist'; +import { forgotPassword, verifyResetCode, resetPassword } from '../api/auth'; +import { usePasswordPolicy } from '../hooks/usePasswordPolicy'; +import { + passwordMeetsPolicy, + passwordTooLong, + PASSWORD_MAX_CHARS, +} from '../utils/passwordPolicy'; +import logo from '../assets/logo.png'; + +const CODE_LENGTH = 6; +const RESEND_COOLDOWN = 60; // seconds + +export function ForgotPasswordPage() { + const navigate = useNavigate(); + const [step, setStep] = useState<'email' | 'code' | 'password'>('email'); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [resetToken, setResetToken] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [cooldown, setCooldown] = useState(0); + const [done, setDone] = useState(false); + const [message, setMessage] = useState(''); + + const policy = usePasswordPolicy(step === 'password'); + + // Cooldown countdown + useEffect(() => { + if (cooldown <= 0) return; + const timer = setInterval(() => { + setCooldown((c) => (c > 1 ? c - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + }, [cooldown]); + + const goToLogin = () => navigate('/login', { replace: true }); + + // Step 1: Send reset code + const handleEmailSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!email.trim()) { + setError('Please enter your email address.'); + return; + } + setSubmitting(true); + setError(null); + try { + await forgotPassword(email.trim()); + setStep('code'); + setCooldown(RESEND_COOLDOWN); + } catch (err) { + const msg = (err as { message?: string })?.message; + setError(msg || 'Failed to send reset code. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + // Resend code handler + const handleResend = async () => { + if (cooldown > 0 || submitting) return; + setSubmitting(true); + setError(null); + try { + await forgotPassword(email.trim()); + setCooldown(RESEND_COOLDOWN); + } catch (err) { + const msg = (err as { message?: string })?.message; + setError(msg || 'Failed to resend code.'); + } finally { + setSubmitting(false); + } + }; + + // Step 2: Verify reset OTP code + const handleCodeSubmit = async (e: FormEvent) => { + e.preventDefault(); + const cleanCode = code.replace(/\D/g, ''); + if (cleanCode.length !== CODE_LENGTH) { + setError(`Please enter the complete ${CODE_LENGTH}-digit code.`); + return; + } + setSubmitting(true); + setError(null); + try { + const res = await verifyResetCode(email.trim(), cleanCode); + setResetToken(res.reset_token); + setStep('password'); + } catch (err) { + const msg = (err as { message?: string })?.message; + setError(msg || 'The code is invalid or has expired.'); + } finally { + setSubmitting(false); + } + }; + + // Step 3: Set new password + const handlePasswordSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + + if (!passwordMeetsPolicy(policy, password, { email })) { + setError('Password does not meet all requirements.'); + return; + } + if (passwordTooLong(password)) { + setError(`Password must be at most ${PASSWORD_MAX_CHARS} characters.`); + return; + } + if (password !== confirm) { + setError('Passwords do not match.'); + return; + } + + setSubmitting(true); + try { + const res = await resetPassword(resetToken, password); + setMessage(res.message || 'Password updated successfully! You can now log in.'); + setDone(true); + setTimeout(goToLogin, 2500); + } catch (err) { + const msg = (err as { message?: string })?.message; + setError(msg || 'Failed to reset password. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ +
+ Krishna Sales Logo +

+ {done + ? 'Password Reset Complete' + : step === 'email' + ? 'Forgot Password' + : step === 'code' + ? 'Enter Verification Code' + : 'Create New Password'} +

+

+ {done + ? 'Your password has been changed. Redirecting to login…' + : step === 'email' + ? "Enter your email address and we'll send you a 6-digit verification code." + : step === 'code' + ? `We emailed a ${CODE_LENGTH}-digit code to ${email}. It is valid for 10 minutes.` + : 'Please enter a strong new password that meets the policy requirements.'} +

+
+ + {error && ( +
+ + {error} +
+ )} + + {done ? ( +
+
+ + {message} +
+ +
+ ) : step === 'email' ? ( + /* Step 1: Email Form */ +
+ + + + + + + Back to Sign in + + + ) : step === 'code' ? ( + /* Step 2: OTP Verification Form */ +
+ + + + +
+ + +
+
+ ) : ( + /* Step 3: New Password Form */ +
+
+ + New password * + +
+ setPassword(e.target.value)} + required + autoFocus + disabled={submitting} + 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" + placeholder="Enter new password" + /> + +
+ +
+ +
+ + Confirm new password * + +
+ setConfirm(e.target.value)} + required + disabled={submitting} + 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" + placeholder="Re-enter new password" + /> + +
+
+ + + + +
+ )} +
+
+ ); +} diff --git a/src/screens/LoginPage.tsx b/src/screens/LoginPage.tsx index 7ace2ae..2a37395 100644 --- a/src/screens/LoginPage.tsx +++ b/src/screens/LoginPage.tsx @@ -1,21 +1,38 @@ import { useState, type FormEvent } from 'react'; -import { Navigate, useNavigate } from 'react-router-dom'; -import { useAuth } from '../auth/context'; +import { Navigate, useNavigate, Link } from 'react-router-dom'; +import { Eye, EyeOff, Lock, AlertCircle, ArrowLeft } from 'lucide-react'; +import { useAuth, type PasswordChangeRequiredInfo } from '../auth/context'; import { Button } from '../components/buttons'; -import { Card, Input } from '../components/reusable'; +import { Card } from '../components/reusable'; import { getDefaultRoute } from '../auth/ProtectedRoute'; +import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist'; +import { usePasswordPolicy } from '../hooks/usePasswordPolicy'; +import { + passwordMeetsPolicy, + passwordTooLong, + PASSWORD_MAX_CHARS, +} from '../utils/passwordPolicy'; import logo from '../assets/logo.png'; /** Login gate. Redirects to default route once authenticated. */ export function LoginPage() { - const { authed, login, roles, isAdmin } = useAuth(); + const { authed, login, completePasswordChange, roles, isAdmin } = useAuth(); const navigate = useNavigate(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - + const [showPassword, setShowPassword] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // Forced password change state + const [changeRequired, setChangeRequired] = useState(null); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const policy = usePasswordPolicy(!!changeRequired); + if (authed) return ; async function submit(e: FormEvent) { @@ -24,33 +41,255 @@ export function LoginPage() { setError(null); try { await login(email, password); - - // Wait for a tick so useAuth state updates (if necessary) or we can just navigate to root - // which will redirect. But since we need the updated roles, navigating to root is safest. navigate('/', { replace: true }); - } catch (err) { - setError((err as { message?: string })?.message ?? 'Login failed'); + } 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') + ) { + 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, + rules: err?.rules || err?.data?.rules || [], + }); + setError(null); + } else { + setError(err?.message ?? 'Login failed. Please check your credentials.'); + } } finally { setBusy(false); } } + async function handleChangePasswordSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + + if (!newPassword || !confirmPassword) { + setError('Please fill in both password fields.'); + return; + } + + if (!passwordMeetsPolicy(policy, newPassword, { email })) { + setError('New password does not meet all of the password requirements.'); + return; + } + + if (passwordTooLong(newPassword)) { + setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`); + return; + } + + if (newPassword !== confirmPassword) { + setError('New passwords do not match.'); + return; + } + + if (newPassword === password) { + setError('New password must be different from your current password.'); + return; + } + + setBusy(true); + try { + await completePasswordChange(email, password, newPassword); + navigate('/', { replace: true }); + } catch (err: any) { + setError(err?.message ?? 'Could not update password. Please try again.'); + } finally { + setBusy(false); + } + } + + function backToSignIn() { + setChangeRequired(null); + setNewPassword(''); + setConfirmPassword(''); + setError(null); + } + return (
- -
- Logo -

Krishna Sales

+ +
+ Krishna Sales Logo +

+ {changeRequired ? 'Set a New Password' : 'Krishna Sales'} +

+ {changeRequired && ( +

+ {changeRequired.message} +

+ )}
-
- setEmail(e.target.value)} required autoFocus className="[&>div]:rounded-full [&>div:focus-within]:!border-emerald-500 [&>div:focus-within]:!shadow-[0_0_0_3px_rgba(16,185,129,0.3)]" /> - setPassword(e.target.value)} required className="[&>div]:rounded-full [&>div:focus-within]:!border-emerald-500 [&>div:focus-within]:!shadow-[0_0_0_3px_rgba(16,185,129,0.3)]" /> - {error &&
{error}
} - -
+ {error && ( +
+ + {error} +
+ )} + + {changeRequired ? ( + /* Forced Password Change Form */ +
+
+ + + You must update your password before continuing to Krishna Sales. + +
+ + {/* New Password */} +
+ +
+ setNewPassword(e.target.value)} + required + 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" + placeholder="Enter new password" + /> + +
+ +
+ + {/* Confirm New Password */} +
+ +
+ setConfirmPassword(e.target.value)} + required + 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" + placeholder="Confirm new password" + /> + +
+
+ + + + +
+ ) : ( + /* Standard Login Form */ +
+ + +
+
+ + Password * + + + Forgot password? + +
+
+ setPassword(e.target.value)} + required + 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" + placeholder="Enter your password" + /> + +
+
+ + +
+ )}
); diff --git a/src/utils/passwordPolicy.ts b/src/utils/passwordPolicy.ts new file mode 100644 index 0000000..0927111 --- /dev/null +++ b/src/utils/passwordPolicy.ts @@ -0,0 +1,112 @@ +// passwordPolicy — client-side validation for the app-user password policy. +// Mirrors sm/pkg/pwpolicy's Policy.Check rule for rule, so the ticks the user +// sees agree with what user-service decides. UX only: the server re-checks every password. + +export const PASSWORD_MAX_CHARS = 64; +export const PASSWORD_MAX_BYTES = 72; + +export interface PasswordPolicy { + rules?: string[]; + min_length: number; + require_upper?: boolean; + require_lower?: boolean; + require_digit?: boolean; + require_special?: boolean; + disallow_personal_info?: boolean; + expiry_days?: number; +} + +export interface PasswordRuleCheck { + label: string; + met: boolean; +} + +const UPPER = /\p{Lu}/u; +const LOWER = /\p{Ll}/u; +const DIGIT = /\p{Nd}/u; +const SPECIAL = /[^\p{Lu}\p{Ll}\p{Nd}\s]/u; + +function personalPieces(email?: string | null, name?: string | null): string[] { + const pieces: string[] = []; + if (typeof email === 'string') { + const at = email.indexOf('@'); + if (at > 0) { + const local = email.slice(0, at).toLowerCase(); + pieces.push(local, ...local.split(/[^\p{L}\p{Nd}]+/u)); + } + } + if (typeof name === 'string') { + pieces.push(...name.toLowerCase().split(/\s+/u)); + } + return pieces.filter((s) => [...s].length >= 3); +} + +export function containsPersonalInfo(password: string, email?: string | null, name?: string | null): boolean { + const pw = String(password || '').toLowerCase(); + return personalPieces(email, name).some((s) => pw.includes(s)); +} + +export function passwordRuleChecks( + policy: PasswordPolicy | null | undefined, + password: string, + who?: { email?: string | null; name?: string | null } +): PasswordRuleCheck[] { + if (!policy) return []; + const pw = String(password || ''); + const rows: PasswordRuleCheck[] = [ + { label: `At least ${policy.min_length} characters`, met: [...pw].length >= policy.min_length } + ]; + + if (policy.require_upper) { + rows.push({ label: 'An uppercase letter (A-Z)', met: UPPER.test(pw) }); + } + if (policy.require_lower) { + rows.push({ label: 'A lowercase letter (a-z)', met: LOWER.test(pw) }); + } + if (policy.require_digit) { + rows.push({ label: 'A number (0-9)', met: DIGIT.test(pw) }); + } + if (policy.require_special) { + rows.push({ label: 'A special character (e.g. ! @ # $ %)', met: SPECIAL.test(pw) }); + } + if (policy.disallow_personal_info && (who?.email || who?.name)) { + rows.push({ + label: 'Must not contain your name or email prefix', + met: pw.length > 0 && !containsPersonalInfo(pw, who.email, who.name), + }); + } + + return rows; +} + +export function passwordMeetsPolicy( + policy: PasswordPolicy | null | undefined, + password: string, + who?: { email?: string | null; name?: string | null } +): boolean { + if (!policy) return true; + return passwordRuleChecks(policy, password, who).every((r) => r.met); +} + +export function passwordTooLong(password: string): boolean { + const pw = String(password || ''); + return [...pw].length > PASSWORD_MAX_CHARS || new TextEncoder().encode(pw).length > PASSWORD_MAX_BYTES; +} + +export interface PasswordPolicyResponseBody { + code?: string; + violations?: string[]; + error?: string; + message?: string; +} + +export function passwordPolicyMessage(body: PasswordPolicyResponseBody | unknown): string | null { + if (body && typeof body === 'object') { + const b = body as PasswordPolicyResponseBody; + if (b.code === 'password_policy' && Array.isArray(b.violations) && b.violations.length) { + return `Password ${b.violations.join('; ')}.`; + } + return b.error || b.message || null; + } + return null; +}