feat:password policy

This commit is contained in:
suryac 2026-10-15 13:22:07 +05:30
parent 1d57bf042f
commit b546e870a1
15 changed files with 1308 additions and 46 deletions

View File

@ -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 { DatasetItemsPage } from './screens/admin/DatasetItemsPage' import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
@ -26,6 +27,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
View 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;
}

View File

@ -47,11 +47,15 @@ export class ZinoClient {
this.onAuthError = fn; this.onAuthError = fn;
} }
setToken(token: string | null): void { setToken(token: string | null, user?: User | null): void {
this.token = token; this.token = token;
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (token) { if (token) {
localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(TOKEN_KEY, token);
if (user) {
this.user = user;
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
} else { } else {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
@ -90,13 +94,26 @@ export class ZinoClient {
} }
if (!res.ok) { if (!res.ok) {
let message = res.statusText; let message = res.statusText;
let field_errors: any[] | undefined;
let code: string | undefined;
let j: any = null;
try { try {
const j = (await res.json()) as { error?: string }; 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.code) code = j.code;
} catch { } catch {
/* non-JSON body */ /* 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; if (res.status === 204) return undefined as T;
return (await res.json()) as T; return (await res.json()) as T;
@ -110,11 +127,7 @@ export class ZinoClient {
password, password,
...(orgId ? { org_id: Number(orgId) } : {}), ...(orgId ? { org_id: Number(orgId) } : {}),
}); });
this.setToken(res.token); this.setToken(res.token, res.user);
if (typeof window !== 'undefined' && res.user) {
this.user = res.user;
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
}
return res; return res;
} }

View File

@ -27,10 +27,30 @@ 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);
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 { try {
const me = await orderBookingClient.getMe(); const me = await orderBookingClient.getMe();
res.user = { ...res.user, ...me }; res.user = { ...res.user, ...me };

View File

@ -5,7 +5,7 @@
// docs under src/docs/api. // docs under src/docs/api.
export const APP_ID = '434'; 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; export const FORM_DRAFT_EXPIRY_MS = 24 * 60 * 60 * 1000;
// Backend URL comes from the runtime config.js the frontgen server writes at // Backend URL comes from the runtime config.js the frontgen server writes at

View File

@ -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 {

View File

@ -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_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 = { const value: AuthValue = {
authed, authed,
isAdmin, isAdmin,
@ -31,13 +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_user_email', res.user.email); const res = await completePasswordChangeAll(email, currentPassword, newPassword, orgId);
} applyUserSession(res);
setIsAdmin(!!res.user?.is_admin);
setRoles(res.user?.roles ?? []);
}, },
logout: () => { logout: () => {
logoutAll(); logoutAll();

View File

@ -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;
} }

View 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>
);
}

View 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>
);
}

View 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;
}

View File

@ -1,12 +1,11 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'; 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 { 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 { APP_VERSION } from '../api/config'; import { APP_VERSION } from '../api/config';
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
import { REPORT_MAP } from './ReportPage'; 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>. */ /** Auth-guarded shell: navy top bar + 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 location = useLocation(); const location = useLocation();
const user = orderBookingClient.currentUser(); const user = orderBookingClient.currentUser();
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
const isReportsActive = location.pathname.startsWith('/reports'); const isReportsActive = location.pathname.startsWith('/reports');
// A 401 from any workflow client bounces back to login. // A 401 from any workflow client bounces back to login.
@ -155,7 +156,17 @@ export function ConsoleLayout() {
</NavLink> </NavLink>
</div> </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 <button
onClick={() => { onClick={() => {
logout(); logout();
@ -179,6 +190,18 @@ export function ConsoleLayout() {
<Outlet /> <Outlet />
</div> </div>
</main> </main>
<ChangePasswordModal
open={isChangePasswordOpen}
onClose={() => setIsChangePasswordOpen(false)}
userEmail={userEmail || user?.email}
userName={user?.name}
onSuccessLogout={() => {
setIsChangePasswordOpen(false);
logout();
navigate('/login', { replace: true });
}}
/>
</div> </div>
); );
} }

View 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>
);
}

View File

@ -1,21 +1,38 @@
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, Input } from '../components/reusable'; import { Card } from '../components/reusable';
import { getDefaultRoute } from '../auth/ProtectedRoute'; 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'; import logo from '../assets/logo.png';
/** Login gate. Redirects to default route once authenticated. */ /** Login gate. Redirects to default route once authenticated. */
export function LoginPage() { export function LoginPage() {
const { authed, login, roles, isAdmin } = useAuth(); const { authed, login, completePasswordChange, roles, isAdmin } = 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={getDefaultRoute(roles, isAdmin)} replace />; if (authed) return <Navigate to={getDefaultRoute(roles, isAdmin)} replace />;
async function submit(e: FormEvent) { async function submit(e: FormEvent) {
@ -24,33 +41,255 @@ export function LoginPage() {
setError(null); setError(null);
try { try {
await login(email, password); 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 }); navigate('/', { 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('/', { 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-[#E8FBF0]"> <div className="min-h-screen flex items-center justify-center p-4 bg-[#E8FBF0]">
<Card className="w-full max-w-[400px]"> <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-1 mb-6 items-center text-center"> <div className="flex flex-col gap-2 items-center mb-6 text-center">
<img src={logo} alt="Logo" className="h-20 w-auto object-contain mb-2" /> <img src={logo} alt="Krishna Sales Logo" className="h-16 w-auto object-contain" />
<h1 className="m-0 text-2xl font-extrabold text-strong 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">
<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>} {error && (
<Button type="submit" full disabled={busy} className="rounded-full bg-emerald-600 hover:bg-emerald-700 text-white border-transparent"> <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">
{busy ? 'Signing in…' : 'Sign in'} <AlertCircle size={16} className="shrink-0 mt-0.5" />
</Button> <span>{error}</span>
</form> </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> </Card>
</div> </div>
); );

112
src/utils/passwordPolicy.ts Normal file
View 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;
}