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=http://localhost:3000/api/v1
|
||||||
VITE_API_BASE_URL=https://backendqaasure.tech4bizsolutions.com/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",
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
@ -4244,6 +4245,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"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",
|
"react-router-dom": "^7.12.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Tenants from "./pages/Tenants";
|
import Tenants from "./pages/Tenants";
|
||||||
@ -14,6 +15,7 @@ import ResetPassword from "./pages/ResetPassword";
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Toaster position="top-right" richColors />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Login />} />
|
<Route path="/" element={<Login />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||||
import { logoutAsync } from '@/store/authSlice';
|
import { logoutAsync } from '@/store/authSlice';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@ -70,14 +71,21 @@ export const Header = ({ currentPage, onMenuClick }: HeaderProps): ReactElement
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Call logout API with Bearer token
|
// 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
|
// Clear state and redirect
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Even if API call fails, clear local state and redirect to login
|
// Even if API call fails, clear local state and redirect to login
|
||||||
console.error('Logout error:', error);
|
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 logout action to clear local state
|
||||||
dispatch({ type: 'auth/logout' });
|
dispatch({ type: 'auth/logout' });
|
||||||
|
showToast.success(message, description);
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export const ActionDropdown = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className={cn(
|
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
|
isOpen
|
||||||
? 'bg-[#084cc8] text-white'
|
? 'bg-[#084cc8] text-white'
|
||||||
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
|
: 'bg-white text-[#0f1724] hover:bg-[#084cc8] hover:text-white'
|
||||||
@ -66,7 +66,7 @@ export const ActionDropdown = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleAction(onView)}
|
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" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
<span>View</span>
|
<span>View</span>
|
||||||
@ -76,7 +76,7 @@ export const ActionDropdown = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleAction(onEdit)}
|
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" />
|
<Edit className="w-3 h-3" />
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
@ -86,7 +86,7 @@ export const ActionDropdown = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleAction(onDelete)}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
|
|||||||
@ -75,6 +75,13 @@ export const EditUserModal = ({
|
|||||||
const tenantIdValue = watch('tenant_id');
|
const tenantIdValue = watch('tenant_id');
|
||||||
const roleIdValue = watch('role_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
|
// Load tenants for dropdown - ensure selected tenant is included
|
||||||
const loadTenants = async (page: number, limit: number) => {
|
const loadTenants = async (page: number, limit: number) => {
|
||||||
const response = await tenantService.getAll(page, limit);
|
const response = await tenantService.getAll(page, limit);
|
||||||
@ -83,8 +90,28 @@ export const EditUserModal = ({
|
|||||||
label: tenant.name,
|
label: tenant.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// If we have a selected tenant ID and it's not in the current options, fetch it specifically
|
// Always include initial option if it exists and matches the selected value
|
||||||
if (selectedTenantId && page === 1 && !options.find((opt) => opt.value === selectedTenantId)) {
|
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: selectedTenantId,
|
||||||
|
label: currentTenantName,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
const tenantResponse = await tenantService.getById(selectedTenantId);
|
const tenantResponse = await tenantService.getById(selectedTenantId);
|
||||||
if (tenantResponse.success) {
|
if (tenantResponse.success) {
|
||||||
@ -102,6 +129,8 @@ export const EditUserModal = ({
|
|||||||
console.warn('Failed to fetch selected tenant:', err);
|
console.warn('Failed to fetch selected tenant:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
options,
|
options,
|
||||||
@ -117,8 +146,28 @@ export const EditUserModal = ({
|
|||||||
label: role.name,
|
label: role.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// If we have a selected role ID and it's not in the current options, fetch it specifically
|
// Always include initial option if it exists and matches the selected value
|
||||||
if (selectedRoleId && page === 1 && !options.find((opt) => opt.value === selectedRoleId)) {
|
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: selectedRoleId,
|
||||||
|
label: currentRoleName,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
const roleResponse = await roleService.getById(selectedRoleId);
|
const roleResponse = await roleService.getById(selectedRoleId);
|
||||||
if (roleResponse.success) {
|
if (roleResponse.success) {
|
||||||
@ -136,6 +185,8 @@ export const EditUserModal = ({
|
|||||||
console.warn('Failed to fetch selected role:', err);
|
console.warn('Failed to fetch selected role:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
options,
|
options,
|
||||||
@ -153,11 +204,24 @@ export const EditUserModal = ({
|
|||||||
clearErrors();
|
clearErrors();
|
||||||
const user = await onLoadUser(userId);
|
const user = await onLoadUser(userId);
|
||||||
|
|
||||||
// Store selected IDs for dropdown pre-loading
|
// Extract tenant and role IDs from nested objects or fallback to direct properties
|
||||||
const tenantId = user.tenant_id || '';
|
const tenantId = user.tenant?.id || user.tenant_id || '';
|
||||||
const roleId = user.role_id || '';
|
const roleId = user.role?.id || user.role_id || '';
|
||||||
|
const tenantName = user.tenant?.name || '';
|
||||||
|
const roleName = user.role?.name || '';
|
||||||
|
|
||||||
setSelectedTenantId(tenantId);
|
setSelectedTenantId(tenantId);
|
||||||
setSelectedRoleId(roleId);
|
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({
|
reset({
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@ -177,6 +241,10 @@ export const EditUserModal = ({
|
|||||||
} else {
|
} else {
|
||||||
setSelectedTenantId('');
|
setSelectedTenantId('');
|
||||||
setSelectedRoleId('');
|
setSelectedRoleId('');
|
||||||
|
setCurrentTenantName('');
|
||||||
|
setCurrentRoleName('');
|
||||||
|
setInitialTenantOption(null);
|
||||||
|
setInitialRoleOption(null);
|
||||||
reset({
|
reset({
|
||||||
email: '',
|
email: '',
|
||||||
first_name: '',
|
first_name: '',
|
||||||
@ -320,6 +388,7 @@ export const EditUserModal = ({
|
|||||||
value={tenantIdValue || ''}
|
value={tenantIdValue || ''}
|
||||||
onValueChange={(value) => setValue('tenant_id', value)}
|
onValueChange={(value) => setValue('tenant_id', value)}
|
||||||
onLoadOptions={loadTenants}
|
onLoadOptions={loadTenants}
|
||||||
|
initialOption={initialTenantOption || undefined}
|
||||||
error={errors.tenant_id?.message}
|
error={errors.tenant_id?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -330,6 +399,7 @@ export const EditUserModal = ({
|
|||||||
value={roleIdValue || ''}
|
value={roleIdValue || ''}
|
||||||
onValueChange={(value) => setValue('role_id', value)}
|
onValueChange={(value) => setValue('role_id', value)}
|
||||||
onLoadOptions={loadRoles}
|
onLoadOptions={loadRoles}
|
||||||
|
initialOption={initialRoleOption || undefined}
|
||||||
error={errors.role_id?.message}
|
error={errors.role_id?.message}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import type { ReactElement, InputHTMLAttributes } from 'react';
|
import type { ReactElement, InputHTMLAttributes } from 'react';
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface FormFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
@ -15,10 +17,13 @@ export const FormField = ({
|
|||||||
helperText,
|
helperText,
|
||||||
className,
|
className,
|
||||||
id,
|
id,
|
||||||
|
type,
|
||||||
...props
|
...props
|
||||||
}: FormFieldProps): ReactElement => {
|
}: FormFieldProps): ReactElement => {
|
||||||
const fieldId = id || `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
const fieldId = id || `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
const hasError = Boolean(error);
|
const hasError = Boolean(error);
|
||||||
|
const isPassword = type === 'password';
|
||||||
|
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 pb-4">
|
<div className="flex flex-col gap-2 pb-4">
|
||||||
@ -29,8 +34,10 @@ export const FormField = ({
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{required && <span className="text-[#e02424]">*</span>}
|
{required && <span className="text-[#e02424]">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
id={fieldId}
|
id={fieldId}
|
||||||
|
type={isPassword && showPassword ? 'text' : type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
'h-10 w-full px-3.5 py-1 bg-white border rounded-md text-sm transition-colors',
|
||||||
'placeholder:text-[#9aa6b2] text-[#0e1b2a]',
|
'placeholder:text-[#9aa6b2] text-[#0e1b2a]',
|
||||||
@ -38,12 +45,30 @@ export const FormField = ({
|
|||||||
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20'
|
? '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',
|
: 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20',
|
||||||
'focus-visible:outline-none focus-visible:ring-2',
|
'focus-visible:outline-none focus-visible:ring-2',
|
||||||
|
props.disabled && 'bg-[#f3f4f6] cursor-not-allowed opacity-60',
|
||||||
|
isPassword && 'pr-10',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-invalid={hasError}
|
aria-invalid={hasError}
|
||||||
aria-describedby={error ? `${fieldId}-error` : helperText ? `${fieldId}-helper` : undefined}
|
aria-describedby={error ? `${fieldId}-error` : helperText ? `${fieldId}-helper` : undefined}
|
||||||
{...props}
|
{...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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
|
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ interface PaginatedSelectProps {
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
initialOption?: { value: string; label: string };
|
||||||
className?: string;
|
className?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
@ -40,6 +41,7 @@ export const PaginatedSelect = ({
|
|||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
onLoadOptions,
|
onLoadOptions,
|
||||||
|
initialOption,
|
||||||
className,
|
className,
|
||||||
id,
|
id,
|
||||||
}: PaginatedSelectProps): ReactElement => {
|
}: PaginatedSelectProps): ReactElement => {
|
||||||
@ -181,7 +183,8 @@ export const PaginatedSelect = ({
|
|||||||
|
|
||||||
const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
const hasError = Boolean(error);
|
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) => {
|
const handleSelect = (optionValue: string) => {
|
||||||
onValueChange(optionValue);
|
onValueChange(optionValue);
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const primaryButtonVariants = cva(
|
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: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const secondaryButtonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@ -332,7 +332,7 @@ const AuditLogs = (): ReactElement => {
|
|||||||
{/* Export Button */}
|
{/* Export Button */}
|
||||||
<button
|
<button
|
||||||
type="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" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { loginAsync, clearError } from '@/store/authSlice';
|
|||||||
import { FormField } from '@/components/shared';
|
import { FormField } from '@/components/shared';
|
||||||
import { PrimaryButton } from '@/components/shared';
|
import { PrimaryButton } from '@/components/shared';
|
||||||
import type { LoginError } from '@/services/auth-service';
|
import type { LoginError } from '@/services/auth-service';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
|
||||||
// Zod validation schema
|
// Zod validation schema
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@ -68,6 +69,9 @@ const Login = (): ReactElement => {
|
|||||||
try {
|
try {
|
||||||
const result = await dispatch(loginAsync(data)).unwrap();
|
const result = await dispatch(loginAsync(data)).unwrap();
|
||||||
if (result) {
|
if (result) {
|
||||||
|
const message = result.message || 'Login successful';
|
||||||
|
const description = result.message ? undefined : 'Welcome back!';
|
||||||
|
showToast.success(message, description);
|
||||||
// Only navigate on success
|
// Only navigate on success
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -314,7 +314,7 @@ const Modules = (): ReactElement => {
|
|||||||
{/* Export Button */}
|
{/* Export Button */}
|
||||||
<button
|
<button
|
||||||
type="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" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
|
|||||||
@ -8,11 +8,15 @@ import { Shield, ArrowLeft } from 'lucide-react';
|
|||||||
import { authService } from '@/services/auth-service';
|
import { authService } from '@/services/auth-service';
|
||||||
import { FormField } from '@/components/shared';
|
import { FormField } from '@/components/shared';
|
||||||
import { PrimaryButton } from '@/components/shared';
|
import { PrimaryButton } from '@/components/shared';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
|
||||||
// Zod validation schema
|
// Zod validation schema - token is optional if provided in URL
|
||||||
const resetPasswordSchema = z
|
const createResetPasswordSchema = (hasTokenFromUrl: boolean) =>
|
||||||
|
z
|
||||||
.object({
|
.object({
|
||||||
token: z.string().min(1, 'Reset token is required'),
|
token: hasTokenFromUrl
|
||||||
|
? z.string().optional()
|
||||||
|
: z.string().min(1, 'Reset token is required'),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, 'Password is required')
|
.min(1, 'Password is required')
|
||||||
@ -24,28 +28,32 @@ const resetPasswordSchema = z
|
|||||||
path: ['confirmPassword'],
|
path: ['confirmPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
|
|
||||||
|
|
||||||
const ResetPassword = (): ReactElement => {
|
const ResetPassword = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [success, setSuccess] = useState<string>('');
|
const [success, setSuccess] = useState<string>('');
|
||||||
|
|
||||||
const tokenFromUrl = searchParams.get('token') || '';
|
const tokenFromUrl = searchParams.get('token') || '';
|
||||||
|
const hasTokenFromUrl = Boolean(tokenFromUrl);
|
||||||
|
|
||||||
|
const resetPasswordSchema = createResetPasswordSchema(hasTokenFromUrl);
|
||||||
|
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
|
setError,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
clearErrors,
|
clearErrors,
|
||||||
} = useForm<ResetPasswordFormData>({
|
} = useForm<ResetPasswordFormData>({
|
||||||
resolver: zodResolver(resetPasswordSchema),
|
resolver: zodResolver(resetPasswordSchema),
|
||||||
mode: 'onBlur',
|
mode: 'onBlur',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
token: tokenFromUrl,
|
token: tokenFromUrl || undefined,
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,18 +65,23 @@ const ResetPassword = (): ReactElement => {
|
|||||||
}, [tokenFromUrl, setValue]);
|
}, [tokenFromUrl, setValue]);
|
||||||
|
|
||||||
const onSubmit = async (data: ResetPasswordFormData): Promise<void> => {
|
const onSubmit = async (data: ResetPasswordFormData): Promise<void> => {
|
||||||
setError('');
|
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
clearErrors();
|
clearErrors();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Always use token from URL if available, otherwise use form data
|
||||||
|
const tokenToUse = tokenFromUrl || data.token || '';
|
||||||
const response = await authService.resetPassword({
|
const response = await authService.resetPassword({
|
||||||
token: data.token,
|
token: tokenToUse,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
});
|
});
|
||||||
if (response.success) {
|
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
|
// Redirect to login after 2 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
@ -76,18 +89,29 @@ const ResetPassword = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Reset password error:', err);
|
console.error('Reset password error:', err);
|
||||||
if (err?.response?.data?.error?.message) {
|
// Handle validation errors from API
|
||||||
setError(err.response.data.error.message);
|
if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
|
||||||
} else if (err?.response?.data?.error) {
|
const validationErrors = err.response.data.details;
|
||||||
setError(typeof err.response.data.error === 'string' ? err.response.data.error : 'Failed to reset password');
|
validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||||
} else if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
|
if (detail.path === 'token' || detail.path === 'password') {
|
||||||
// Handle validation errors
|
setError(detail.path as keyof ResetPasswordFormData, {
|
||||||
const firstError = err.response.data.details[0];
|
type: 'server',
|
||||||
setError(firstError.message || 'Validation error');
|
message: detail.message,
|
||||||
} else if (err?.message) {
|
});
|
||||||
setError(err.message);
|
}
|
||||||
|
});
|
||||||
} else {
|
} 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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@ -115,7 +139,9 @@ const ResetPassword = (): ReactElement => {
|
|||||||
Reset Password
|
Reset Password
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm md:text-base text-[#6b7280]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -126,13 +152,6 @@ const ResetPassword = (): ReactElement => {
|
|||||||
</div>
|
</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 ? (
|
{!success ? (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@ -143,7 +162,15 @@ const ResetPassword = (): ReactElement => {
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
{/* Token Field */}
|
{/* 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
|
<FormField
|
||||||
label="Reset Token"
|
label="Reset Token"
|
||||||
type="text"
|
type="text"
|
||||||
@ -152,6 +179,7 @@ const ResetPassword = (): ReactElement => {
|
|||||||
error={errors.token?.message}
|
error={errors.token?.message}
|
||||||
{...register('token')}
|
{...register('token')}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Password Field */}
|
{/* Password Field */}
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||||
import { roleService } from '@/services/role-service';
|
import { roleService } from '@/services/role-service';
|
||||||
import type { Role } from '@/types/role';
|
import type { Role } from '@/types/role';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
|
||||||
// Helper function to format date
|
// Helper function to format date
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
@ -110,7 +111,10 @@ const Roles = (): ReactElement => {
|
|||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsCreating(true);
|
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);
|
setIsModalOpen(false);
|
||||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -145,7 +149,10 @@ const Roles = (): ReactElement => {
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsUpdating(true);
|
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);
|
setEditModalOpen(false);
|
||||||
setSelectedRoleId(null);
|
setSelectedRoleId(null);
|
||||||
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
await fetchRoles(currentPage, limit, scopeFilter, orderBy);
|
||||||
@ -336,7 +343,7 @@ const Roles = (): ReactElement => {
|
|||||||
{/* Export Button */}
|
{/* Export Button */}
|
||||||
<button
|
<button
|
||||||
type="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" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||||
import { tenantService } from '@/services/tenant-service';
|
import { tenantService } from '@/services/tenant-service';
|
||||||
import type { Tenant } from '@/types/tenant';
|
import type { Tenant } from '@/types/tenant';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
|
||||||
// Helper function to get tenant initials
|
// Helper function to get tenant initials
|
||||||
const getTenantInitials = (name: string): string => {
|
const getTenantInitials = (name: string): string => {
|
||||||
@ -126,7 +127,10 @@ const Tenants = (): ReactElement => {
|
|||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsCreating(true);
|
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
|
// Close modal and refresh tenant list
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||||
@ -165,7 +169,10 @@ const Tenants = (): ReactElement => {
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsUpdating(true);
|
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);
|
setEditModalOpen(false);
|
||||||
setSelectedTenantId(null);
|
setSelectedTenantId(null);
|
||||||
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
await fetchTenants(currentPage, limit, statusFilter, orderBy);
|
||||||
@ -265,7 +272,7 @@ const Tenants = (): ReactElement => {
|
|||||||
{/* Export Button */}
|
{/* Export Button */}
|
||||||
<button
|
<button
|
||||||
type="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" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
import { Plus, Download, ArrowUpDown } from 'lucide-react';
|
||||||
import { userService } from '@/services/user-service';
|
import { userService } from '@/services/user-service';
|
||||||
import type { User } from '@/types/user';
|
import type { User } from '@/types/user';
|
||||||
|
import { showToast } from '@/utils/toast';
|
||||||
|
|
||||||
// Helper function to get user initials
|
// Helper function to get user initials
|
||||||
const getUserInitials = (firstName: string, lastName: string): string => {
|
const getUserInitials = (firstName: string, lastName: string): string => {
|
||||||
@ -123,7 +124,10 @@ const Users = (): ReactElement => {
|
|||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsCreating(true);
|
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);
|
setIsModalOpen(false);
|
||||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -160,7 +164,10 @@ const Users = (): ReactElement => {
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsUpdating(true);
|
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);
|
setEditModalOpen(false);
|
||||||
setSelectedUserId(null);
|
setSelectedUserId(null);
|
||||||
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
await fetchUsers(currentPage, limit, statusFilter, orderBy);
|
||||||
@ -369,7 +376,7 @@ const Users = (): ReactElement => {
|
|||||||
{/* Export Button */}
|
{/* Export Button */}
|
||||||
<button
|
<button
|
||||||
type="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" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export interface LoginResponse {
|
|||||||
expires_in: number;
|
expires_in: number;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
};
|
};
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidationError {
|
export interface ValidationError {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export interface CreateTenantRequest {
|
|||||||
export interface CreateTenantResponse {
|
export interface CreateTenantResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: Tenant;
|
data: Tenant;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeneralError {
|
export interface GeneralError {
|
||||||
@ -50,6 +51,7 @@ export interface UpdateTenantRequest {
|
|||||||
export interface UpdateTenantResponse {
|
export interface UpdateTenantResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: Tenant;
|
data: Tenant;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteTenantResponse {
|
export interface DeleteTenantResponse {
|
||||||
|
|||||||
@ -38,14 +38,14 @@ const initialState: AuthState = {
|
|||||||
|
|
||||||
// Async thunk for login
|
// Async thunk for login
|
||||||
export const loginAsync = createAsyncThunk<
|
export const loginAsync = createAsyncThunk<
|
||||||
LoginResponse['data'],
|
{ data: LoginResponse['data']; message?: string },
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
{ rejectValue: LoginError }
|
{ rejectValue: LoginError }
|
||||||
>('auth/login', async (credentials, { rejectWithValue }) => {
|
>('auth/login', async (credentials, { rejectWithValue }) => {
|
||||||
try {
|
try {
|
||||||
const response = await authService.login(credentials);
|
const response = await authService.login(credentials);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
return response.data;
|
return { data: response.data, message: response.message };
|
||||||
}
|
}
|
||||||
return rejectWithValue(response as unknown as LoginError);
|
return rejectWithValue(response as unknown as LoginError);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -63,13 +63,14 @@ export const loginAsync = createAsyncThunk<
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Async thunk for logout
|
// 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 {
|
try {
|
||||||
await authService.logout();
|
const response = await authService.logout();
|
||||||
return true;
|
return { message: response.message };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Even if API call fails, we should still logout locally
|
// 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.isLoading = true;
|
||||||
state.error = null;
|
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.isLoading = false;
|
||||||
state.user = action.payload.user;
|
state.user = action.payload.data.user;
|
||||||
state.tenantId = action.payload.tenant_id;
|
state.tenantId = action.payload.data.tenant_id;
|
||||||
state.roles = action.payload.roles;
|
state.roles = action.payload.data.roles;
|
||||||
state.accessToken = action.payload.access_token;
|
state.accessToken = action.payload.data.access_token;
|
||||||
state.refreshToken = action.payload.refresh_token;
|
state.refreshToken = action.payload.data.refresh_token;
|
||||||
state.tokenType = action.payload.token_type;
|
state.tokenType = action.payload.data.token_type;
|
||||||
state.expiresIn = action.payload.expires_in;
|
state.expiresIn = action.payload.data.expires_in;
|
||||||
state.expiresAt = action.payload.expires_at;
|
state.expiresAt = action.payload.data.expires_at;
|
||||||
state.isAuthenticated = true;
|
state.isAuthenticated = true;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
})
|
})
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export interface CreateRoleRequest {
|
|||||||
export interface CreateRoleResponse {
|
export interface CreateRoleResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: Role;
|
data: Role;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetRoleResponse {
|
export interface GetRoleResponse {
|
||||||
@ -49,6 +50,7 @@ export interface UpdateRoleRequest {
|
|||||||
export interface UpdateRoleResponse {
|
export interface UpdateRoleResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: Role;
|
data: Role;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteRoleResponse {
|
export interface DeleteRoleResponse {
|
||||||
|
|||||||
@ -7,6 +7,14 @@ export interface User {
|
|||||||
auth_provider: string;
|
auth_provider: string;
|
||||||
tenant_id?: string;
|
tenant_id?: string;
|
||||||
role_id?: string;
|
role_id?: string;
|
||||||
|
tenant?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
role?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -39,6 +47,7 @@ export interface CreateUserRequest {
|
|||||||
export interface CreateUserResponse {
|
export interface CreateUserResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: User;
|
data: User;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetUserResponse {
|
export interface GetUserResponse {
|
||||||
@ -59,6 +68,7 @@ export interface UpdateUserRequest {
|
|||||||
export interface UpdateUserResponse {
|
export interface UpdateUserResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: User;
|
data: User;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteUserResponse {
|
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