feat:password policy
This commit is contained in:
parent
1d57bf042f
commit
b546e870a1
@ -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() {
|
||||
<BrowserRouter basename={routerBasename}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route element={<ConsoleLayout />}>
|
||||
{routeConfig.map((route, i) => {
|
||||
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
|
||||
|
||||
146
src/api/auth.ts
Normal file
146
src/api/auth.ts
Normal file
@ -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<PasswordPolicy> {
|
||||
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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<void>;
|
||||
completePasswordChange: (email: string, currentPassword: string, newPassword: string, orgId?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
|
||||
232
src/components/auth/ChangePasswordModal.tsx
Normal file
232
src/components/auth/ChangePasswordModal.tsx
Normal file
@ -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<string | null>(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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
width="sm"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="text-emerald-600 size-5" />
|
||||
<span>Change Password</span>
|
||||
</div>
|
||||
}
|
||||
subtitle="Update your account password. You'll need to sign in again after."
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Current Password */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-slate-700">
|
||||
Current password <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type={showCurrent ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* New Password */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-slate-700">
|
||||
New password <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type={showNew ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew((s) => !s)}
|
||||
disabled={submitting || done}
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{showNew ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordRulesChecklist
|
||||
policy={policy}
|
||||
password={newPassword}
|
||||
email={userEmail}
|
||||
name={userName}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-slate-700">
|
||||
Confirm new password <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((s) => !s)}
|
||||
disabled={submitting || done}
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{showConfirm ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-rose-50 border border-rose-200 text-rose-700 text-xs flex items-start gap-2">
|
||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && (
|
||||
<div className="p-3 rounded-lg bg-emerald-50 border border-emerald-200 text-emerald-800 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 size={16} className="shrink-0" />
|
||||
<span>Password updated successfully. Signing you out…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitting || done}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || done}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{submitting ? 'Updating…' : 'Update Password'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
51
src/components/auth/PasswordRulesChecklist.tsx
Normal file
51
src/components/auth/PasswordRulesChecklist.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
role="list"
|
||||
aria-label="Password rules"
|
||||
className={`p-3 bg-slate-50 border border-slate-200/80 rounded-lg flex flex-col gap-1.5 text-xs ${className}`}
|
||||
>
|
||||
<span className="font-semibold text-slate-700 mb-0.5">Password requirements:</span>
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.label}
|
||||
role="listitem"
|
||||
className={`flex items-center gap-1.5 transition-colors ${
|
||||
r.met ? 'text-emerald-700 font-medium' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{r.met ? (
|
||||
<span className="flex items-center justify-center size-4 rounded-full bg-emerald-100 text-emerald-600 shrink-0">
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center size-4 rounded-full bg-slate-200 text-slate-400 shrink-0">
|
||||
<Dot size={14} strokeWidth={4} />
|
||||
</span>
|
||||
)}
|
||||
<span>{r.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/hooks/usePasswordPolicy.ts
Normal file
43
src/hooks/usePasswordPolicy.ts
Normal file
@ -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<PasswordPolicy | null>(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;
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
|
||||
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
|
||||
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() {
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-2 pb-2 flex flex-col gap-2">
|
||||
<div className="px-2 pb-2 flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsChangePasswordOpen(true);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-700 font-medium hover:bg-emerald-50 hover:text-emerald-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<KeyRound size={16} className="text-emerald-600" />
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
@ -179,6 +190,18 @@ export function ConsoleLayout() {
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ChangePasswordModal
|
||||
open={isChangePasswordOpen}
|
||||
onClose={() => setIsChangePasswordOpen(false)}
|
||||
userEmail={userEmail || user?.email}
|
||||
userName={user?.name}
|
||||
onSuccessLogout={() => {
|
||||
setIsChangePasswordOpen(false);
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
357
src/screens/ForgotPasswordPage.tsx
Normal file
357
src/screens/ForgotPasswordPage.tsx
Normal file
@ -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<string | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-[#E8FBF0]">
|
||||
<Card className="w-full max-w-[420px] shadow-lg border-emerald-100 bg-white p-6 sm:p-7">
|
||||
<div className="flex flex-col gap-2 items-center mb-6 text-center">
|
||||
<img src={logo} alt="Krishna Sales Logo" className="h-16 w-auto object-contain" />
|
||||
<h1 className="text-xl font-extrabold text-emerald-950 tracking-tight">
|
||||
{done
|
||||
? 'Password Reset Complete'
|
||||
: step === 'email'
|
||||
? 'Forgot Password'
|
||||
: step === 'code'
|
||||
? 'Enter Verification Code'
|
||||
: 'Create New Password'}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 max-w-[340px] leading-relaxed">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 text-xs text-rose-700 bg-rose-50 border border-rose-200 p-3 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done ? (
|
||||
<div className="flex flex-col gap-4 text-center">
|
||||
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-lg flex flex-col items-center gap-2 text-emerald-800 text-sm">
|
||||
<CheckCircle2 size={36} className="text-emerald-600" />
|
||||
<span className="font-semibold">{message}</span>
|
||||
</div>
|
||||
<Button type="button" full onClick={goToLogin} className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
) : step === 'email' ? (
|
||||
/* Step 1: Email Form */
|
||||
<form onSubmit={handleEmailSubmit} className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 font-sans">
|
||||
<span className="text-sm font-semibold text-emerald-800 flex items-center gap-1.5">
|
||||
<Mail size={15} />
|
||||
Email address <span className="text-emerald-500">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
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"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
full
|
||||
disabled={submitting}
|
||||
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||
>
|
||||
{submitting ? 'Sending code…' : 'Send Reset Code'}
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center justify-center gap-1.5 text-xs font-semibold text-emerald-700 hover:text-emerald-900 py-1 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Back to Sign in
|
||||
</Link>
|
||||
</form>
|
||||
) : step === 'code' ? (
|
||||
/* Step 2: OTP Verification Form */
|
||||
<form onSubmit={handleCodeSubmit} className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 font-sans">
|
||||
<span className="text-sm font-semibold text-emerald-800 flex items-center gap-1.5">
|
||||
<KeyRound size={15} />
|
||||
Reset Code <span className="text-emerald-500">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={CODE_LENGTH}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, CODE_LENGTH))}
|
||||
required
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
className="h-[46px] px-3 text-center text-xl font-bold tracking-[0.3em] 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"
|
||||
placeholder="123456"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
full
|
||||
disabled={submitting || code.length !== CODE_LENGTH}
|
||||
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||
>
|
||||
{submitting ? 'Verifying…' : 'Verify Code'}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between text-xs pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={submitting || cooldown > 0}
|
||||
className="font-medium text-emerald-700 hover:text-emerald-900 disabled:text-slate-400 cursor-pointer"
|
||||
>
|
||||
{cooldown > 0 ? `Resend code in ${cooldown}s` : 'Resend code'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setStep('email');
|
||||
}}
|
||||
disabled={submitting}
|
||||
className="font-medium text-slate-500 hover:text-slate-800 cursor-pointer"
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
/* Step 3: New Password Form */
|
||||
<form onSubmit={handlePasswordSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<span className="text-sm font-semibold text-emerald-800">
|
||||
New password <span className="text-emerald-500">*</span>
|
||||
</span>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
disabled={submitting}
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordRulesChecklist policy={policy} password={password} email={email} className="mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<span className="text-sm font-semibold text-emerald-800">
|
||||
Confirm new password <span className="text-emerald-500">*</span>
|
||||
</span>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((s) => !s)}
|
||||
disabled={submitting}
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{showConfirm ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
full
|
||||
disabled={submitting}
|
||||
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||
>
|
||||
{submitting ? 'Updating password…' : 'Set New Password'}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setStep('email');
|
||||
}}
|
||||
disabled={submitting}
|
||||
className="text-xs font-semibold text-slate-500 hover:text-slate-700 py-1 transition-colors text-center cursor-pointer"
|
||||
>
|
||||
Start over
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<string | null>(null);
|
||||
|
||||
// Forced password change state
|
||||
const [changeRequired, setChangeRequired] = useState<PasswordChangeRequiredInfo | null>(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 <Navigate to={getDefaultRoute(roles, isAdmin)} replace />;
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-[#E8FBF0]">
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<div className="flex flex-col gap-1 mb-6 items-center text-center">
|
||||
<img src={logo} alt="Logo" className="h-20 w-auto object-contain mb-2" />
|
||||
<h1 className="m-0 text-2xl font-extrabold text-strong tracking-[-0.02em]">Krishna Sales</h1>
|
||||
<Card className="w-full max-w-[420px] shadow-lg border-emerald-100 bg-white p-6 sm:p-7">
|
||||
<div className="flex flex-col gap-2 items-center mb-6 text-center">
|
||||
<img src={logo} alt="Krishna Sales Logo" className="h-16 w-auto object-contain" />
|
||||
<h1 className="m-0 text-2xl font-extrabold text-emerald-900 tracking-[-0.02em]">
|
||||
{changeRequired ? 'Set a New Password' : 'Krishna Sales'}
|
||||
</h1>
|
||||
{changeRequired && (
|
||||
<p className="text-xs text-slate-600 mt-1 max-w-[340px] leading-relaxed">
|
||||
{changeRequired.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||
<Input label="Email" type="email" value={email} onChange={(e) => 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)]" />
|
||||
<Input label="Password" type="password" value={password} onChange={(e) => 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 && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
|
||||
<Button type="submit" full disabled={busy} className="rounded-full bg-emerald-600 hover:bg-emerald-700 text-white border-transparent">
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="mb-4 text-xs text-rose-700 bg-rose-50 border border-rose-200 p-3 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changeRequired ? (
|
||||
/* Forced Password Change Form */
|
||||
<form 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">
|
||||
<Lock size={16} className="text-amber-700 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
You must update your password before continuing to Krishna Sales.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* New Password */}
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<label className="text-xs font-semibold text-slate-700">
|
||||
New password <span className="text-emerald-500">*</span>
|
||||
</label>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword((s) => !s)}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{showNewPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordRulesChecklist
|
||||
policy={policy}
|
||||
password={newPassword}
|
||||
email={email}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm New Password */}
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<label className="text-xs font-semibold text-slate-700">
|
||||
Confirm new password <span className="text-emerald-500">*</span>
|
||||
</label>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword((s) => !s)}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
full
|
||||
disabled={busy}
|
||||
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||
>
|
||||
{busy ? 'Updating & Signing in…' : 'Update Password & Sign In'}
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToSignIn}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center justify-center gap-1.5 text-xs font-semibold text-slate-500 hover:text-slate-800 py-1 transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Cancel & back to sign in
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
/* Standard Login Form */
|
||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5 font-sans">
|
||||
<span className="text-sm font-semibold text-emerald-800">
|
||||
Email <span className="text-emerald-500">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
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"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-emerald-800">
|
||||
Password <span className="text-emerald-500">*</span>
|
||||
</span>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs font-semibold text-emerald-700 hover:text-emerald-900 transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
className="absolute right-3 text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
full
|
||||
disabled={busy}
|
||||
className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"
|
||||
>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
112
src/utils/passwordPolicy.ts
Normal file
112
src/utils/passwordPolicy.ts
Normal file
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user