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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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