Refactor environment configuration for local development, add Sonner for toast notifications, and enhance user feedback across various components. Update API response handling in authentication and management services to include message properties. Improve form components with password visibility toggle and initial option handling in dropdowns.
This commit is contained in:
parent
f4f1225f02
commit
b0d720c821
4
.env
4
.env
@ -1,2 +1,2 @@
|
||||
# VITE_API_BASE_URL=http://localhost:3000/api/v1
|
||||
VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1
|
||||
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
||||
# VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/api/v1
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -23,6 +23,7 @@
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^3.6.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.3.5"
|
||||
@ -4244,6 +4245,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^3.6.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Tenants from "./pages/Tenants";
|
||||
@ -14,6 +15,7 @@ import ResetPassword from "./pages/ResetPassword";
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Toaster position="top-right" richColors />
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
|
||||
@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||
import { logoutAsync } from '@/store/authSlice';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { showToast } from '@/utils/toast';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
@ -70,14 +71,21 @@ export const Header = ({ currentPage, onMenuClick }: HeaderProps): ReactElement
|
||||
|
||||
try {
|
||||
// Call logout API with Bearer token
|
||||
await dispatch(logoutAsync()).unwrap();
|
||||
const result = await dispatch(logoutAsync()).unwrap();
|
||||
const message = result.message || 'Logged out successfully';
|
||||
const description = result.message ? undefined : 'You have been logged out';
|
||||
showToast.success(message, description);
|
||||
// Clear state and redirect
|
||||
navigate('/', { replace: true });
|
||||
} catch (error: any) {
|
||||
// Even if API call fails, clear local state and redirect to login
|
||||
console.error('Logout error:', error);
|
||||
// Try to get message from error response
|
||||
const message = error?.message || 'Logged out successfully';
|
||||
const description = error?.message ? undefined : 'You have been logged out';
|
||||
// Dispatch logout action to clear local state
|
||||
dispatch({ type: 'auth/logout' });
|
||||
showToast.success(message, description);
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
@ -48,7 +48,7 @@ export const ActionDropdown = ({
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors',
|
||||
'flex items-center justify-center w-7 h-7 rounded-full border border-[rgba(0,0,0,0.08)] transition-colors cursor-pointer',
|
||||
isOpen
|
||||
? 'bg-[#084cc8] text-white'
|
||||
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
|
||||
@ -66,7 +66,7 @@ export const ActionDropdown = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(onView)}
|
||||
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>View</span>
|
||||
@ -76,7 +76,7 @@ export const ActionDropdown = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(onEdit)}
|
||||
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center gap-2.5 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
<span>Edit</span>
|
||||
@ -86,7 +86,7 @@ export const ActionDropdown = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction(onDelete)}
|
||||
className="flex items-center gap-2 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center gap-2 px-2 py-1 text-[11px] font-medium text-[#6b7280] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span>Delete</span>
|
||||
|
||||
@ -75,6 +75,13 @@ export const EditUserModal = ({
|
||||
const tenantIdValue = watch('tenant_id');
|
||||
const roleIdValue = watch('role_id');
|
||||
|
||||
// Store tenant and role names from user response
|
||||
const [currentTenantName, setCurrentTenantName] = useState<string>('');
|
||||
const [currentRoleName, setCurrentRoleName] = useState<string>('');
|
||||
// Store initial options for immediate display
|
||||
const [initialTenantOption, setInitialTenantOption] = useState<{ value: string; label: string } | null>(null);
|
||||
const [initialRoleOption, setInitialRoleOption] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
// Load tenants for dropdown - ensure selected tenant is included
|
||||
const loadTenants = async (page: number, limit: number) => {
|
||||
const response = await tenantService.getAll(page, limit);
|
||||
@ -83,23 +90,45 @@ export const EditUserModal = ({
|
||||
label: tenant.name,
|
||||
}));
|
||||
|
||||
// If we have a selected tenant ID and it's not in the current options, fetch it specifically
|
||||
if (selectedTenantId && page === 1 && !options.find((opt) => opt.value === selectedTenantId)) {
|
||||
try {
|
||||
const tenantResponse = await tenantService.getById(selectedTenantId);
|
||||
if (tenantResponse.success) {
|
||||
// Prepend the selected tenant to the options
|
||||
// Always include initial option if it exists and matches the selected value
|
||||
if (initialTenantOption && page === 1) {
|
||||
const exists = options.find((opt) => opt.value === initialTenantOption.value);
|
||||
if (!exists) {
|
||||
options = [initialTenantOption, ...options];
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a selected tenant ID and it's not in the current options, add it with stored name
|
||||
if (selectedTenantId && page === 1 && !initialTenantOption) {
|
||||
const existingOption = options.find((opt) => opt.value === selectedTenantId);
|
||||
if (!existingOption) {
|
||||
// If we have the name from user response, use it; otherwise fetch
|
||||
if (currentTenantName) {
|
||||
options = [
|
||||
{
|
||||
value: tenantResponse.data.id,
|
||||
label: tenantResponse.data.name,
|
||||
value: selectedTenantId,
|
||||
label: currentTenantName,
|
||||
},
|
||||
...options,
|
||||
];
|
||||
} else {
|
||||
try {
|
||||
const tenantResponse = await tenantService.getById(selectedTenantId);
|
||||
if (tenantResponse.success) {
|
||||
// Prepend the selected tenant to the options
|
||||
options = [
|
||||
{
|
||||
value: tenantResponse.data.id,
|
||||
label: tenantResponse.data.name,
|
||||
},
|
||||
...options,
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
// If fetching fails, just continue with existing options
|
||||
console.warn('Failed to fetch selected tenant:', err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If fetching fails, just continue with existing options
|
||||
console.warn('Failed to fetch selected tenant:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,23 +146,45 @@ export const EditUserModal = ({
|
||||
label: role.name,
|
||||
}));
|
||||
|
||||
// If we have a selected role ID and it's not in the current options, fetch it specifically
|
||||
if (selectedRoleId && page === 1 && !options.find((opt) => opt.value === selectedRoleId)) {
|
||||
try {
|
||||
const roleResponse = await roleService.getById(selectedRoleId);
|
||||
if (roleResponse.success) {
|
||||
// Prepend the selected role to the options
|
||||
// Always include initial option if it exists and matches the selected value
|
||||
if (initialRoleOption && page === 1) {
|
||||
const exists = options.find((opt) => opt.value === initialRoleOption.value);
|
||||
if (!exists) {
|
||||
options = [initialRoleOption, ...options];
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a selected role ID and it's not in the current options, add it with stored name
|
||||
if (selectedRoleId && page === 1 && !initialRoleOption) {
|
||||
const existingOption = options.find((opt) => opt.value === selectedRoleId);
|
||||
if (!existingOption) {
|
||||
// If we have the name from user response, use it; otherwise fetch
|
||||
if (currentRoleName) {
|
||||
options = [
|
||||
{
|
||||
value: roleResponse.data.id,
|
||||
label: roleResponse.data.name,
|
||||
value: selectedRoleId,
|
||||
label: currentRoleName,
|
||||
},
|
||||
...options,
|
||||
];
|
||||
} else {
|
||||
try {
|
||||
const roleResponse = await roleService.getById(selectedRoleId);
|
||||
if (roleResponse.success) {
|
||||
// Prepend the selected role to the options
|
||||
options = [
|
||||
{
|
||||
value: roleResponse.data.id,
|
||||
label: roleResponse.data.name,
|
||||
},
|
||||
...options,
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
// If fetching fails, just continue with existing options
|
||||
console.warn('Failed to fetch selected role:', err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If fetching fails, just continue with existing options
|
||||
console.warn('Failed to fetch selected role:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,11 +204,24 @@ export const EditUserModal = ({
|
||||
clearErrors();
|
||||
const user = await onLoadUser(userId);
|
||||
|
||||
// Store selected IDs for dropdown pre-loading
|
||||
const tenantId = user.tenant_id || '';
|
||||
const roleId = user.role_id || '';
|
||||
// Extract tenant and role IDs from nested objects or fallback to direct properties
|
||||
const tenantId = user.tenant?.id || user.tenant_id || '';
|
||||
const roleId = user.role?.id || user.role_id || '';
|
||||
const tenantName = user.tenant?.name || '';
|
||||
const roleName = user.role?.name || '';
|
||||
|
||||
setSelectedTenantId(tenantId);
|
||||
setSelectedRoleId(roleId);
|
||||
setCurrentTenantName(tenantName);
|
||||
setCurrentRoleName(roleName);
|
||||
|
||||
// Set initial options for immediate display using names from user response
|
||||
if (tenantId && tenantName) {
|
||||
setInitialTenantOption({ value: tenantId, label: tenantName });
|
||||
}
|
||||
if (roleId && roleName) {
|
||||
setInitialRoleOption({ value: roleId, label: roleName });
|
||||
}
|
||||
|
||||
reset({
|
||||
email: user.email,
|
||||
@ -177,6 +241,10 @@ export const EditUserModal = ({
|
||||
} else {
|
||||
setSelectedTenantId('');
|
||||
setSelectedRoleId('');
|
||||
setCurrentTenantName('');
|
||||
setCurrentRoleName('');
|
||||
setInitialTenantOption(null);
|
||||
setInitialRoleOption(null);
|
||||
reset({
|
||||
email: '',
|
||||
first_name: '',
|
||||
@ -320,6 +388,7 @@ export const EditUserModal = ({
|
||||
value={tenantIdValue || ''}
|
||||
onValueChange={(value) => setValue('tenant_id', value)}
|
||||
onLoadOptions={loadTenants}
|
||||
initialOption={initialTenantOption || undefined}
|
||||
error={errors.tenant_id?.message}
|
||||
/>
|
||||
|
||||
@ -330,6 +399,7 @@ export const EditUserModal = ({
|
||||
value={roleIdValue || ''}
|
||||
onValueChange={(value) => setValue('role_id', value)}
|
||||
onLoadOptions={loadRoles}
|
||||
initialOption={initialRoleOption || undefined}
|
||||
error={errors.role_id?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactElement, InputHTMLAttributes } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
@ -15,10 +17,13 @@ export const FormField = ({
|
||||
helperText,
|
||||
className,
|
||||
id,
|
||||
type,
|
||||
...props
|
||||
}: FormFieldProps): ReactElement => {
|
||||
const fieldId = id || `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const hasError = Boolean(error);
|
||||
const isPassword = type === 'password';
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
@ -29,21 +34,41 @@ export const FormField = ({
|
||||
<span>{label}</span>
|
||||
{required && <span className="text-[#e02424]">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={fieldId}
|
||||
className={cn(
|
||||
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
||||
'placeholder:text-[#9aa6b2] text-[#0e1b2a]',
|
||||
hasError
|
||||
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
||||
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
||||
'focus-visible:outline-none focus-visible:ring-2',
|
||||
className
|
||||
<div className="relative">
|
||||
<input
|
||||
id={fieldId}
|
||||
type={isPassword && showPassword ? 'text' : type}
|
||||
className={cn(
|
||||
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
||||
'placeholder:text-[#9aa6b2] text-[#0e1b2a]',
|
||||
hasError
|
||||
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
||||
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
||||
'focus-visible:outline-none focus-visible:ring-2',
|
||||
props.disabled && 'bg-[#f3f4f6] cursor-not-allowed opacity-60',
|
||||
isPassword && 'pr-10',
|
||||
className
|
||||
)}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${fieldId}-error` : helperText ? `${fieldId}-helper` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#6b7280] hover:text-[#0e1b2a] transition-colors focus:outline-none focus:ring-2 focus:ring-[#112868]/20 rounded p-1"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={error ? `${fieldId}-error` : helperText ? `${fieldId}-helper` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
|
||||
{error}
|
||||
|
||||
@ -27,6 +27,7 @@ interface PaginatedSelectProps {
|
||||
hasMore: boolean;
|
||||
};
|
||||
}>;
|
||||
initialOption?: { value: string; label: string };
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
@ -40,6 +41,7 @@ export const PaginatedSelect = ({
|
||||
value,
|
||||
onValueChange,
|
||||
onLoadOptions,
|
||||
initialOption,
|
||||
className,
|
||||
id,
|
||||
}: PaginatedSelectProps): ReactElement => {
|
||||
@ -181,7 +183,8 @@ export const PaginatedSelect = ({
|
||||
|
||||
const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const hasError = Boolean(error);
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
const selectedOption = options.find((opt) => opt.value === value) ||
|
||||
(initialOption && initialOption.value === value ? initialOption : null);
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onValueChange(optionValue);
|
||||
|
||||
@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const primaryButtonVariants = cva(
|
||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
|
||||
@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const secondaryButtonVariants = cva(
|
||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'inline-flex items-center justify-center px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@ -332,7 +332,7 @@ const AuditLogs = (): ReactElement => {
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
|
||||
@ -10,6 +10,7 @@ import { loginAsync, clearError } from '@/store/authSlice';
|
||||
import { FormField } from '@/components/shared';
|
||||
import { PrimaryButton } from '@/components/shared';
|
||||
import type { LoginError } from '@/services/auth-service';
|
||||
import { showToast } from '@/utils/toast';
|
||||
|
||||
// Zod validation schema
|
||||
const loginSchema = z.object({
|
||||
@ -68,6 +69,9 @@ const Login = (): ReactElement => {
|
||||
try {
|
||||
const result = await dispatch(loginAsync(data)).unwrap();
|
||||
if (result) {
|
||||
const message = result.message || 'Login successful';
|
||||
const description = result.message ? undefined : 'Welcome back!';
|
||||
showToast.success(message, description);
|
||||
// Only navigate on success
|
||||
navigate('/dashboard');
|
||||
}
|
||||
|
||||
@ -314,7 +314,7 @@ const Modules = (): ReactElement => {
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
|
||||
@ -8,44 +8,52 @@ import { Shield, ArrowLeft } from 'lucide-react';
|
||||
import { authService } from '@/services/auth-service';
|
||||
import { FormField } from '@/components/shared';
|
||||
import { PrimaryButton } from '@/components/shared';
|
||||
import { showToast } from '@/utils/toast';
|
||||
|
||||
// Zod validation schema
|
||||
const resetPasswordSchema = z
|
||||
.object({
|
||||
token: z.string().min(1, 'Reset token is required'),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
|
||||
// Zod validation schema - token is optional if provided in URL
|
||||
const createResetPasswordSchema = (hasTokenFromUrl: boolean) =>
|
||||
z
|
||||
.object({
|
||||
token: hasTokenFromUrl
|
||||
? z.string().optional()
|
||||
: z.string().min(1, 'Reset token is required'),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
const ResetPassword = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [success, setSuccess] = useState<string>('');
|
||||
|
||||
const tokenFromUrl = searchParams.get('token') || '';
|
||||
const hasTokenFromUrl = Boolean(tokenFromUrl);
|
||||
|
||||
const resetPasswordSchema = createResetPasswordSchema(hasTokenFromUrl);
|
||||
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
setError,
|
||||
formState: { errors },
|
||||
clearErrors,
|
||||
} = useForm<ResetPasswordFormData>({
|
||||
resolver: zodResolver(resetPasswordSchema),
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
token: tokenFromUrl,
|
||||
token: tokenFromUrl || undefined,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
@ -57,18 +65,23 @@ const ResetPassword = (): ReactElement => {
|
||||
}, [tokenFromUrl, setValue]);
|
||||
|
||||
const onSubmit = async (data: ResetPasswordFormData): Promise<void> => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
clearErrors();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Always use token from URL if available, otherwise use form data
|
||||
const tokenToUse = tokenFromUrl || data.token || '';
|
||||
const response = await authService.resetPassword({
|
||||
token: data.token,
|
||||
token: tokenToUse,
|
||||
password: data.password,
|
||||
});
|
||||
if (response.success) {
|
||||
setSuccess(response.data?.message || response.message || 'Password reset successfully! You can now login with your new password.');
|
||||
const message = response.message || response.data?.message || 'Password reset successfully!';
|
||||
const description = (response.message || response.data?.message) ? undefined : 'You can now login with your new password';
|
||||
const successMessage = response.message || response.data?.message || 'Password reset successfully! You can now login with your new password.';
|
||||
setSuccess(successMessage);
|
||||
showToast.success(message, description);
|
||||
// Redirect to login after 2 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
@ -76,18 +89,29 @@ const ResetPassword = (): ReactElement => {
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Reset password error:', err);
|
||||
if (err?.response?.data?.error?.message) {
|
||||
setError(err.response.data.error.message);
|
||||
} else if (err?.response?.data?.error) {
|
||||
setError(typeof err.response.data.error === 'string' ? err.response.data.error : 'Failed to reset password');
|
||||
} else if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
|
||||
// Handle validation errors
|
||||
const firstError = err.response.data.details[0];
|
||||
setError(firstError.message || 'Validation error');
|
||||
} else if (err?.message) {
|
||||
setError(err.message);
|
||||
// Handle validation errors from API
|
||||
if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
|
||||
const validationErrors = err.response.data.details;
|
||||
validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||
if (detail.path === 'token' || detail.path === 'password') {
|
||||
setError(detail.path as keyof ResetPasswordFormData, {
|
||||
type: 'server',
|
||||
message: detail.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setError('Failed to reset password. Please check your token and try again.');
|
||||
// Handle general errors
|
||||
const errorMessage =
|
||||
err?.response?.data?.error?.message ||
|
||||
err?.response?.data?.error ||
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
'Failed to reset password. Please try again.';
|
||||
setError('root', {
|
||||
type: 'server',
|
||||
message: typeof errorMessage === 'string' ? errorMessage : 'Failed to reset password. Please try again.',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@ -115,7 +139,9 @@ const ResetPassword = (): ReactElement => {
|
||||
Reset Password
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-[#6b7280]">
|
||||
Enter your reset token and new password
|
||||
{tokenFromUrl
|
||||
? 'Enter your new password to reset'
|
||||
: 'Enter your reset token and new password'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -126,13 +152,6 @@ const ResetPassword = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
|
||||
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!success ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@ -143,15 +162,24 @@ const ResetPassword = (): ReactElement => {
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* Token Field */}
|
||||
<FormField
|
||||
label="Reset Token"
|
||||
type="text"
|
||||
placeholder="Enter reset token from email"
|
||||
required
|
||||
error={errors.token?.message}
|
||||
{...register('token')}
|
||||
/>
|
||||
{/* General Error Display */}
|
||||
{errors.root && (
|
||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Field - Only show if not in URL */}
|
||||
{!hasTokenFromUrl && (
|
||||
<FormField
|
||||
label="Reset Token"
|
||||
type="text"
|
||||
placeholder="Enter reset token from email"
|
||||
required
|
||||
error={errors.token?.message}
|
||||
{...register('token')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { roleService } from '@/services/role-service';
|
||||
import type { Role } from '@/types/role';
|
||||
import { showToast } from '@/utils/toast';
|
||||
|
||||
// Helper function to format date
|
||||
const formatDate = (dateString: string): string => {
|
||||
@ -110,7 +111,10 @@ const Roles = (): ReactElement => {
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
await roleService.create(data);
|
||||
const response = await roleService.create(data);
|
||||
const message = response.message || `Role created successfully`;
|
||||
const description = response.message ? undefined : `${data.name} has been added`;
|
||||
showToast.success(message, description);
|
||||
setIsModalOpen(false);
|
||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
@ -145,7 +149,10 @@ const Roles = (): ReactElement => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await roleService.update(id, data);
|
||||
const response = await roleService.update(id, data);
|
||||
const message = response.message || `Role updated successfully`;
|
||||
const description = response.message ? undefined : `${data.name} has been updated`;
|
||||
showToast.success(message, description);
|
||||
setEditModalOpen(false);
|
||||
setSelectedRoleId(null);
|
||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||
@ -336,7 +343,7 @@ const Roles = (): ReactElement => {
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { tenantService } from '@/services/tenant-service';
|
||||
import type { Tenant } from '@/types/tenant';
|
||||
import { showToast } from '@/utils/toast';
|
||||
|
||||
// Helper function to get tenant initials
|
||||
const getTenantInitials = (name: string): string => {
|
||||
@ -126,7 +127,10 @@ const Tenants = (): ReactElement => {
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
await tenantService.create(data);
|
||||
const response = await tenantService.create(data);
|
||||
const message = response.message || `Tenant created successfully`;
|
||||
const description = response.message ? undefined : `${data.name} has been added`;
|
||||
showToast.success(message, description);
|
||||
// Close modal and refresh tenant list
|
||||
setIsModalOpen(false);
|
||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||
@ -165,7 +169,10 @@ const Tenants = (): ReactElement => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await tenantService.update(id, data);
|
||||
const response = await tenantService.update(id, data);
|
||||
const message = response.message || `Tenant updated successfully`;
|
||||
const description = response.message ? undefined : `${data.name} has been updated`;
|
||||
showToast.success(message, description);
|
||||
setEditModalOpen(false);
|
||||
setSelectedTenantId(null);
|
||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||
@ -265,7 +272,7 @@ const Tenants = (): ReactElement => {
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||
import { userService } from '@/services/user-service';
|
||||
import type { User } from '@/types/user';
|
||||
import { showToast } from '@/utils/toast';
|
||||
|
||||
// Helper function to get user initials
|
||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
||||
@ -123,7 +124,10 @@ const Users = (): ReactElement => {
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
await userService.create(data);
|
||||
const response = await userService.create(data);
|
||||
const message = response.message || `User created successfully`;
|
||||
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
|
||||
showToast.success(message, description);
|
||||
setIsModalOpen(false);
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
} catch (err: any) {
|
||||
@ -160,7 +164,10 @@ const Users = (): ReactElement => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await userService.update(id, data);
|
||||
const response = await userService.update(id, data);
|
||||
const message = response.message || `User updated successfully`;
|
||||
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been updated`;
|
||||
showToast.success(message, description);
|
||||
setEditModalOpen(false);
|
||||
setSelectedUserId(null);
|
||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||
@ -369,7 +376,7 @@ const Users = (): ReactElement => {
|
||||
{/* Export Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export</span>
|
||||
|
||||
@ -24,6 +24,7 @@ export interface LoginResponse {
|
||||
expires_in: number;
|
||||
expires_at: string;
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
|
||||
@ -20,6 +20,7 @@ export interface CreateTenantRequest {
|
||||
export interface CreateTenantResponse {
|
||||
success: boolean;
|
||||
data: Tenant;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface GeneralError {
|
||||
@ -50,6 +51,7 @@ export interface UpdateTenantRequest {
|
||||
export interface UpdateTenantResponse {
|
||||
success: boolean;
|
||||
data: Tenant;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DeleteTenantResponse {
|
||||
|
||||
@ -38,14 +38,14 @@ const initialState: AuthState = {
|
||||
|
||||
// Async thunk for login
|
||||
export const loginAsync = createAsyncThunk<
|
||||
LoginResponse['data'],
|
||||
{ data: LoginResponse['data']; message?: string },
|
||||
LoginRequest,
|
||||
{ rejectValue: LoginError }
|
||||
>('auth/login', async (credentials, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await authService.login(credentials);
|
||||
if (response.success) {
|
||||
return response.data;
|
||||
return { data: response.data, message: response.message };
|
||||
}
|
||||
return rejectWithValue(response as unknown as LoginError);
|
||||
} catch (error: any) {
|
||||
@ -63,13 +63,14 @@ export const loginAsync = createAsyncThunk<
|
||||
});
|
||||
|
||||
// Async thunk for logout
|
||||
export const logoutAsync = createAsyncThunk('auth/logout', async (_, { rejectWithValue }) => {
|
||||
export const logoutAsync = createAsyncThunk<{ message?: string }, void, { rejectValue: { message?: string } }>('auth/logout', async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
await authService.logout();
|
||||
return true;
|
||||
const response = await authService.logout();
|
||||
return { message: response.message };
|
||||
} catch (error: any) {
|
||||
// Even if API call fails, we should still logout locally
|
||||
return rejectWithValue(error?.response?.data || { message: 'Logout failed' });
|
||||
const errorData = error?.response?.data || { message: 'Logout failed' };
|
||||
return rejectWithValue({ message: errorData.message });
|
||||
}
|
||||
});
|
||||
|
||||
@ -99,16 +100,16 @@ const authSlice = createSlice({
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loginAsync.fulfilled, (state, action: PayloadAction<LoginResponse['data']>) => {
|
||||
.addCase(loginAsync.fulfilled, (state, action: PayloadAction<{ data: LoginResponse['data']; message?: string }>) => {
|
||||
state.isLoading = false;
|
||||
state.user = action.payload.user;
|
||||
state.tenantId = action.payload.tenant_id;
|
||||
state.roles = action.payload.roles;
|
||||
state.accessToken = action.payload.access_token;
|
||||
state.refreshToken = action.payload.refresh_token;
|
||||
state.tokenType = action.payload.token_type;
|
||||
state.expiresIn = action.payload.expires_in;
|
||||
state.expiresAt = action.payload.expires_at;
|
||||
state.user = action.payload.data.user;
|
||||
state.tenantId = action.payload.data.tenant_id;
|
||||
state.roles = action.payload.data.roles;
|
||||
state.accessToken = action.payload.data.access_token;
|
||||
state.refreshToken = action.payload.data.refresh_token;
|
||||
state.tokenType = action.payload.data.token_type;
|
||||
state.expiresIn = action.payload.data.expires_in;
|
||||
state.expiresAt = action.payload.data.expires_at;
|
||||
state.isAuthenticated = true;
|
||||
state.error = null;
|
||||
})
|
||||
|
||||
@ -32,6 +32,7 @@ export interface CreateRoleRequest {
|
||||
export interface CreateRoleResponse {
|
||||
success: boolean;
|
||||
data: Role;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface GetRoleResponse {
|
||||
@ -49,6 +50,7 @@ export interface UpdateRoleRequest {
|
||||
export interface UpdateRoleResponse {
|
||||
success: boolean;
|
||||
data: Role;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DeleteRoleResponse {
|
||||
|
||||
@ -7,6 +7,14 @@ export interface User {
|
||||
auth_provider: string;
|
||||
tenant_id?: string;
|
||||
role_id?: string;
|
||||
tenant?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
role?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -39,6 +47,7 @@ export interface CreateUserRequest {
|
||||
export interface CreateUserResponse {
|
||||
success: boolean;
|
||||
data: User;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface GetUserResponse {
|
||||
@ -59,6 +68,7 @@ export interface UpdateUserRequest {
|
||||
export interface UpdateUserResponse {
|
||||
success: boolean;
|
||||
data: User;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DeleteUserResponse {
|
||||
|
||||
31
src/utils/toast.ts
Normal file
31
src/utils/toast.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Reusable toast utility for showing success and error messages
|
||||
*/
|
||||
export const showToast = {
|
||||
success: (message: string, description?: string) => {
|
||||
toast.success(message, {
|
||||
description,
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
error: (message: string, description?: string) => {
|
||||
toast.error(message, {
|
||||
description,
|
||||
duration: 4000,
|
||||
});
|
||||
},
|
||||
info: (message: string, description?: string) => {
|
||||
toast.info(message, {
|
||||
description,
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
warning: (message: string, description?: string) => {
|
||||
toast.warning(message, {
|
||||
description,
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user