password expiry policy added
This commit is contained in:
parent
dc0b46fbd9
commit
1bbe205a79
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "krishna-sales-pwa",
|
||||
"private": true,
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -47,4 +47,4 @@
|
||||
"vite": "^8.1.1",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -271,5 +271,5 @@ export const HIDDEN_FORM_FIELDS = [
|
||||
export const SELECT_OPTION_LIMIT = 200;
|
||||
export const PDF_MAX_SKUS_PER_CHUNK = 50;
|
||||
|
||||
export const APP_VERSION = '1.0.4';
|
||||
export const APP_VERSION = '1.0.5';
|
||||
export const FORM_DRAFT_EXPIRY_MS = 30 * 60 * 1000;
|
||||
|
||||
@ -81,6 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
login: async (email, password, orgId) => {
|
||||
const res = await loginAll(email, password, orgId);
|
||||
applyUserSession(res);
|
||||
return (res.user?.password_status as PasswordStatus | undefined) ?? null;
|
||||
},
|
||||
completePasswordChange: async (email, currentPassword, newPassword, orgId) => {
|
||||
const res = await completePasswordChangeAll(email, currentPassword, newPassword, orgId);
|
||||
|
||||
@ -13,7 +13,8 @@ export interface AuthValue {
|
||||
roles: string[];
|
||||
userEmail: string | null;
|
||||
passwordStatus: PasswordStatus | null;
|
||||
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
||||
/** Resolves with the account's password status so the caller can react to must_change. */
|
||||
login: (email: string, password: string, orgId?: string) => Promise<PasswordStatus | null>;
|
||||
completePasswordChange: (email: string, currentPassword: string, newPassword: string, orgId?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Navigate, useNavigate, Link } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Lock, Mail, AlertCircle, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { changePassword } from '../api/auth';
|
||||
import { useAuth, type PasswordChangeRequiredInfo } from '../auth/context';
|
||||
import { Button } from '../components/buttons';
|
||||
import { AuthShell } from '../components/auth/AuthShell';
|
||||
@ -15,7 +16,7 @@ import {
|
||||
|
||||
/** Login gate. Redirects to /daily once authenticated. */
|
||||
export function LoginPage() {
|
||||
const { authed, login, completePasswordChange } = useAuth();
|
||||
const { authed, login, logout, completePasswordChange } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@ -37,9 +38,13 @@ export function LoginPage() {
|
||||
confirmPassword?: boolean;
|
||||
}>({});
|
||||
|
||||
// True when sign-in succeeded but the account is flagged must_change: the session exists,
|
||||
// so the new password is set via the authenticated endpoint rather than change-expired-password.
|
||||
const [sessionChange, setSessionChange] = useState(false);
|
||||
|
||||
const policy = usePasswordPolicy(!!changeRequired);
|
||||
|
||||
if (authed) return <Navigate to="/daily" replace />;
|
||||
if (authed && !changeRequired) return <Navigate to="/daily" replace />;
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@ -56,15 +61,23 @@ export function LoginPage() {
|
||||
errs.email && errs.password
|
||||
? 'Please enter both your email and password.'
|
||||
: errs.email
|
||||
? 'Email address is required.'
|
||||
: 'Password is required.'
|
||||
? 'Email address is required.'
|
||||
: 'Password is required.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
const status = await login(email, password);
|
||||
if (status?.must_change || (status?.days_left !== undefined && status.days_left <= 0)) {
|
||||
setSessionChange(true);
|
||||
setChangeRequired({
|
||||
message: 'Your password has expired. Please choose a new password to continue.',
|
||||
reason: 'expired',
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate('/daily', { replace: true });
|
||||
} catch (err: any) {
|
||||
// Backend signals that password policy or expiry requires a new password first
|
||||
@ -98,8 +111,8 @@ export function LoginPage() {
|
||||
errs.newPassword && errs.confirmPassword
|
||||
? 'Please enter and confirm your new password.'
|
||||
: errs.newPassword
|
||||
? 'New password is required.'
|
||||
: 'Please confirm your new password.'
|
||||
? 'New password is required.'
|
||||
: 'Please confirm your new password.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -130,7 +143,15 @@ export function LoginPage() {
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await completePasswordChange(email, password, newPassword);
|
||||
if (sessionChange) {
|
||||
// Changing the password invalidates the current session, so sign in again with the new one.
|
||||
await changePassword(undefined, newPassword);
|
||||
await login(email, newPassword);
|
||||
setSessionChange(false);
|
||||
setChangeRequired(null);
|
||||
} else {
|
||||
await completePasswordChange(email, password, newPassword);
|
||||
}
|
||||
navigate('/daily', { replace: true });
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Could not update password. Please try again.');
|
||||
@ -140,6 +161,10 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
function backToSignIn() {
|
||||
if (sessionChange) {
|
||||
logout();
|
||||
setSessionChange(false);
|
||||
}
|
||||
setChangeRequired(null);
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
@ -152,195 +177,195 @@ export function LoginPage() {
|
||||
title={changeRequired ? 'Set a New Password' : 'Welcome Back'}
|
||||
subtitle={changeRequired ? changeRequired.message : 'Sign in to continue to Krishna Sales'}
|
||||
>
|
||||
{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>
|
||||
{error && (
|
||||
<div className="mb-4 text-xs text-rose-700 bg-rose-50 border border-rose-200 p-3 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle size={16} className="shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changeRequired ? (
|
||||
/* Forced Password Change Form */
|
||||
<form noValidate 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>
|
||||
)}
|
||||
|
||||
{changeRequired ? (
|
||||
/* Forced Password Change Form */
|
||||
<form noValidate 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">
|
||||
<div className="relative flex items-center">
|
||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
if (fieldErrors.newPassword) setFieldErrors((prev) => ({ ...prev, newPassword: false }));
|
||||
setError(null);
|
||||
}}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'h-12 w-full pl-11 pr-11 rounded-full outline-none transition-all text-sm',
|
||||
fieldErrors.newPassword
|
||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
||||
)}
|
||||
aria-label="New password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword((s) => !s)}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
||||
>
|
||||
{showNewPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
</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">
|
||||
<div className="relative flex items-center">
|
||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
if (fieldErrors.confirmPassword) setFieldErrors((prev) => ({ ...prev, confirmPassword: false }));
|
||||
setError(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'h-12 w-full pl-11 pr-11 rounded-full outline-none transition-all text-sm',
|
||||
fieldErrors.confirmPassword
|
||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
||||
)}
|
||||
aria-label="Confirm new password"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword((s) => !s)}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
||||
>
|
||||
{showConfirmPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
full
|
||||
disabled={busy}
|
||||
className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20"
|
||||
>
|
||||
{busy ? 'Updating & Signing in…' : (<>Update Password & Sign In <ArrowRight size={18} className="ml-2" /></>)}
|
||||
</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 noValidate onSubmit={submit} className="flex flex-col gap-4">
|
||||
{/* New Password */}
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<div className="relative flex items-center">
|
||||
<Mail size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
type="email"
|
||||
aria-label="Email"
|
||||
value={email}
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: false }));
|
||||
setNewPassword(e.target.value);
|
||||
if (fieldErrors.newPassword) setFieldErrors((prev) => ({ ...prev, newPassword: false }));
|
||||
setError(null);
|
||||
}}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'h-12 w-full pl-11 pr-4 rounded-full outline-none transition-all text-sm',
|
||||
fieldErrors.email
|
||||
'h-12 w-full pl-11 pr-11 rounded-full outline-none transition-all text-sm',
|
||||
fieldErrors.newPassword
|
||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
||||
)}
|
||||
placeholder="Email"
|
||||
aria-label="New password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative flex items-center">
|
||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
aria-label="Password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: false }));
|
||||
setError(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'h-12 w-full pl-11 pr-11 rounded-full outline-none transition-all text-sm',
|
||||
fieldErrors.password
|
||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
||||
)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
||||
>
|
||||
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="self-end text-sm font-medium text-emerald-900 hover:text-emerald-700 transition-colors"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword((s) => !s)}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
{showNewPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordRulesChecklist
|
||||
policy={policy}
|
||||
password={newPassword}
|
||||
email={email}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
full
|
||||
{/* Confirm New Password */}
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<div className="relative flex items-center">
|
||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
if (fieldErrors.confirmPassword) setFieldErrors((prev) => ({ ...prev, confirmPassword: false }));
|
||||
setError(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'h-12 w-full pl-11 pr-11 rounded-full outline-none transition-all text-sm',
|
||||
fieldErrors.confirmPassword
|
||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
||||
)}
|
||||
aria-label="Confirm new password"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword((s) => !s)}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
||||
>
|
||||
{showConfirmPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
full
|
||||
disabled={busy}
|
||||
className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20"
|
||||
>
|
||||
{busy ? 'Updating & Signing in…' : (<>Update Password & Sign In <ArrowRight size={18} className="ml-2" /></>)}
|
||||
</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 noValidate onSubmit={submit} className="flex flex-col gap-4">
|
||||
<div className="relative flex items-center">
|
||||
<Mail size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
type="email"
|
||||
aria-label="Email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: false }));
|
||||
setError(null);
|
||||
}}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20"
|
||||
className={cn(
|
||||
'h-12 w-full pl-11 pr-4 rounded-full outline-none transition-all text-sm',
|
||||
fieldErrors.email
|
||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
||||
)}
|
||||
placeholder="Email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative flex items-center">
|
||||
<Lock size={18} className="absolute left-4 text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
aria-label="Password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: false }));
|
||||
setError(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
'h-12 w-full pl-11 pr-11 rounded-full outline-none transition-all text-sm',
|
||||
fieldErrors.password
|
||||
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400'
|
||||
: 'border border-slate-200 bg-slate-100/80 text-slate-900 placeholder:text-slate-500 focus:ring-2 focus:ring-emerald-300 focus:border-emerald-400'
|
||||
)}
|
||||
placeholder="Password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
className="absolute right-4 text-slate-600 hover:text-slate-800 transition-colors cursor-pointer"
|
||||
>
|
||||
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="self-end text-sm font-medium text-emerald-900 hover:text-emerald-700 transition-colors"
|
||||
>
|
||||
{busy ? 'Signing in…' : (<>Sign In <ArrowRight size={18} className="ml-2" /></>)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
full
|
||||
disabled={busy}
|
||||
className="mt-1 !rounded-full !h-12 !text-base bg-gradient-to-r from-[#3aa75a] to-[#0f5a30] hover:brightness-110 text-white border-transparent shadow-lg shadow-emerald-900/20"
|
||||
>
|
||||
{busy ? 'Signing in…' : (<>Sign In <ArrowRight size={18} className="ml-2" /></>)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user