password expiry policy
This commit is contained in:
parent
8ba8d133a5
commit
9e7998db6a
@ -14,6 +14,7 @@ This is a Progressive Web App (Pwa) built for the Krishna Sales team.
|
|||||||
- **Platform maintenance notices** (`src/api/notices.ts`): polls `GET /usr/notices` every 30s. `announced` shows a dismissible strip, `draining` keeps the strip and refuses writes (`PLATFORM_MAINTENANCE` 503), `closed` shows a full-screen block that lifts on its own.
|
- **Platform maintenance notices** (`src/api/notices.ts`): polls `GET /usr/notices` every 30s. `announced` shows a dismissible strip, `draining` keeps the strip and refuses writes (`PLATFORM_MAINTENANCE` 503), `closed` shows a full-screen block that lifts on its own.
|
||||||
- Other 503s show an "unavailable" strip without blocking writes.
|
- Other 503s show an "unavailable" strip without blocking writes.
|
||||||
- Logout now clears per-user data but keeps session-protocol keys instead of `localStorage.clear()`.
|
- Logout now clears per-user data but keeps session-protocol keys instead of `localStorage.clear()`.
|
||||||
|
- **Password expiry warning & enforcement**: Evaluates `user.password_status` (`warn`, `days_left`, `must_change`). Shows a top banner in `ConsoleLayout` with "Change Password" and "Later" skip options during the warning window; automatically launches the mandatory password change dialog when the password has expired.
|
||||||
|
|
||||||
## Sitemap
|
## Sitemap
|
||||||
|
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export async function resetPassword(resetToken: string, newPassword: string): Pr
|
|||||||
* On 200 success, the backend bumps password_changed_at and invalidates the session JWT.
|
* On 200 success, the backend bumps password_changed_at and invalidates the session JWT.
|
||||||
*/
|
*/
|
||||||
export async function changePassword(
|
export async function changePassword(
|
||||||
currentPassword: string,
|
currentPassword: string | undefined | null,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
token?: string | null
|
token?: string | null
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
@ -97,6 +97,13 @@ export async function changePassword(
|
|||||||
throw new AuthApiError('You must be signed in to change your password', 401);
|
throw new AuthApiError('You must be signed in to change your password', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, string> = {
|
||||||
|
new_password: newPassword,
|
||||||
|
};
|
||||||
|
if (currentPassword) {
|
||||||
|
payload.current_password = currentPassword;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/usr/me/password`, {
|
const res = await fetch(`${BASE_URL}/usr/me/password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: AUTH_CREDENTIALS,
|
credentials: AUTH_CREDENTIALS,
|
||||||
@ -104,10 +111,7 @@ export async function changePassword(
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${authToken}`,
|
Authorization: `Bearer ${authToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
current_password: currentPassword,
|
|
||||||
new_password: newPassword,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export async function loginAll(email: string, password: string, orgId?: string)
|
|||||||
try {
|
try {
|
||||||
const me = await orderBookingClient.getMe();
|
const me = await orderBookingClient.getMe();
|
||||||
res.user = { ...res.user, ...me };
|
res.user = { ...res.user, ...me };
|
||||||
|
ALL.forEach((c) => c.setUser(res.user));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch user profile:', e);
|
console.error('Failed to fetch user profile:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,13 @@ export interface ApiError {
|
|||||||
data?: any;
|
data?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasswordStatus {
|
||||||
|
must_change?: boolean;
|
||||||
|
expires_at?: string;
|
||||||
|
days_left?: number;
|
||||||
|
warn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
org_id: string;
|
org_id: string;
|
||||||
@ -19,6 +26,8 @@ export interface User {
|
|||||||
roles: string[];
|
roles: string[];
|
||||||
groups: string[];
|
groups: string[];
|
||||||
is_admin?: boolean;
|
is_admin?: boolean;
|
||||||
|
provider?: string;
|
||||||
|
password_status?: PasswordStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, type ReactNode, useEffect } from 'react';
|
import { useState, type ReactNode, useEffect } from 'react';
|
||||||
import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients';
|
import { loginAll, logoutAll, currentToken, orderBookingClient, completePasswordChangeAll } from '../api/clients';
|
||||||
import { AuthCtx, type AuthValue } from './context';
|
import { AuthCtx, type AuthValue } from './context';
|
||||||
|
import type { PasswordStatus } from '../api/types';
|
||||||
import { sessionClient } from '../api/session';
|
import { sessionClient } from '../api/session';
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
@ -14,6 +15,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [roles, setRoles] = useState<string[]>(() => {
|
const [roles, setRoles] = useState<string[]>(() => {
|
||||||
return orderBookingClient.currentUser()?.roles ?? [];
|
return orderBookingClient.currentUser()?.roles ?? [];
|
||||||
});
|
});
|
||||||
|
const [passwordStatus, setPasswordStatus] = useState<PasswordStatus | null>(() => {
|
||||||
|
return orderBookingClient.currentUser()?.password_status ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
// Keep React in step with the session: refresh/idle expiry, a sign-out or a
|
// Keep React in step with the session: refresh/idle expiry, a sign-out or a
|
||||||
// different user signing in from another tab.
|
// different user signing in from another tab.
|
||||||
@ -25,6 +29,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUserEmail(null);
|
setUserEmail(null);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
|
setPasswordStatus(null);
|
||||||
} else if (type === 'user-switched') {
|
} else if (type === 'user-switched') {
|
||||||
// This tab's state belongs to the previous user.
|
// This tab's state belongs to the previous user.
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@ -38,10 +43,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// If we are authenticated but don't have isAdmin from cache, we might want to fetch it
|
// If we are authenticated but don't have isAdmin from cache, we might want to fetch it
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authed && (!isAdmin || roles.length === 0)) {
|
if (authed && (!isAdmin || roles.length === 0 || !passwordStatus)) {
|
||||||
orderBookingClient.getMe().then(me => {
|
orderBookingClient.getMe().then(me => {
|
||||||
if (me.is_admin) setIsAdmin(true);
|
if (me.is_admin) setIsAdmin(true);
|
||||||
if (me.roles) setRoles(me.roles);
|
if (me.roles) setRoles(me.roles);
|
||||||
|
if (me.password_status) setPasswordStatus(me.password_status);
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
}, [authed]);
|
}, [authed]);
|
||||||
@ -58,6 +64,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem('krishna_sales_is_admin', userIsAdmin ? 'true' : 'false');
|
localStorage.setItem('krishna_sales_is_admin', userIsAdmin ? 'true' : 'false');
|
||||||
setRoles(userRoles);
|
setRoles(userRoles);
|
||||||
localStorage.setItem('krishna_sales_roles', JSON.stringify(userRoles));
|
localStorage.setItem('krishna_sales_roles', JSON.stringify(userRoles));
|
||||||
|
if (res.user?.password_status) {
|
||||||
|
setPasswordStatus(res.user.password_status);
|
||||||
|
} else {
|
||||||
|
setPasswordStatus(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const value: AuthValue = {
|
const value: AuthValue = {
|
||||||
@ -65,6 +76,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
roles,
|
roles,
|
||||||
userEmail,
|
userEmail,
|
||||||
|
passwordStatus,
|
||||||
login: async (email, password, orgId) => {
|
login: async (email, password, orgId) => {
|
||||||
const res = await loginAll(email, password, orgId);
|
const res = await loginAll(email, password, orgId);
|
||||||
applyUserSession(res);
|
applyUserSession(res);
|
||||||
@ -79,6 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setUserEmail(null);
|
setUserEmail(null);
|
||||||
setIsAdmin(false);
|
setIsAdmin(false);
|
||||||
setRoles([]);
|
setRoles([]);
|
||||||
|
setPasswordStatus(null);
|
||||||
// Drop this user's cached data (incl. form drafts) but keep the session
|
// Drop this user's cached data (incl. form drafts) but keep the session
|
||||||
// protocol keys (sm_*): the pending-logout marker must survive until the server hears it.
|
// protocol keys (sm_*): the pending-logout marker must survive until the server hears it.
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { PasswordStatus } from '../api/types';
|
||||||
|
|
||||||
export interface PasswordChangeRequiredInfo {
|
export interface PasswordChangeRequiredInfo {
|
||||||
message: string;
|
message: string;
|
||||||
@ -11,6 +12,7 @@ export interface AuthValue {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
|
passwordStatus: PasswordStatus | 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>;
|
completePasswordChange: (email: string, currentPassword: string, newPassword: string, orgId?: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Button } from '../buttons/Button';
|
|||||||
import { PasswordRulesChecklist } from './PasswordRulesChecklist';
|
import { PasswordRulesChecklist } from './PasswordRulesChecklist';
|
||||||
import { changePassword } from '../../api/auth';
|
import { changePassword } from '../../api/auth';
|
||||||
import { usePasswordPolicy } from '../../hooks/usePasswordPolicy';
|
import { usePasswordPolicy } from '../../hooks/usePasswordPolicy';
|
||||||
|
import { cn } from '../../lib/cn';
|
||||||
import {
|
import {
|
||||||
passwordMeetsPolicy,
|
passwordMeetsPolicy,
|
||||||
passwordTooLong,
|
passwordTooLong,
|
||||||
@ -17,6 +18,7 @@ interface ChangePasswordModalProps {
|
|||||||
userEmail?: string | null;
|
userEmail?: string | null;
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
onSuccessLogout: () => void;
|
onSuccessLogout: () => void;
|
||||||
|
required?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChangePasswordModal({
|
export function ChangePasswordModal({
|
||||||
@ -25,6 +27,7 @@ export function ChangePasswordModal({
|
|||||||
userEmail,
|
userEmail,
|
||||||
userName,
|
userName,
|
||||||
onSuccessLogout,
|
onSuccessLogout,
|
||||||
|
required = false,
|
||||||
}: ChangePasswordModalProps) {
|
}: ChangePasswordModalProps) {
|
||||||
const policy = usePasswordPolicy(open);
|
const policy = usePasswordPolicy(open);
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
@ -34,6 +37,12 @@ export function ChangePasswordModal({
|
|||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
|
current?: boolean;
|
||||||
|
new?: boolean;
|
||||||
|
confirm?: boolean;
|
||||||
|
}>({});
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
@ -43,6 +52,7 @@ export function ChangePasswordModal({
|
|||||||
setCurrentPassword('');
|
setCurrentPassword('');
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
|
setFieldErrors({});
|
||||||
setError(null);
|
setError(null);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@ -50,35 +60,61 @@ export function ChangePasswordModal({
|
|||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
const errors: { current?: boolean; new?: boolean; confirm?: boolean } = {};
|
||||||
setError('All fields are required.');
|
|
||||||
|
if (!required && !currentPassword) {
|
||||||
|
errors.current = true;
|
||||||
|
}
|
||||||
|
if (!newPassword) {
|
||||||
|
errors.new = true;
|
||||||
|
}
|
||||||
|
if (!confirmPassword) {
|
||||||
|
errors.confirm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setFieldErrors(errors);
|
||||||
|
if (!required && errors.current) {
|
||||||
|
setError('Current password is required.');
|
||||||
|
} else if (errors.new && errors.confirm) {
|
||||||
|
setError('Please enter and confirm your new password.');
|
||||||
|
} else if (errors.new) {
|
||||||
|
setError('New password is required.');
|
||||||
|
} else {
|
||||||
|
setError('Please confirm your new password.');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwordMeetsPolicy(policy, newPassword, { email: userEmail, name: userName })) {
|
if (!passwordMeetsPolicy(policy, newPassword, { email: userEmail, name: userName })) {
|
||||||
|
setFieldErrors({ new: true });
|
||||||
setError('New password does not meet all of the password requirements.');
|
setError('New password does not meet all of the password requirements.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordTooLong(newPassword)) {
|
if (passwordTooLong(newPassword)) {
|
||||||
|
setFieldErrors({ new: true });
|
||||||
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
|
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
|
setFieldErrors({ confirm: true });
|
||||||
setError('New passwords do not match.');
|
setError('New passwords do not match.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword === currentPassword) {
|
if (!required && currentPassword && newPassword === currentPassword) {
|
||||||
|
setFieldErrors({ new: true });
|
||||||
setError('New password must be different from your current password.');
|
setError('New password must be different from your current password.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await changePassword(currentPassword, newPassword);
|
await changePassword(required ? undefined : currentPassword, newPassword);
|
||||||
setDone(true);
|
setDone(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onSuccessLogout();
|
onSuccessLogout();
|
||||||
@ -94,17 +130,23 @@ export function ChangePasswordModal({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
|
closable={!required}
|
||||||
width="sm"
|
width="sm"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Lock className="text-emerald-600 size-5" />
|
<Lock className="text-emerald-600 size-5" />
|
||||||
<span>Change Password</span>
|
<span>{required ? 'Password Expired' : 'Change Password'}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
subtitle="Update your account password. You'll need to sign in again after."
|
subtitle={
|
||||||
|
required
|
||||||
|
? 'Your password has expired. Please set a new password to continue.'
|
||||||
|
: "Update your account password. You'll need to sign in again after."
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form noValidate onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
{/* Current Password */}
|
{/* Current Password — only required for self-service change; hidden when must_change is active */}
|
||||||
|
{!required && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs font-semibold text-slate-700">
|
<label className="text-xs font-semibold text-slate-700">
|
||||||
Current password <span className="text-rose-500">*</span>
|
Current password <span className="text-rose-500">*</span>
|
||||||
@ -114,10 +156,18 @@ export function ChangePasswordModal({
|
|||||||
type={showCurrent ? 'text' : 'password'}
|
type={showCurrent ? 'text' : 'password'}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setCurrentPassword(e.target.value);
|
||||||
|
if (fieldErrors.current) setFieldErrors((prev) => ({ ...prev, current: false }));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
disabled={submitting || done}
|
disabled={submitting || done}
|
||||||
required
|
className={cn(
|
||||||
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"
|
'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all',
|
||||||
|
fieldErrors.current
|
||||||
|
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500'
|
||||||
|
: 'border border-slate-200 bg-slate-50/50 text-slate-800 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500'
|
||||||
|
)}
|
||||||
placeholder="Enter current password"
|
placeholder="Enter current password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -131,6 +181,7 @@ export function ChangePasswordModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* New Password */}
|
{/* New Password */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@ -142,10 +193,19 @@ export function ChangePasswordModal({
|
|||||||
type={showNew ? 'text' : 'password'}
|
type={showNew ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setNewPassword(e.target.value);
|
||||||
|
if (fieldErrors.new) setFieldErrors((prev) => ({ ...prev, new: false }));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
disabled={submitting || done}
|
disabled={submitting || done}
|
||||||
required
|
autoFocus={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"
|
className={cn(
|
||||||
|
'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all',
|
||||||
|
fieldErrors.new
|
||||||
|
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500'
|
||||||
|
: 'border border-slate-200 bg-slate-50/50 text-slate-800 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500'
|
||||||
|
)}
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -177,10 +237,18 @@ export function ChangePasswordModal({
|
|||||||
type={showConfirm ? 'text' : 'password'}
|
type={showConfirm ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setConfirmPassword(e.target.value);
|
||||||
|
if (fieldErrors.confirm) setFieldErrors((prev) => ({ ...prev, confirm: false }));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
disabled={submitting || done}
|
disabled={submitting || done}
|
||||||
required
|
className={cn(
|
||||||
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"
|
'h-[42px] w-full px-3 pr-10 text-sm rounded-lg outline-none transition-all',
|
||||||
|
fieldErrors.confirm
|
||||||
|
? 'border-2 border-rose-500 bg-rose-50/20 text-rose-900 focus:ring-2 focus:ring-rose-400 focus:border-rose-500'
|
||||||
|
: 'border border-slate-200 bg-slate-50/50 text-slate-800 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500'
|
||||||
|
)}
|
||||||
placeholder="Re-enter new password"
|
placeholder="Re-enter new password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -210,6 +278,16 @@ export function ChangePasswordModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2.5 mt-2">
|
<div className="flex items-center justify-end gap-2.5 mt-2">
|
||||||
|
{required ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={submitting || done}
|
||||||
|
onClick={onSuccessLogout}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -218,6 +296,7 @@ export function ChangePasswordModal({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || done}
|
disabled={submitting || done}
|
||||||
|
|||||||
127
src/components/auth/PasswordExpiryWarningBanner.tsx
Normal file
127
src/components/auth/PasswordExpiryWarningBanner.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AlertTriangle, KeyRound, Lock } from 'lucide-react';
|
||||||
|
import type { PasswordStatus } from '../../api/types';
|
||||||
|
|
||||||
|
interface PasswordExpiryWarningBannerProps {
|
||||||
|
passwordStatus?: PasswordStatus | null;
|
||||||
|
onChangePassword: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordExpiryWarningBanner({
|
||||||
|
passwordStatus,
|
||||||
|
onChangePassword,
|
||||||
|
}: PasswordExpiryWarningBannerProps) {
|
||||||
|
const expiresAt = passwordStatus?.expires_at || 'session';
|
||||||
|
const dismissKey = `krishna_sales_dismissed_pw_warn_${expiresAt}`;
|
||||||
|
|
||||||
|
const [dismissed, setDismissed] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(dismissKey) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isExpired = Boolean(
|
||||||
|
passwordStatus?.must_change ||
|
||||||
|
(passwordStatus?.days_left !== undefined && passwordStatus.days_left <= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Evaluate warning state: backend flags warn: true, or days_left is within warning window (<= 3)
|
||||||
|
const isWarnActive = Boolean(
|
||||||
|
isExpired ||
|
||||||
|
passwordStatus?.warn ||
|
||||||
|
(passwordStatus?.days_left !== undefined && passwordStatus.days_left <= 3)
|
||||||
|
);
|
||||||
|
|
||||||
|
// If not warning/expired, or if dismissed while NOT expired, hide banner.
|
||||||
|
// When expired, dismissal is disabled so the user must take action.
|
||||||
|
if (!isWarnActive || (dismissed && !isExpired)) return null;
|
||||||
|
|
||||||
|
const daysLeft = passwordStatus?.days_left;
|
||||||
|
let headline = 'Your password will expire soon.';
|
||||||
|
if (isExpired) {
|
||||||
|
headline = 'Your password has expired.';
|
||||||
|
} else if (daysLeft !== undefined) {
|
||||||
|
if (daysLeft === 1) {
|
||||||
|
headline = 'Your password will expire tomorrow.';
|
||||||
|
} else {
|
||||||
|
headline = `Your password will expire in ${daysLeft} days.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setDismissed(true);
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(dismissKey, 'true');
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="print:hidden shrink-0 flex flex-wrap sm:flex-nowrap items-center justify-between gap-2.5 sm:gap-4 px-4 py-2.5 bg-rose-50 border-b border-rose-200 text-xs text-rose-900 font-sans shadow-xs transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
|
<Lock size={16} className="text-rose-600 shrink-0" />
|
||||||
|
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 min-w-0">
|
||||||
|
<strong className="font-semibold text-rose-950">{headline}</strong>
|
||||||
|
<span className="text-rose-800">
|
||||||
|
You must update your password to continue using your account.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-auto sm:ml-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onChangePassword}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 bg-rose-600 hover:bg-rose-700 active:bg-rose-800 text-white font-medium rounded-md shadow-xs transition-colors cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
<KeyRound size={13} />
|
||||||
|
<span>Change Password Now</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="print:hidden shrink-0 flex flex-wrap sm:flex-nowrap items-center justify-between gap-2.5 sm:gap-4 px-4 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-900 font-sans shadow-xs transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
|
<AlertTriangle size={16} className="text-amber-600 shrink-0" />
|
||||||
|
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 min-w-0">
|
||||||
|
<strong className="font-semibold text-amber-950">{headline}</strong>
|
||||||
|
<span className="text-amber-800">
|
||||||
|
Please update your password to maintain access to your account.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-auto sm:ml-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium text-amber-800 hover:text-amber-950 hover:bg-amber-100/80 rounded-md transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Later
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onChangePassword}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 bg-amber-600 hover:bg-amber-700 active:bg-amber-800 text-white font-medium rounded-md shadow-xs transition-colors cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
<KeyRound size={13} />
|
||||||
|
<span>Change Password</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,6 +15,8 @@ export interface ModalProps {
|
|||||||
width?: ModalWidth;
|
width?: ModalWidth;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
/** Whether the modal can be dismissed via Esc, backdrop, or close button. @default true */
|
||||||
|
closable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WIDTH_CLASSES: Record<ModalWidth, string> = {
|
const WIDTH_CLASSES: Record<ModalWidth, string> = {
|
||||||
@ -25,9 +27,18 @@ const WIDTH_CLASSES: Record<ModalWidth, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
|
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
|
||||||
export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) {
|
export function Modal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
width = 'md',
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
closable = true,
|
||||||
|
}: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open || !closable) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') onClose();
|
||||||
};
|
};
|
||||||
@ -38,7 +49,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', actions, c
|
|||||||
document.removeEventListener('keydown', onKey);
|
document.removeEventListener('keydown', onKey);
|
||||||
document.body.style.overflow = prev;
|
document.body.style.overflow = prev;
|
||||||
};
|
};
|
||||||
}, [open, onClose]);
|
}, [open, onClose, closable]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@ -46,7 +57,7 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', actions, c
|
|||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center p-4 sm:p-6 overflow-hidden bg-black/50 backdrop-blur-sm transition-opacity"
|
className="fixed inset-0 z-[10000] flex items-center justify-center p-4 sm:p-6 overflow-hidden bg-black/50 backdrop-blur-sm transition-opacity"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (closable && e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<style>{`
|
<style>{`
|
||||||
@ -74,13 +85,15 @@ export function Modal({ open, onClose, title, subtitle, width = 'md', actions, c
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{actions && <div className="flex items-center gap-2 mr-2">{actions}</div>}
|
{actions && <div className="flex items-center gap-2 mr-2">{actions}</div>}
|
||||||
|
{closable && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
|
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5 cursor-pointer"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
|
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { onAuthErrorAll, orderBookingClient } from '../api/clients';
|
|||||||
import { APP_VERSION } from '../api/config';
|
import { APP_VERSION } from '../api/config';
|
||||||
import { NoticeStrips } from '../components/common/NoticeBanner';
|
import { NoticeStrips } from '../components/common/NoticeBanner';
|
||||||
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
|
import { ChangePasswordModal } from '../components/auth/ChangePasswordModal';
|
||||||
|
import { PasswordExpiryWarningBanner } from '../components/auth/PasswordExpiryWarningBanner';
|
||||||
|
|
||||||
import { REPORT_MAP } from './ReportPage';
|
import { REPORT_MAP } from './ReportPage';
|
||||||
|
|
||||||
@ -26,13 +27,26 @@ 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, userEmail } = useAuth();
|
const { authed, logout, isAdmin, roles: userRoles, userEmail, passwordStatus } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = orderBookingClient.currentUser();
|
const user = orderBookingClient.currentUser();
|
||||||
|
const effectivePasswordStatus = passwordStatus ?? user?.password_status;
|
||||||
|
|
||||||
|
const isExpired = Boolean(
|
||||||
|
effectivePasswordStatus?.must_change ||
|
||||||
|
(effectivePasswordStatus?.days_left !== undefined && effectivePasswordStatus.days_left <= 0)
|
||||||
|
);
|
||||||
|
|
||||||
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||||
|
|
||||||
|
// If password has expired, automatically move the user directly to change password
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExpired) {
|
||||||
|
setIsChangePasswordOpen(true);
|
||||||
|
}
|
||||||
|
}, [isExpired]);
|
||||||
|
|
||||||
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.
|
||||||
@ -187,6 +201,10 @@ export function ConsoleLayout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<NoticeStrips />
|
<NoticeStrips />
|
||||||
|
<PasswordExpiryWarningBanner
|
||||||
|
passwordStatus={effectivePasswordStatus}
|
||||||
|
onChangePassword={() => setIsChangePasswordOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-[1240px] w-full mx-auto flex flex-col gap-5">
|
<div className="max-w-[1240px] w-full mx-auto flex flex-col gap-5">
|
||||||
@ -196,7 +214,12 @@ export function ConsoleLayout() {
|
|||||||
|
|
||||||
<ChangePasswordModal
|
<ChangePasswordModal
|
||||||
open={isChangePasswordOpen}
|
open={isChangePasswordOpen}
|
||||||
onClose={() => setIsChangePasswordOpen(false)}
|
onClose={() => {
|
||||||
|
if (!isExpired) {
|
||||||
|
setIsChangePasswordOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
required={isExpired}
|
||||||
userEmail={userEmail || user?.email}
|
userEmail={userEmail || user?.email}
|
||||||
userName={user?.name}
|
userName={user?.name}
|
||||||
onSuccessLogout={() => {
|
onSuccessLogout={() => {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Card } from '../components/reusable';
|
|||||||
import { getDefaultRoute } from '../auth/ProtectedRoute';
|
import { getDefaultRoute } from '../auth/ProtectedRoute';
|
||||||
import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist';
|
import { PasswordRulesChecklist } from '../components/auth/PasswordRulesChecklist';
|
||||||
import { usePasswordPolicy } from '../hooks/usePasswordPolicy';
|
import { usePasswordPolicy } from '../hooks/usePasswordPolicy';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
import {
|
import {
|
||||||
passwordMeetsPolicy,
|
passwordMeetsPolicy,
|
||||||
passwordTooLong,
|
passwordTooLong,
|
||||||
@ -31,14 +32,39 @@ export function LoginPage() {
|
|||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
|
email?: boolean;
|
||||||
|
password?: boolean;
|
||||||
|
newPassword?: boolean;
|
||||||
|
confirmPassword?: boolean;
|
||||||
|
}>({});
|
||||||
|
|
||||||
const policy = usePasswordPolicy(!!changeRequired);
|
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) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
|
const errs: typeof fieldErrors = {};
|
||||||
|
if (!email) errs.email = true;
|
||||||
|
if (!password) errs.password = true;
|
||||||
|
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
setFieldErrors(errs);
|
||||||
|
setError(
|
||||||
|
errs.email && errs.password
|
||||||
|
? 'Please enter both your email and password.'
|
||||||
|
: errs.email
|
||||||
|
? 'Email address is required.'
|
||||||
|
: 'Password is required.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
@ -69,28 +95,38 @@ export function LoginPage() {
|
|||||||
async function handleChangePasswordSubmit(e: FormEvent) {
|
async function handleChangePasswordSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
if (!newPassword || !confirmPassword) {
|
const errs: typeof fieldErrors = {};
|
||||||
|
if (!newPassword) errs.newPassword = true;
|
||||||
|
if (!confirmPassword) errs.confirmPassword = true;
|
||||||
|
|
||||||
|
if (Object.keys(errs).length > 0) {
|
||||||
|
setFieldErrors(errs);
|
||||||
setError('Please fill in both password fields.');
|
setError('Please fill in both password fields.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwordMeetsPolicy(policy, newPassword, { email })) {
|
if (!passwordMeetsPolicy(policy, newPassword, { email })) {
|
||||||
|
setFieldErrors({ newPassword: true });
|
||||||
setError('New password does not meet all of the password requirements.');
|
setError('New password does not meet all of the password requirements.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordTooLong(newPassword)) {
|
if (passwordTooLong(newPassword)) {
|
||||||
|
setFieldErrors({ newPassword: true });
|
||||||
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
|
setError(`New password must be at most ${PASSWORD_MAX_CHARS} characters.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
|
setFieldErrors({ confirmPassword: true });
|
||||||
setError('New passwords do not match.');
|
setError('New passwords do not match.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword === password) {
|
if (newPassword === password) {
|
||||||
|
setFieldErrors({ newPassword: true });
|
||||||
setError('New password must be different from your current password.');
|
setError('New password must be different from your current password.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -110,6 +146,7 @@ export function LoginPage() {
|
|||||||
setChangeRequired(null);
|
setChangeRequired(null);
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
|
setFieldErrors({});
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,7 +174,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{changeRequired ? (
|
{changeRequired ? (
|
||||||
/* Forced Password Change Form */
|
/* Forced Password Change Form */
|
||||||
<form onSubmit={handleChangePasswordSubmit} className="flex flex-col gap-4">
|
<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">
|
<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" />
|
<Lock size={16} className="text-amber-700 shrink-0 mt-0.5" />
|
||||||
<span>
|
<span>
|
||||||
@ -155,11 +192,19 @@ export function LoginPage() {
|
|||||||
type={showNewPassword ? 'text' : 'password'}
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => {
|
||||||
required
|
setNewPassword(e.target.value);
|
||||||
|
if (fieldErrors.newPassword) setFieldErrors((prev) => ({ ...prev, newPassword: false }));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={busy}
|
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"
|
className={cn(
|
||||||
|
'h-[42px] w-full px-3 pr-10 rounded-md 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 focus:border-rose-500'
|
||||||
|
: 'border border-emerald-200 bg-emerald-50/50 text-emerald-900 placeholder:text-slate-400 focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400'
|
||||||
|
)}
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -190,10 +235,18 @@ export function LoginPage() {
|
|||||||
type={showConfirmPassword ? 'text' : 'password'}
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => {
|
||||||
required
|
setConfirmPassword(e.target.value);
|
||||||
|
if (fieldErrors.confirmPassword) setFieldErrors((prev) => ({ ...prev, confirmPassword: false }));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
disabled={busy}
|
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"
|
className={cn(
|
||||||
|
'h-[42px] w-full px-3 pr-10 rounded-md 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 focus:border-rose-500'
|
||||||
|
: 'border border-emerald-200 bg-emerald-50/50 text-emerald-900 placeholder:text-slate-400 focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400'
|
||||||
|
)}
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -229,7 +282,7 @@ export function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
/* Standard Login Form */
|
/* Standard Login Form */
|
||||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
<form noValidate onSubmit={submit} className="flex flex-col gap-4">
|
||||||
<label className="flex flex-col gap-1.5 font-sans">
|
<label className="flex flex-col gap-1.5 font-sans">
|
||||||
<span className="text-sm font-semibold text-emerald-800">
|
<span className="text-sm font-semibold text-emerald-800">
|
||||||
Email <span className="text-emerald-500">*</span>
|
Email <span className="text-emerald-500">*</span>
|
||||||
@ -237,11 +290,19 @@ export function LoginPage() {
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => {
|
||||||
required
|
setEmail(e.target.value);
|
||||||
|
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: false }));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={busy}
|
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"
|
className={cn(
|
||||||
|
'h-[42px] px-3 rounded-md 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 focus:border-rose-500'
|
||||||
|
: 'border border-emerald-200 bg-emerald-50/50 text-emerald-900 placeholder:text-slate-400 focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400'
|
||||||
|
)}
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -262,10 +323,18 @@ export function LoginPage() {
|
|||||||
<input
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => {
|
||||||
required
|
setPassword(e.target.value);
|
||||||
|
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: false }));
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
disabled={busy}
|
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"
|
className={cn(
|
||||||
|
'h-[42px] w-full px-3 pr-10 rounded-md 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 focus:border-rose-500'
|
||||||
|
: 'border border-emerald-200 bg-emerald-50/50 text-emerald-900 placeholder:text-slate-400 focus:ring-2 focus:ring-emerald-400 focus:border-emerald-400'
|
||||||
|
)}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user