feat: implement password complexity policies and mandatory reset logic with new security service integration

This commit is contained in:
Yashwin 2026-05-06 15:04:06 +05:30
parent eaf0568a64
commit 950cfe9f83
14 changed files with 389 additions and 63 deletions

View File

@ -243,6 +243,10 @@ const tenantAdminSystemMenu: MenuItem[] = [
label: "General Settings",
path: "/tenant/settings",
},
{
label: "Security Policy",
path: "/tenant/settings/security-policy",
},
{
label: "Notification Settings",
path: "/tenant/settings/notifications",

View File

@ -28,8 +28,6 @@ const assignmentSchema = z.object({
const newUserSchema = z
.object({
email: z.email({ message: "Please enter a valid email address" }),
password: z.string().min(1, "Password is required").min(6, "Password must be at least 6 characters"),
confirmPassword: z.string().min(1, "Confirm password is required"),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
status: z.enum(["active", "suspended", "deleted"], { message: "Status is required" }),
@ -54,14 +52,10 @@ const newUserSchema = z
designation_id: z.string().min(1, "Designation is required"),
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
supplier_id: z.string().optional().nullable(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type NewUserFormData = z.infer<typeof newUserSchema>;
type CreateUserPayload = Omit<NewUserFormData, "confirmPassword" | "role_module_assignments"> & {
type CreateUserPayload = Omit<NewUserFormData, "role_module_assignments"> & {
role_module_combinations: { role_id: string; module_id: string }[];
};
@ -116,8 +110,6 @@ export const NewUserModal = ({
if (!isOpen) {
reset({
email: "",
password: "",
confirmPassword: "",
first_name: "",
last_name: "",
status: "active",
@ -179,7 +171,7 @@ export const NewUserModal = ({
const handleFormSubmit = async (data: NewUserFormData): Promise<void> => {
clearErrors();
try {
const { confirmPassword, role_module_assignments, ...submitData } = data;
const { role_module_assignments, ...submitData } = data;
const role_module_combinations = role_module_assignments.flatMap((row) =>
row.module_ids.map((module_id) => ({ role_id: row.role_id, module_id })),
);
@ -250,10 +242,6 @@ export const NewUserModal = ({
<FormField label="First Name" required placeholder="Enter first name" error={errors.first_name?.message} {...register("first_name")} />
<FormField label="Last Name" required placeholder="Enter last name" error={errors.last_name?.message} {...register("last_name")} />
</div>
<div className="grid grid-cols-2 gap-5 pb-4">
<FormField label="Password" type="password" required placeholder="Enter password" error={errors.password?.message} {...register("password")} />
<FormField label="Confirm Password" type="password" required placeholder="Confirm password" error={errors.confirmPassword?.message} {...register("confirmPassword")} />
</div>
<div className="grid grid-cols-2 gap-5 pb-4">
<PaginatedSelect label="Department" required placeholder="Select Department" value={departmentIdValue || ""} onValueChange={(value) => setValue("department_id", value, { shouldValidate: true })} onLoadOptions={loadDepartments} error={errors.department_id?.message} />
<PaginatedSelect label="Designation" required placeholder="Select Designation" value={designationIdValue || ""} onValueChange={(value) => setValue("designation_id", value, { shouldValidate: true })} onLoadOptions={loadDesignations} error={errors.designation_id?.message} />

View File

@ -190,7 +190,7 @@ export const UsersTable = forwardRef<UsersTableRef, UsersTableProps>(({
const handleCreateUser = async (data: {
email: string;
password: string;
password?: string;
first_name: string;
last_name: string;
status: "active" | "suspended" | "deleted";

View File

@ -86,6 +86,14 @@ const Login = (): ReactElement => {
try {
const result = await dispatch(loginAsync(data)).unwrap();
if (result) {
// Check if password reset is required
if (result.data.requirePasswordReset) {
const { tempToken, resetReason } = result.data;
showToast.info('Password reset required', 'For security reasons, please update your password.');
navigate(`/reset-password?token=${tempToken}&reason=${resetReason}`);
return;
}
const message = result.message || 'Login successful';
const description = result.message ? undefined : 'Welcome back!';
showToast.success(message, description);

View File

@ -35,6 +35,7 @@ const ResetPassword = (): ReactElement => {
const [success, setSuccess] = useState<string>('');
const tokenFromUrl = searchParams.get('token') || '';
const reason = searchParams.get('reason') || '';
const hasTokenFromUrl = Boolean(tokenFromUrl);
const resetPasswordSchema = createResetPasswordSchema(hasTokenFromUrl);
@ -136,12 +137,15 @@ const ResetPassword = (): ReactElement => {
</div>
<div className="mb-6">
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2">
Reset Password
{reason === 'FIRST_LOGIN' ? 'Set Initial Password' :
reason === 'EXPIRED' ? 'Update Expired Password' :
'Reset Password'}
</h1>
<p className="text-sm md:text-base text-[#6b7280]">
{tokenFromUrl
? 'Enter your new password to reset'
: 'Enter your reset token and new password'}
{reason === 'FIRST_LOGIN' ? 'Welcome to QAssure! Please set a new password for your account to continue.' :
reason === 'EXPIRED' ? 'Your password has expired. For security, please choose a new password.' :
tokenFromUrl ? 'Enter your new password to reset' :
'Enter your reset token and new password'}
</p>
</div>
@ -229,7 +233,7 @@ const ResetPassword = (): ReactElement => {
)}
{/* Back to Login Link */}
{!success && (
{!success && !reason && (
<div className="mt-6 text-center">
<Link
to="/"

View File

@ -62,11 +62,6 @@ const tenantDetailsSchema = z.object({
const contactDetailsSchema = z
.object({
email: z.email({ message: "Please enter a valid email address" }),
password: z
.string()
.min(1, "Password is required")
.min(6, "Password must be at least 6 characters"),
confirmPassword: z.string().min(1, "Confirm password is required"),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
contact_phone: z
@ -90,10 +85,6 @@ const contactDetailsSchema = z
.string()
.regex(/^[1-9]\d{5}$/, "Postal code must be a valid 6-digit PIN code"),
country: z.string().min(1, "Country is required"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
// Step 3: Settings Schema
@ -171,8 +162,6 @@ const CreateTenantWizard = (): ReactElement => {
reValidateMode: "onChange",
defaultValues: {
email: "",
password: "",
confirmPassword: "",
first_name: "",
last_name: "",
contact_phone: "",
@ -414,8 +403,7 @@ const CreateTenantWizard = (): ReactElement => {
// Combine all data for tenant creation - matches NewTenantModal structure
const { modules, ...restTenantDetails } = tenantDetails;
// Extract confirmPassword from contactDetails (not needed in API call)
const { confirmPassword, ...contactData } = contactDetails;
const contactData = contactDetails;
// Extract branding colors from settings
const {
@ -475,10 +463,6 @@ const CreateTenantWizard = (): ReactElement => {
// Contact details errors from nested path
hasContactErrors = true;
const fieldName = path.replace("settings.contact.", "");
if (fieldName === "confirmPassword") {
// Skip confirmPassword as it's not in the form schema
return;
}
contactDetailsForm.setError(
fieldName as keyof ContactDetailsForm,
{
@ -894,30 +878,6 @@ const CreateTenantWizard = (): ReactElement => {
{...contactDetailsForm.register("last_name")}
/>
</div>
{/* Password and Confirm Password Row */}
<div className="grid grid-cols-2 gap-5">
<FormField
label="Password"
type="password"
required
placeholder="Enter password"
error={
contactDetailsForm.formState.errors.password?.message
}
{...contactDetailsForm.register("password")}
/>
<FormField
label="Confirm Password"
type="password"
required
placeholder="Confirm password"
error={
contactDetailsForm.formState.errors.confirmPassword
?.message
}
{...contactDetailsForm.register("confirmPassword")}
/>
</div>
{/* Contact Phone */}
<div className="mt-4">
<FormField

View File

@ -66,7 +66,8 @@ const tenantDetailsSchema = z.object({
// Step 2: Contact Details Schema - NO password fields
const contactDetailsSchema = z.object({
email: z.email({ message: "Please enter a valid email address" }),
id: z.string().uuid().optional().nullable(),
email: z.string().email({ message: "Please enter a valid email address" }),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
contact_phone: z
@ -178,6 +179,7 @@ const EditTenant = (): ReactElement => {
mode: "onChange",
reValidateMode: "onChange",
defaultValues: {
id: "",
email: "",
first_name: "",
last_name: "",
@ -354,6 +356,7 @@ const EditTenant = (): ReactElement => {
previousNameRef.current = tenant.name;
contactDetailsForm.reset({
id: contactInfo.id || "",
email: contactInfo.email || "",
first_name: contactInfo.first_name || "",
last_name: contactInfo.last_name || "",

View File

@ -0,0 +1,274 @@
import { useState, useEffect, type ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import {
Shield,
Clock,
Lock,
Save,
Loader2,
AlertCircle,
CheckCircle2
} from "lucide-react";
import { securityService, type PasswordPolicy } from "@/services/security-service";
import { showToast } from "@/utils/toast";
import { PrimaryButton, FormField } from "@/components/shared";
const SecurityPolicy = (): ReactElement => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [policy, setPolicy] = useState<PasswordPolicy | null>(null);
useEffect(() => {
const fetchPolicy = async () => {
try {
setIsLoading(true);
const response = await securityService.getPasswordPolicy();
if (response.success) {
setPolicy(response.data);
}
} catch (err: any) {
showToast.error(err?.response?.data?.message || "Failed to load security policy");
} finally {
setIsLoading(false);
}
};
fetchPolicy();
}, []);
const handleToggle = (section: keyof PasswordPolicy, field: string) => {
if (!policy) return;
const sectionData = (policy[section] as any);
setPolicy({
...policy,
[section]: {
...sectionData,
[field]: !sectionData[field]
}
});
};
const handleInputChange = (section: keyof PasswordPolicy, field: string, value: string) => {
if (!policy) return;
const numValue = parseInt(value) || 0;
const sectionData = (policy[section] as any);
setPolicy({
...policy,
[section]: {
...sectionData,
[field]: numValue
}
});
};
const handleSave = async () => {
if (!policy) return;
try {
setIsSaving(true);
const response = await securityService.updatePasswordPolicy({
min_length: policy.complexity.min_length,
require_uppercase: policy.complexity.require_uppercase,
require_lowercase: policy.complexity.require_lowercase,
require_numbers: policy.complexity.require_numbers,
require_special_chars: policy.complexity.require_special_chars,
password_expiry_days: policy.expiry.password_expiry_days,
password_history_count: policy.history.password_history_count,
max_failed_attempts: policy.lockout.max_failed_attempts,
lockout_duration_minutes: policy.lockout.lockout_duration_minutes,
});
if (response.success) {
showToast.success("Security policy updated successfully");
setPolicy(response.data);
}
} catch (err: any) {
showToast.error(err?.response?.data?.message || "Failed to update security policy");
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<Layout currentPage="Security Policy">
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 text-[#112868] animate-spin" />
</div>
</Layout>
);
}
return (
<Layout
currentPage="Security Policy"
pageHeader={{
title: "Tenant Security Policy",
description: "Configure password complexity, expiry, and account protection rules.",
}}
>
<div className="max-w-4xl space-y-6">
{/* Password Complexity Card */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200 flex items-center gap-3">
<Shield className="w-5 h-5 text-[#112868]" />
<h3 className="font-semibold text-slate-800 text-lg">Password Complexity</h3>
</div>
<div className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="Minimum Password Length"
type="number"
min={6}
max={32}
value={policy?.complexity.min_length || 8}
onChange={(e) => handleInputChange("complexity", "min_length", e.target.value)}
helperText="Minimum number of characters required for passwords."
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
<PolicyToggle
label="Require Uppercase"
description="At least one uppercase letter (A-Z)"
enabled={!!policy?.complexity.require_uppercase}
onToggle={() => handleToggle("complexity", "require_uppercase")}
/>
<PolicyToggle
label="Require Lowercase"
description="At least one lowercase letter (a-z)"
enabled={!!policy?.complexity.require_lowercase}
onToggle={() => handleToggle("complexity", "require_lowercase")}
/>
<PolicyToggle
label="Require Numbers"
description="At least one numeric digit (0-9)"
enabled={!!policy?.complexity.require_numbers}
onToggle={() => handleToggle("complexity", "require_numbers")}
/>
<PolicyToggle
label="Require Special Characters"
description="At least one special character (!@#$%^&*)"
enabled={!!policy?.complexity.require_special_chars}
onToggle={() => handleToggle("complexity", "require_special_chars")}
/>
</div>
</div>
</div>
{/* Password Lifecycle Card */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200 flex items-center gap-3">
<Clock className="w-5 h-5 text-[#112868]" />
<h3 className="font-semibold text-slate-800 text-lg">Password Lifecycle</h3>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="Password Expiry (Days)"
type="number"
min={0}
max={365}
value={policy?.expiry.password_expiry_days || 90}
onChange={(e) => handleInputChange("expiry", "password_expiry_days", e.target.value)}
helperText="Force password reset after these many days. Set to 0 to disable."
/>
<FormField
label="Password History"
type="number"
min={0}
max={24}
value={policy?.history.password_history_count || 5}
onChange={(e) => handleInputChange("history", "password_history_count", e.target.value)}
helperText="Number of previous passwords that cannot be reused."
/>
</div>
</div>
{/* Account Protection Card */}
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200 flex items-center gap-3">
<Lock className="w-5 h-5 text-[#112868]" />
<h3 className="font-semibold text-slate-800 text-lg">Account Protection</h3>
</div>
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="Max Login Attempts"
type="number"
min={1}
max={20}
value={policy?.lockout.max_failed_attempts || 5}
onChange={(e) => handleInputChange("lockout", "max_failed_attempts", e.target.value)}
helperText="Number of failed attempts before account is locked."
/>
<FormField
label="Lockout Duration (Minutes)"
type="number"
min={1}
max={1440}
value={policy?.lockout.lockout_duration_minutes || 30}
onChange={(e) => handleInputChange("lockout", "lockout_duration_minutes", e.target.value)}
helperText="Time to wait before account is automatically unlocked."
/>
</div>
</div>
{/* Action Footer */}
<div className="flex items-center justify-between pt-4 bg-slate-50/50 p-6 rounded-xl border border-slate-200">
<div className="flex items-center gap-2 text-sm text-slate-500">
<AlertCircle className="w-4 h-4" />
<span>Changes will apply to all users upon their next password change.</span>
</div>
<PrimaryButton
onClick={handleSave}
disabled={isSaving}
className="px-8 flex items-center gap-2"
>
{isSaving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{isSaving ? "Saving..." : "Save Policy"}
</PrimaryButton>
</div>
</div>
</Layout>
);
};
interface PolicyToggleProps {
label: string;
description: string;
enabled: boolean;
onToggle: () => void;
}
const PolicyToggle = ({ label, description, enabled, onToggle }: PolicyToggleProps) => (
<button
type="button"
onClick={onToggle}
className={`flex items-start gap-4 p-4 rounded-lg border transition-all text-left group ${
enabled
? "bg-emerald-50/50 border-emerald-200 ring-1 ring-emerald-200"
: "bg-white border-slate-200 hover:border-slate-300"
}`}
>
<div className={`mt-0.5 rounded-full p-1 transition-colors ${
enabled ? "bg-emerald-500 text-white" : "bg-slate-100 text-slate-400"
}`}>
<CheckCircle2 className="w-4 h-4" />
</div>
<div className="flex-1">
<h4 className={`text-sm font-semibold transition-colors ${
enabled ? "text-emerald-900" : "text-slate-800"
}`}>
{label}
</h4>
<p className="text-xs text-slate-500 mt-0.5">
{description}
</p>
</div>
</button>
);
export default SecurityPolicy;

View File

@ -90,6 +90,14 @@ const TenantLogin = (): ReactElement => {
try {
const result = await dispatch(loginAsync(data)).unwrap();
if (result) {
// Check if password reset is required
if (result.data.requirePasswordReset) {
const { tempToken, resetReason } = result.data;
showToast.info('Password reset required', 'For security reasons, please update your password.');
navigate(`/reset-password?token=${tempToken}&reason=${resetReason}`);
return;
}
const message = result.message || 'Login successful';
showToast.success(message);

View File

@ -58,6 +58,7 @@ const TenantAIProviderCreate = lazy(
() => import("@/pages/tenant/TenantAIProviderCreate"),
);
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
const SecurityPolicy = lazy(() => import("@/pages/tenant/SecurityPolicy"));
// Loading fallback component
const RouteLoader = (): ReactElement => (
@ -244,6 +245,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/ai/dashboard",
element: <LazyRoute component={TenantAIDashboard} />,
},
{
path: "/tenant/settings/security-policy",
element: <LazyRoute component={SecurityPolicy} />,
},
// {
// path: "/tenant/ai/knowledge",
// element: <LazyRoute component={AIGateway} />,

View File

@ -41,6 +41,10 @@ export interface LoginResponse {
token_type: string;
expires_in: number;
expires_at: string;
// Password reset fields
requirePasswordReset?: boolean;
resetReason?: 'FIRST_LOGIN' | 'EXPIRED';
tempToken?: string;
};
message?: string;
}

View File

@ -0,0 +1,58 @@
import apiClient from './api-client';
export interface PasswordPolicy {
id: string;
tenant_id: string;
complexity: {
min_length: number;
max_length: number;
require_uppercase: boolean;
require_lowercase: boolean;
require_numbers: boolean;
require_special_chars: boolean;
special_chars_allowed: string;
};
history: {
password_history_count: number;
};
expiry: {
password_expiry_days: number;
password_expiry_warning_days: number;
};
lockout: {
max_failed_attempts: number;
lockout_duration_minutes: number;
};
updated_at: string;
}
export interface PasswordPolicyResponse {
success: boolean;
data: PasswordPolicy;
}
export interface UpdatePasswordPolicyRequest {
min_length?: number;
max_length?: number;
require_uppercase?: boolean;
require_lowercase?: boolean;
require_numbers?: boolean;
require_special_chars?: boolean;
special_chars_allowed?: string;
password_history_count?: number;
password_expiry_days?: number;
password_expiry_warning_days?: number;
max_failed_attempts?: number;
lockout_duration_minutes?: number;
}
export const securityService = {
getPasswordPolicy: async (): Promise<PasswordPolicyResponse> => {
const response = await apiClient.get<PasswordPolicyResponse>('/security/password-policy');
return response.data;
},
updatePasswordPolicy: async (data: UpdatePasswordPolicyRequest): Promise<PasswordPolicyResponse> => {
const response = await apiClient.put<PasswordPolicyResponse>('/security/password-policy', data);
return response.data;
},
};

View File

@ -132,6 +132,16 @@ const authSlice = createSlice({
})
.addCase(loginAsync.fulfilled, (state, action: PayloadAction<{ data: LoginResponse['data']; message?: string }>) => {
state.isLoading = false;
// If password reset is required, don't set isAuthenticated yet
if (action.payload.data.requirePasswordReset) {
state.isAuthenticated = false;
state.user = action.payload.data.user;
state.tenantId = action.payload.data.tenant_id;
state.error = null;
return;
}
state.user = action.payload.data.user;
state.tenantId = action.payload.data.tenant_id;
// state.tenant = action.payload.data.tenant;

View File

@ -68,7 +68,7 @@ export interface UsersResponse {
export interface CreateUserRequest {
email: string;
password: string;
password?: string;
first_name: string;
last_name: string;
status: 'active' | 'suspended' | 'deleted';