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",
|
||||
path: "/tenant/settings",
|
||||
},
|
||||
{
|
||||
label: "Security Policy",
|
||||
path: "/tenant/settings/security-policy",
|
||||
},
|
||||
{
|
||||
label: "Notification Settings",
|
||||
path: "/tenant/settings/notifications",
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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="/"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 || "",
|
||||
|
||||
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 {
|
||||
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);
|
||||
|
||||
|
||||
@ -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} />,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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 }>) => {
|
||||
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;
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user