password policy added
This commit is contained in:
parent
b501467f94
commit
eab38564c2
@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { AuthProvider } from './auth/AuthProvider'
|
import { AuthProvider } from './auth/AuthProvider'
|
||||||
import { LoginPage } from './screens/LoginPage'
|
import { LoginPage } from './screens/LoginPage'
|
||||||
|
import { ForgotPasswordPage } from './screens/ForgotPasswordPage'
|
||||||
import { ConsoleLayout } from './screens/ConsoleLayout'
|
import { ConsoleLayout } from './screens/ConsoleLayout'
|
||||||
import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
|
import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
|
||||||
import { useAuth } from './auth/context'
|
import { useAuth } from './auth/context'
|
||||||
@ -24,6 +25,7 @@ function App() {
|
|||||||
<BrowserRouter basename={routerBasename}>
|
<BrowserRouter basename={routerBasename}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route element={<ConsoleLayout />}>
|
<Route element={<ConsoleLayout />}>
|
||||||
{routeConfig.map((route, i) => {
|
{routeConfig.map((route, i) => {
|
||||||
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
|
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;
|
||||||
|
}
|
||||||
@ -84,15 +84,24 @@ export class ZinoClient {
|
|||||||
let message = res.statusText;
|
let message = res.statusText;
|
||||||
let field_errors: any[] | undefined;
|
let field_errors: any[] | undefined;
|
||||||
let code: string | undefined;
|
let code: string | undefined;
|
||||||
|
let j: any = null;
|
||||||
try {
|
try {
|
||||||
const j = (await res.json()) as any;
|
j = (await res.json()) as any;
|
||||||
if (j.error) message = j.error;
|
if (j.error) message = j.error;
|
||||||
if (j.field_errors) field_errors = j.field_errors;
|
if (j.field_errors) field_errors = j.field_errors;
|
||||||
if (j.code) code = j.code;
|
if (j.code) code = j.code;
|
||||||
} catch {
|
} catch {
|
||||||
/* non-JSON body */
|
/* non-JSON body */
|
||||||
}
|
}
|
||||||
throw { status: res.status, message, field_errors, code } as any;
|
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;
|
if (res.status === 204) return undefined as T;
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export const userClient = clientFor('user');
|
|||||||
// client holds its own copy — so auth helpers fan out to all of them.
|
// client holds its own copy — so auth helpers fan out to all of them.
|
||||||
const ALL = [orderBookingClient, storeClient, dailyReportsClient, userClient];
|
const ALL = [orderBookingClient, storeClient, dailyReportsClient, userClient];
|
||||||
|
|
||||||
|
import { changeExpiredPassword } from './auth';
|
||||||
|
|
||||||
/** Log in once and share the JWT with every workflow client. */
|
/** Log in once and share the JWT with every workflow client. */
|
||||||
export async function loginAll(email: string, password: string, orgId?: string) {
|
export async function loginAll(email: string, password: string, orgId?: string) {
|
||||||
const res = await orderBookingClient.login(email, password, orgId);
|
const res = await orderBookingClient.login(email, password, orgId);
|
||||||
@ -40,6 +42,24 @@ export async function loginAll(email: string, password: string, orgId?: string)
|
|||||||
return res;
|
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));
|
||||||
|
try {
|
||||||
|
const me = await orderBookingClient.getMe();
|
||||||
|
res.user = { ...res.user, ...me };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch user profile:', e);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
/** Clear the session on every client. */
|
/** Clear the session on every client. */
|
||||||
export function logoutAll(): void {
|
export function logoutAll(): void {
|
||||||
ALL.forEach((c) => c.setToken(null));
|
ALL.forEach((c) => c.setToken(null));
|
||||||
|
|||||||
@ -3,6 +3,11 @@
|
|||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
status: number;
|
status: number;
|
||||||
message: string;
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
reason?: string;
|
||||||
|
rules?: string[];
|
||||||
|
field_errors?: any[];
|
||||||
|
data?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, type ReactNode, useEffect } from 'react';
|
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';
|
import { AuthCtx, type AuthValue } from './context';
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
@ -24,6 +24,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [authed]);
|
}, [authed]);
|
||||||
|
|
||||||
|
const applyUserSession = (res: any) => {
|
||||||
|
setAuthed(true);
|
||||||
|
if (res.user?.email) {
|
||||||
|
setUserEmail(res.user.email);
|
||||||
|
localStorage.setItem('krishna_sales_mobile_user_email', res.user.email);
|
||||||
|
}
|
||||||
|
const userRoles = res.user?.roles ?? [];
|
||||||
|
const userIsAdmin = !!res.user?.is_admin;
|
||||||
|
setIsAdmin(userIsAdmin);
|
||||||
|
localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false');
|
||||||
|
setRoles(userRoles);
|
||||||
|
localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles));
|
||||||
|
};
|
||||||
|
|
||||||
const value: AuthValue = {
|
const value: AuthValue = {
|
||||||
authed,
|
authed,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@ -31,17 +45,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
userEmail,
|
userEmail,
|
||||||
login: async (email, password, orgId) => {
|
login: async (email, password, orgId) => {
|
||||||
const res = await loginAll(email, password, orgId);
|
const res = await loginAll(email, password, orgId);
|
||||||
setAuthed(true);
|
applyUserSession(res);
|
||||||
if (res.user?.email) {
|
},
|
||||||
setUserEmail(res.user.email);
|
completePasswordChange: async (email, currentPassword, newPassword, orgId) => {
|
||||||
localStorage.setItem('krishna_sales_mobile_user_email', res.user.email);
|
const res = await completePasswordChangeAll(email, currentPassword, newPassword, orgId);
|
||||||
}
|
applyUserSession(res);
|
||||||
const userRoles = res.user?.roles ?? [];
|
|
||||||
const userIsAdmin = !!res.user?.is_admin;
|
|
||||||
setIsAdmin(userIsAdmin);
|
|
||||||
localStorage.setItem('krishna_sales_mobile_is_admin', userIsAdmin ? 'true' : 'false');
|
|
||||||
setRoles(userRoles);
|
|
||||||
localStorage.setItem('krishna_sales_mobile_roles', JSON.stringify(userRoles));
|
|
||||||
},
|
},
|
||||||
logout: () => {
|
logout: () => {
|
||||||
logoutAll();
|
logoutAll();
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export interface PasswordChangeRequiredInfo {
|
||||||
|
message: string;
|
||||||
|
reason?: string;
|
||||||
|
rules?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthValue {
|
export interface AuthValue {
|
||||||
authed: boolean;
|
authed: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
||||||
|
completePasswordChange: (email: string, currentPassword: string, newPassword: string, orgId?: string) => Promise<void>;
|
||||||
logout: () => 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, CheckCircle2, AlertCircle } 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,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { LogOut, Menu, X, Database, Users } from 'lucide-react';
|
import { LogOut, Menu, X, Database, Users, KeyRound } from 'lucide-react';
|
||||||
import { cn } from '../lib/cn';
|
import { cn } from '../lib/cn';
|
||||||
import { useAuth } from '../auth/context';
|
import { useAuth } from '../auth/context';
|
||||||
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
||||||
import { SCREENS } from './tabs';
|
import { SCREENS } from './tabs';
|
||||||
import { routeConfig } from '../routesConfig';
|
import { routeConfig } from '../routesConfig';
|
||||||
import { APP_VERSION } from '../api/config';
|
import { APP_VERSION } from '../api/config';
|
||||||
|
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
|
||||||
import logoUrl from '../assets/logo.png';
|
import logoUrl from '../assets/logo.png';
|
||||||
import './screen.css';
|
import './screen.css';
|
||||||
|
|
||||||
@ -22,11 +23,12 @@ const canAccessScreen = (key: string, isAdmin: boolean, roles: string[]) => {
|
|||||||
|
|
||||||
/** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
|
/** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
|
||||||
export function ConsoleLayout() {
|
export function ConsoleLayout() {
|
||||||
const { authed, logout, isAdmin, roles: userRoles } = useAuth();
|
const { authed, logout, isAdmin, roles: userRoles, userEmail } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = orderBookingClient.currentUser();
|
const user = orderBookingClient.currentUser();
|
||||||
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||||
|
|
||||||
// A 401 from any workflow client bounces back to login.
|
// A 401 from any workflow client bounces back to login.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -192,6 +194,17 @@ export function ConsoleLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
setIsChangePasswordOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center gap-2 w-full py-2.5 px-4 rounded-lg bg-emerald-50 hover:bg-emerald-100 text-emerald-800 transition-colors font-medium text-sm cursor-pointer border border-emerald-200/60"
|
||||||
|
>
|
||||||
|
<KeyRound size={16} className="text-emerald-700" />
|
||||||
|
<span>Change password</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -209,6 +222,18 @@ export function ConsoleLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChangePasswordModal
|
||||||
|
open={isChangePasswordOpen}
|
||||||
|
onClose={() => setIsChangePasswordOpen(false)}
|
||||||
|
userEmail={userEmail || user?.email}
|
||||||
|
userName={user?.name}
|
||||||
|
onSuccessLogout={() => {
|
||||||
|
setIsChangePasswordOpen(false);
|
||||||
|
logout();
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</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-emerald-50">
|
||||||
|
<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-14 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.`
|
||||||
|
: 'Choose a strong new password that meets the security policy.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 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="flex flex-col gap-4 text-center">
|
||||||
|
<div className="p-4 rounded-xl bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm flex flex-col items-center gap-2">
|
||||||
|
<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,19 +1,37 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { Navigate, useNavigate } from 'react-router-dom';
|
import { Navigate, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/context';
|
import { Eye, EyeOff, Lock, AlertCircle, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useAuth, type PasswordChangeRequiredInfo } from '../auth/context';
|
||||||
import { Button } from '../components/buttons';
|
import { Button } from '../components/buttons';
|
||||||
import { Card } from '../components/reusable';
|
import { Card } from '../components/reusable';
|
||||||
|
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';
|
import logo from '../assets/logo.png';
|
||||||
|
|
||||||
/** Login gate. Redirects to /daily once authenticated. */
|
/** Login gate. Redirects to /daily once authenticated. */
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { authed, login } = useAuth();
|
const { authed, login, completePasswordChange } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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="/daily" replace />;
|
if (authed) return <Navigate to="/daily" replace />;
|
||||||
|
|
||||||
async function submit(e: FormEvent) {
|
async function submit(e: FormEvent) {
|
||||||
@ -23,36 +41,247 @@ export function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate('/daily', { replace: true });
|
navigate('/daily', { replace: true });
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setError((err as { message?: string })?.message ?? 'Login failed');
|
// 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 {
|
} finally {
|
||||||
setBusy(false);
|
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('/daily', { 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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 bg-emerald-50">
|
<div className="min-h-screen flex items-center justify-center p-4 bg-emerald-50">
|
||||||
<Card className="w-full max-w-[400px] shadow-lg border-emerald-100">
|
<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-3 items-center mb-6">
|
<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" />
|
<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]">Krishna Sales</h1>
|
<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>
|
</div>
|
||||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
|
||||||
<label className="flex flex-col gap-1.5 font-sans">
|
{error && (
|
||||||
<span className="text-sm font-semibold text-emerald-800">Email <span className="text-emerald-500">*</span></span>
|
<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">
|
||||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus
|
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||||
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" placeholder="Enter your email" />
|
<span>{error}</span>
|
||||||
</label>
|
</div>
|
||||||
<label className="flex flex-col gap-1.5 font-sans">
|
)}
|
||||||
<span className="text-sm font-semibold text-emerald-800">Password <span className="text-emerald-500">*</span></span>
|
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required
|
{changeRequired ? (
|
||||||
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" placeholder="Enter your password" />
|
/* Forced Password Change Form */
|
||||||
</label>
|
<form onSubmit={handleChangePasswordSubmit} className="flex flex-col gap-4">
|
||||||
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
|
<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">
|
||||||
<Button type="submit" full disabled={busy} className="mt-2 bg-emerald-600 hover:bg-emerald-700 text-white border-transparent">
|
<Lock size={16} className="text-amber-700 shrink-0 mt-0.5" />
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
<span>
|
||||||
</Button>
|
You must update your password before continuing to Krishna Sales.
|
||||||
</form>
|
</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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
113
src/utils/passwordPolicy.ts
Normal file
113
src/utils/passwordPolicy.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
// 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