diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index d73d086..61628ab 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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", diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index 2916cc3..7133342 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -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; -type CreateUserPayload = Omit & { +type CreateUserPayload = Omit & { 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 => { 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 = ({ -
- - -
setValue("department_id", value, { shouldValidate: true })} onLoadOptions={loadDepartments} error={errors.department_id?.message} /> setValue("designation_id", value, { shouldValidate: true })} onLoadOptions={loadDesignations} error={errors.designation_id?.message} /> diff --git a/src/components/superadmin/UsersTable.tsx b/src/components/superadmin/UsersTable.tsx index 5a5f64b..8daadce 100644 --- a/src/components/superadmin/UsersTable.tsx +++ b/src/components/superadmin/UsersTable.tsx @@ -190,7 +190,7 @@ export const UsersTable = forwardRef(({ const handleCreateUser = async (data: { email: string; - password: string; + password?: string; first_name: string; last_name: string; status: "active" | "suspended" | "deleted"; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 713876a..ba546ff 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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); diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx index ab186da..57c016b 100644 --- a/src/pages/ResetPassword.tsx +++ b/src/pages/ResetPassword.tsx @@ -35,6 +35,7 @@ const ResetPassword = (): ReactElement => { const [success, setSuccess] = useState(''); 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 => {

- Reset Password + {reason === 'FIRST_LOGIN' ? 'Set Initial Password' : + reason === 'EXPIRED' ? 'Update Expired Password' : + 'Reset Password'}

- {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'}

@@ -229,7 +233,7 @@ const ResetPassword = (): ReactElement => { )} {/* Back to Login Link */} - {!success && ( + {!success && !reason && (
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")} />
- {/* Password and Confirm Password Row */} -
- - -
{/* Contact Phone */}
{ 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 || "", diff --git a/src/pages/tenant/SecurityPolicy.tsx b/src/pages/tenant/SecurityPolicy.tsx new file mode 100644 index 0000000..2e31c53 --- /dev/null +++ b/src/pages/tenant/SecurityPolicy.tsx @@ -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(true); + const [isSaving, setIsSaving] = useState(false); + const [policy, setPolicy] = useState(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 ( + +
+ +
+
+ ); + } + + return ( + +
+ {/* Password Complexity Card */} +
+
+ +

Password Complexity

+
+
+
+ handleInputChange("complexity", "min_length", e.target.value)} + helperText="Minimum number of characters required for passwords." + /> +
+ +
+ handleToggle("complexity", "require_uppercase")} + /> + handleToggle("complexity", "require_lowercase")} + /> + handleToggle("complexity", "require_numbers")} + /> + handleToggle("complexity", "require_special_chars")} + /> +
+
+
+ + {/* Password Lifecycle Card */} +
+
+ +

Password Lifecycle

+
+
+ handleInputChange("expiry", "password_expiry_days", e.target.value)} + helperText="Force password reset after these many days. Set to 0 to disable." + /> + handleInputChange("history", "password_history_count", e.target.value)} + helperText="Number of previous passwords that cannot be reused." + /> +
+
+ + {/* Account Protection Card */} +
+
+ +

Account Protection

+
+
+ handleInputChange("lockout", "max_failed_attempts", e.target.value)} + helperText="Number of failed attempts before account is locked." + /> + handleInputChange("lockout", "lockout_duration_minutes", e.target.value)} + helperText="Time to wait before account is automatically unlocked." + /> +
+
+ + {/* Action Footer */} +
+
+ + Changes will apply to all users upon their next password change. +
+ + {isSaving ? ( + + ) : ( + + )} + {isSaving ? "Saving..." : "Save Policy"} + +
+
+
+ ); +}; + +interface PolicyToggleProps { + label: string; + description: string; + enabled: boolean; + onToggle: () => void; +} + +const PolicyToggle = ({ label, description, enabled, onToggle }: PolicyToggleProps) => ( + +); + +export default SecurityPolicy; diff --git a/src/pages/tenant/TenantLogin.tsx b/src/pages/tenant/TenantLogin.tsx index 160cee6..dc073d9 100644 --- a/src/pages/tenant/TenantLogin.tsx +++ b/src/pages/tenant/TenantLogin.tsx @@ -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); diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index 11251ee..2c59c0c 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -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: , }, + { + path: "/tenant/settings/security-policy", + element: , + }, // { // path: "/tenant/ai/knowledge", // element: , diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 0742fec..77b6fde 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -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; } diff --git a/src/services/security-service.ts b/src/services/security-service.ts new file mode 100644 index 0000000..c87a421 --- /dev/null +++ b/src/services/security-service.ts @@ -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 => { + const response = await apiClient.get('/security/password-policy'); + return response.data; + }, + updatePasswordPolicy: async (data: UpdatePasswordPolicyRequest): Promise => { + const response = await apiClient.put('/security/password-policy', data); + return response.data; + }, +}; diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index 1fbf76f..ceac6dd 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -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; diff --git a/src/types/user.ts b/src/types/user.ts index 352ef2b..530a6eb 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -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';