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:
Yashwin 2026-01-21 19:32:18 +05:30
parent f4f1225f02
commit b0d720c821
24 changed files with 344 additions and 124 deletions

4
.env
View File

@ -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
View File

@ -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",

View File

@ -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"

View File

@ -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 />} />

View File

@ -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 });
}
};

View File

@ -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>

View File

@ -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,8 +90,28 @@ 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)) {
// 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: selectedTenantId,
label: currentTenantName,
},
...options,
];
} else {
try {
const tenantResponse = await tenantService.getById(selectedTenantId);
if (tenantResponse.success) {
@ -102,6 +129,8 @@ export const EditUserModal = ({
console.warn('Failed to fetch selected tenant:', err);
}
}
}
}
return {
options,
@ -117,8 +146,28 @@ 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)) {
// 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: selectedRoleId,
label: currentRoleName,
},
...options,
];
} else {
try {
const roleResponse = await roleService.getById(selectedRoleId);
if (roleResponse.success) {
@ -136,6 +185,8 @@ export const EditUserModal = ({
console.warn('Failed to fetch selected role:', err);
}
}
}
}
return {
options,
@ -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>

View File

@ -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,8 +34,10 @@ export const FormField = ({
<span>{label}</span>
{required && <span className="text-[#e02424]">*</span>}
</label>
<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]',
@ -38,12 +45,30 @@ export const FormField = ({
? '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>
)}
</div>
{error && (
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
{error}

View File

@ -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);

View File

@ -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: {

View File

@ -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: {

View File

@ -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>

View File

@ -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');
}

View File

@ -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>

View File

@ -8,11 +8,15 @@ 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
// Zod validation schema - token is optional if provided in URL
const createResetPasswordSchema = (hasTokenFromUrl: boolean) =>
z
.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
.string()
.min(1, 'Password is required')
@ -24,28 +28,32 @@ const resetPasswordSchema = z
path: ['confirmPassword'],
});
type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
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,7 +162,15 @@ const ResetPassword = (): ReactElement => {
className="space-y-4"
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
label="Reset Token"
type="text"
@ -152,6 +179,7 @@ const ResetPassword = (): ReactElement => {
error={errors.token?.message}
{...register('token')}
/>
)}
{/* Password Field */}
<FormField

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -24,6 +24,7 @@ export interface LoginResponse {
expires_in: number;
expires_at: string;
};
message?: string;
}
export interface ValidationError {

View File

@ -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 {

View File

@ -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;
})

View File

@ -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 {

View File

@ -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
View 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,
});
},
};