feat: implement password complexity policies and mandatory reset logic with new security service integration
This commit is contained in:
parent
eaf0568a64
commit
950cfe9f83
@ -243,6 +243,10 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
|||||||
label: "General Settings",
|
label: "General Settings",
|
||||||
path: "/tenant/settings",
|
path: "/tenant/settings",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Security Policy",
|
||||||
|
path: "/tenant/settings/security-policy",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Notification Settings",
|
label: "Notification Settings",
|
||||||
path: "/tenant/settings/notifications",
|
path: "/tenant/settings/notifications",
|
||||||
|
|||||||
@ -28,8 +28,6 @@ const assignmentSchema = z.object({
|
|||||||
const newUserSchema = z
|
const newUserSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.email({ message: "Please enter a valid email address" }),
|
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"),
|
first_name: z.string().min(1, "First name is required"),
|
||||||
last_name: z.string().min(1, "Last name is required"),
|
last_name: z.string().min(1, "Last name is required"),
|
||||||
status: z.enum(["active", "suspended", "deleted"], { message: "Status 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"),
|
designation_id: z.string().min(1, "Designation is required"),
|
||||||
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
|
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
|
||||||
supplier_id: z.string().optional().nullable(),
|
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 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 }[];
|
role_module_combinations: { role_id: string; module_id: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -116,8 +110,6 @@ export const NewUserModal = ({
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
reset({
|
reset({
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
first_name: "",
|
first_name: "",
|
||||||
last_name: "",
|
last_name: "",
|
||||||
status: "active",
|
status: "active",
|
||||||
@ -179,7 +171,7 @@ export const NewUserModal = ({
|
|||||||
const handleFormSubmit = async (data: NewUserFormData): Promise<void> => {
|
const handleFormSubmit = async (data: NewUserFormData): Promise<void> => {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
try {
|
try {
|
||||||
const { confirmPassword, role_module_assignments, ...submitData } = data;
|
const { role_module_assignments, ...submitData } = data;
|
||||||
const role_module_combinations = role_module_assignments.flatMap((row) =>
|
const role_module_combinations = role_module_assignments.flatMap((row) =>
|
||||||
row.module_ids.map((module_id) => ({ role_id: row.role_id, module_id })),
|
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="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")} />
|
<FormField label="Last Name" required placeholder="Enter last name" error={errors.last_name?.message} {...register("last_name")} />
|
||||||
</div>
|
</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">
|
<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="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} />
|
<PaginatedSelect label="Designation" required placeholder="Select Designation" value={designationIdValue || ""} onValueChange={(value) => setValue("designation_id", value, { shouldValidate: true })} onLoadOptions={loadDesignations} error={errors.designation_id?.message} />
|
||||||
|
|||||||
@ -190,7 +190,7 @@ export const UsersTable = forwardRef<UsersTableRef, UsersTableProps>(({
|
|||||||
|
|
||||||
const handleCreateUser = async (data: {
|
const handleCreateUser = async (data: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password?: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
status: "active" | "suspended" | "deleted";
|
status: "active" | "suspended" | "deleted";
|
||||||
|
|||||||
@ -86,6 +86,14 @@ const Login = (): ReactElement => {
|
|||||||
try {
|
try {
|
||||||
const result = await dispatch(loginAsync(data)).unwrap();
|
const result = await dispatch(loginAsync(data)).unwrap();
|
||||||
if (result) {
|
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 message = result.message || 'Login successful';
|
||||||
const description = result.message ? undefined : 'Welcome back!';
|
const description = result.message ? undefined : 'Welcome back!';
|
||||||
showToast.success(message, description);
|
showToast.success(message, description);
|
||||||
|
|||||||
@ -35,6 +35,7 @@ const ResetPassword = (): ReactElement => {
|
|||||||
const [success, setSuccess] = useState<string>('');
|
const [success, setSuccess] = useState<string>('');
|
||||||
|
|
||||||
const tokenFromUrl = searchParams.get('token') || '';
|
const tokenFromUrl = searchParams.get('token') || '';
|
||||||
|
const reason = searchParams.get('reason') || '';
|
||||||
const hasTokenFromUrl = Boolean(tokenFromUrl);
|
const hasTokenFromUrl = Boolean(tokenFromUrl);
|
||||||
|
|
||||||
const resetPasswordSchema = createResetPasswordSchema(hasTokenFromUrl);
|
const resetPasswordSchema = createResetPasswordSchema(hasTokenFromUrl);
|
||||||
@ -136,12 +137,15 @@ const ResetPassword = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-[#0f1724] mb-2">
|
<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>
|
</h1>
|
||||||
<p className="text-sm md:text-base text-[#6b7280]">
|
<p className="text-sm md:text-base text-[#6b7280]">
|
||||||
{tokenFromUrl
|
{reason === 'FIRST_LOGIN' ? 'Welcome to QAssure! Please set a new password for your account to continue.' :
|
||||||
? 'Enter your new password to reset'
|
reason === 'EXPIRED' ? 'Your password has expired. For security, please choose a new password.' :
|
||||||
: 'Enter your reset token and new password'}
|
tokenFromUrl ? 'Enter your new password to reset' :
|
||||||
|
'Enter your reset token and new password'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -229,7 +233,7 @@ const ResetPassword = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Back to Login Link */}
|
{/* Back to Login Link */}
|
||||||
{!success && (
|
{!success && !reason && (
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
|
|||||||
@ -62,11 +62,6 @@ const tenantDetailsSchema = z.object({
|
|||||||
const contactDetailsSchema = z
|
const contactDetailsSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.email({ message: "Please enter a valid email address" }),
|
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"),
|
first_name: z.string().min(1, "First name is required"),
|
||||||
last_name: z.string().min(1, "Last name is required"),
|
last_name: z.string().min(1, "Last name is required"),
|
||||||
contact_phone: z
|
contact_phone: z
|
||||||
@ -90,10 +85,6 @@ const contactDetailsSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.regex(/^[1-9]\d{5}$/, "Postal code must be a valid 6-digit PIN code"),
|
.regex(/^[1-9]\d{5}$/, "Postal code must be a valid 6-digit PIN code"),
|
||||||
country: z.string().min(1, "Country is required"),
|
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
|
// Step 3: Settings Schema
|
||||||
@ -171,8 +162,6 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
first_name: "",
|
first_name: "",
|
||||||
last_name: "",
|
last_name: "",
|
||||||
contact_phone: "",
|
contact_phone: "",
|
||||||
@ -414,8 +403,7 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
|
|
||||||
// Combine all data for tenant creation - matches NewTenantModal structure
|
// Combine all data for tenant creation - matches NewTenantModal structure
|
||||||
const { modules, ...restTenantDetails } = tenantDetails;
|
const { modules, ...restTenantDetails } = tenantDetails;
|
||||||
// Extract confirmPassword from contactDetails (not needed in API call)
|
const contactData = contactDetails;
|
||||||
const { confirmPassword, ...contactData } = contactDetails;
|
|
||||||
|
|
||||||
// Extract branding colors from settings
|
// Extract branding colors from settings
|
||||||
const {
|
const {
|
||||||
@ -475,10 +463,6 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
// Contact details errors from nested path
|
// Contact details errors from nested path
|
||||||
hasContactErrors = true;
|
hasContactErrors = true;
|
||||||
const fieldName = path.replace("settings.contact.", "");
|
const fieldName = path.replace("settings.contact.", "");
|
||||||
if (fieldName === "confirmPassword") {
|
|
||||||
// Skip confirmPassword as it's not in the form schema
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
contactDetailsForm.setError(
|
contactDetailsForm.setError(
|
||||||
fieldName as keyof ContactDetailsForm,
|
fieldName as keyof ContactDetailsForm,
|
||||||
{
|
{
|
||||||
@ -894,30 +878,6 @@ const CreateTenantWizard = (): ReactElement => {
|
|||||||
{...contactDetailsForm.register("last_name")}
|
{...contactDetailsForm.register("last_name")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Contact Phone */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@ -66,7 +66,8 @@ const tenantDetailsSchema = z.object({
|
|||||||
|
|
||||||
// Step 2: Contact Details Schema - NO password fields
|
// Step 2: Contact Details Schema - NO password fields
|
||||||
const contactDetailsSchema = z.object({
|
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"),
|
first_name: z.string().min(1, "First name is required"),
|
||||||
last_name: z.string().min(1, "Last name is required"),
|
last_name: z.string().min(1, "Last name is required"),
|
||||||
contact_phone: z
|
contact_phone: z
|
||||||
@ -178,6 +179,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
reValidateMode: "onChange",
|
reValidateMode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
id: "",
|
||||||
email: "",
|
email: "",
|
||||||
first_name: "",
|
first_name: "",
|
||||||
last_name: "",
|
last_name: "",
|
||||||
@ -354,6 +356,7 @@ const EditTenant = (): ReactElement => {
|
|||||||
previousNameRef.current = tenant.name;
|
previousNameRef.current = tenant.name;
|
||||||
|
|
||||||
contactDetailsForm.reset({
|
contactDetailsForm.reset({
|
||||||
|
id: contactInfo.id || "",
|
||||||
email: contactInfo.email || "",
|
email: contactInfo.email || "",
|
||||||
first_name: contactInfo.first_name || "",
|
first_name: contactInfo.first_name || "",
|
||||||
last_name: contactInfo.last_name || "",
|
last_name: contactInfo.last_name || "",
|
||||||
|
|||||||
274
src/pages/tenant/SecurityPolicy.tsx
Normal file
274
src/pages/tenant/SecurityPolicy.tsx
Normal 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;
|
||||||
@ -90,6 +90,14 @@ const TenantLogin = (): ReactElement => {
|
|||||||
try {
|
try {
|
||||||
const result = await dispatch(loginAsync(data)).unwrap();
|
const result = await dispatch(loginAsync(data)).unwrap();
|
||||||
if (result) {
|
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 message = result.message || 'Login successful';
|
||||||
showToast.success(message);
|
showToast.success(message);
|
||||||
|
|
||||||
|
|||||||
@ -58,6 +58,7 @@ const TenantAIProviderCreate = lazy(
|
|||||||
() => import("@/pages/tenant/TenantAIProviderCreate"),
|
() => import("@/pages/tenant/TenantAIProviderCreate"),
|
||||||
);
|
);
|
||||||
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
|
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
|
||||||
|
const SecurityPolicy = lazy(() => import("@/pages/tenant/SecurityPolicy"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -244,6 +245,10 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/ai/dashboard",
|
path: "/tenant/ai/dashboard",
|
||||||
element: <LazyRoute component={TenantAIDashboard} />,
|
element: <LazyRoute component={TenantAIDashboard} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/settings/security-policy",
|
||||||
|
element: <LazyRoute component={SecurityPolicy} />,
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// path: "/tenant/ai/knowledge",
|
// path: "/tenant/ai/knowledge",
|
||||||
// element: <LazyRoute component={AIGateway} />,
|
// element: <LazyRoute component={AIGateway} />,
|
||||||
|
|||||||
@ -41,6 +41,10 @@ export interface LoginResponse {
|
|||||||
token_type: string;
|
token_type: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
// Password reset fields
|
||||||
|
requirePasswordReset?: boolean;
|
||||||
|
resetReason?: 'FIRST_LOGIN' | 'EXPIRED';
|
||||||
|
tempToken?: string;
|
||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/services/security-service.ts
Normal file
58
src/services/security-service.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -132,6 +132,16 @@ const authSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(loginAsync.fulfilled, (state, action: PayloadAction<{ data: LoginResponse['data']; message?: string }>) => {
|
.addCase(loginAsync.fulfilled, (state, action: PayloadAction<{ data: LoginResponse['data']; message?: string }>) => {
|
||||||
state.isLoading = false;
|
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.user = action.payload.data.user;
|
||||||
state.tenantId = action.payload.data.tenant_id;
|
state.tenantId = action.payload.data.tenant_id;
|
||||||
// state.tenant = action.payload.data.tenant;
|
// state.tenant = action.payload.data.tenant;
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export interface UsersResponse {
|
|||||||
|
|
||||||
export interface CreateUserRequest {
|
export interface CreateUserRequest {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password?: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
status: 'active' | 'suspended' | 'deleted';
|
status: 'active' | 'suspended' | 'deleted';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user