feat(platform): Add confirmation modal for email verification
- Create reusable ConfirmationModal component with variants (info, warning, danger, success) - Replace browser confirm() dialogs with styled modal for email verification - Update PlatformBusinesses and PlatformUsers to use the new modal - Add translation keys for verification messages - Unverify test@example.com for testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
131
frontend/src/components/ConfirmationModal.tsx
Normal file
131
frontend/src/components/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { X, AlertTriangle, CheckCircle, Info, AlertCircle } from 'lucide-react';
|
||||
|
||||
type ModalVariant = 'info' | 'warning' | 'danger' | 'success';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string | React.ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: ModalVariant;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const variantConfig: Record<ModalVariant, {
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
confirmButtonClass: string;
|
||||
}> = {
|
||||
info: {
|
||||
icon: <Info size={24} className="text-blue-600 dark:text-blue-400" />,
|
||||
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
confirmButtonClass: 'bg-blue-600 hover:bg-blue-700 text-white',
|
||||
},
|
||||
warning: {
|
||||
icon: <AlertTriangle size={24} className="text-amber-600 dark:text-amber-400" />,
|
||||
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
confirmButtonClass: 'bg-amber-600 hover:bg-amber-700 text-white',
|
||||
},
|
||||
danger: {
|
||||
icon: <AlertCircle size={24} className="text-red-600 dark:text-red-400" />,
|
||||
iconBg: 'bg-red-100 dark:bg-red-900/30',
|
||||
confirmButtonClass: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
},
|
||||
success: {
|
||||
icon: <CheckCircle size={24} className="text-green-600 dark:text-green-400" />,
|
||||
iconBg: 'bg-green-100 dark:bg-green-900/30',
|
||||
confirmButtonClass: 'bg-green-600 hover:bg-green-700 text-white',
|
||||
},
|
||||
};
|
||||
|
||||
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
variant = 'info',
|
||||
isLoading = false,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const config = variantConfig[variant];
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${config.iconBg}`}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6">
|
||||
<div className="text-gray-600 dark:text-gray-300">
|
||||
{typeof message === 'string' ? <p>{message}</p> : message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center gap-2 ${config.confirmButtonClass}`}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
||||
@@ -602,6 +602,8 @@
|
||||
"verifyEmail": "Verify Email",
|
||||
"verify": "Verify",
|
||||
"confirmVerifyEmail": "Are you sure you want to manually verify this user's email?",
|
||||
"confirmVerifyEmailMessage": "Are you sure you want to manually verify this user's email address?",
|
||||
"verifyEmailNote": "This will mark their email as verified and allow them to access all features that require email verification.",
|
||||
"noUsersFound": "No users found matching your filters.",
|
||||
"roles": {
|
||||
"superuser": "Superuser",
|
||||
|
||||
@@ -9,6 +9,7 @@ import PlatformListing from './components/PlatformListing';
|
||||
import PlatformTable from './components/PlatformTable';
|
||||
import PlatformListRow from './components/PlatformListRow';
|
||||
import EditPlatformEntityModal from './components/EditPlatformEntityModal';
|
||||
import ConfirmationModal from '../../components/ConfirmationModal';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface PlatformBusinessesProps {
|
||||
@@ -25,6 +26,8 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [editingBusiness, setEditingBusiness] = useState<PlatformBusiness | null>(null);
|
||||
const [showInactiveBusinesses, setShowInactiveBusinesses] = useState(false);
|
||||
const [verifyEmailUser, setVerifyEmailUser] = useState<{ id: number; email: string; name: string } | null>(null);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
// Filter and separate businesses
|
||||
const filteredBusinesses = (businesses || []).filter(b =>
|
||||
@@ -47,14 +50,22 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async (userId: number) => {
|
||||
if (confirm(t('platform.confirmVerifyEmail'))) {
|
||||
try {
|
||||
await verifyUserEmail(userId);
|
||||
queryClient.invalidateQueries({ queryKey: ['platform', 'businesses'] });
|
||||
} catch (error) {
|
||||
alert(t('errors.generic'));
|
||||
}
|
||||
const handleVerifyEmailClick = (userId: number, email: string, name: string) => {
|
||||
setVerifyEmailUser({ id: userId, email, name });
|
||||
};
|
||||
|
||||
const handleVerifyEmailConfirm = async () => {
|
||||
if (!verifyEmailUser) return;
|
||||
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await verifyUserEmail(verifyEmailUser.id);
|
||||
queryClient.invalidateQueries({ queryKey: ['platform', 'businesses'] });
|
||||
setVerifyEmailUser(null);
|
||||
} catch (error) {
|
||||
alert(t('errors.generic'));
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,9 +81,13 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
|
||||
tertiaryText={business.owner?.email || '-'}
|
||||
actions={
|
||||
<>
|
||||
{business.owner && !business.owner.email_verified && ( // Assuming PlatformBusiness owner object has email_verified, if not we might need to fetch it or update interface
|
||||
{business.owner && !business.owner.email_verified && (
|
||||
<button
|
||||
onClick={() => handleVerifyEmail(business.owner!.id)}
|
||||
onClick={() => handleVerifyEmailClick(
|
||||
business.owner!.id,
|
||||
business.owner!.email,
|
||||
business.owner!.full_name || business.owner!.username || business.name
|
||||
)}
|
||||
className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
|
||||
title={t('platform.verifyEmail')}
|
||||
>
|
||||
@@ -194,6 +209,30 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
|
||||
isOpen={!!editingBusiness}
|
||||
onClose={() => setEditingBusiness(null)}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
isOpen={!!verifyEmailUser}
|
||||
onClose={() => setVerifyEmailUser(null)}
|
||||
onConfirm={handleVerifyEmailConfirm}
|
||||
title={t('platform.verifyEmail')}
|
||||
message={
|
||||
<div className="space-y-3">
|
||||
<p>{t('platform.confirmVerifyEmailMessage')}</p>
|
||||
{verifyEmailUser && (
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{verifyEmailUser.name}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{verifyEmailUser.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platform.verifyEmailNote')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
confirmText={t('platform.verify')}
|
||||
cancelText={t('common.cancel')}
|
||||
variant="success"
|
||||
isLoading={isVerifying}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import PlatformListing from './components/PlatformListing';
|
||||
import PlatformListRow from './components/PlatformListRow';
|
||||
import EditPlatformEntityModal from './components/EditPlatformEntityModal';
|
||||
import ConfirmationModal from '../../components/ConfirmationModal';
|
||||
|
||||
interface PlatformUsersProps {
|
||||
onMasquerade: (targetUser: { id: number; username?: string; name?: string; email?: string; role?: string }) => void;
|
||||
@@ -19,6 +20,8 @@ const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all');
|
||||
const { data: users, isLoading, error } = usePlatformUsers();
|
||||
const [editingUser, setEditingUser] = useState<PlatformUser | null>(null);
|
||||
const [verifyEmailUser, setVerifyEmailUser] = useState<{ id: number; email: string; name: string } | null>(null);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const filteredUsers = (users || []).filter(u => {
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_sales', 'platform_support'].includes(u.role);
|
||||
@@ -51,14 +54,22 @@ const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async (userId: number) => {
|
||||
if (confirm(t('platform.confirmVerifyEmail'))) {
|
||||
try {
|
||||
await verifyUserEmail(userId);
|
||||
queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
|
||||
} catch (error) {
|
||||
alert(t('errors.generic'));
|
||||
}
|
||||
const handleVerifyEmailClick = (userId: number, email: string, name: string) => {
|
||||
setVerifyEmailUser({ id: userId, email, name });
|
||||
};
|
||||
|
||||
const handleVerifyEmailConfirm = async () => {
|
||||
if (!verifyEmailUser) return;
|
||||
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await verifyUserEmail(verifyEmailUser.id);
|
||||
queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
|
||||
setVerifyEmailUser(null);
|
||||
} catch (error) {
|
||||
alert(t('errors.generic'));
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,7 +91,7 @@ const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
|
||||
<>
|
||||
{!u.email_verified && (
|
||||
<button
|
||||
onClick={() => handleVerifyEmail(u.id)}
|
||||
onClick={() => handleVerifyEmailClick(u.id, u.email, u.name || u.username)}
|
||||
className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
|
||||
title={t('platform.verifyEmail')}
|
||||
>
|
||||
@@ -137,6 +148,30 @@ const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
|
||||
isOpen={!!editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
isOpen={!!verifyEmailUser}
|
||||
onClose={() => setVerifyEmailUser(null)}
|
||||
onConfirm={handleVerifyEmailConfirm}
|
||||
title={t('platform.verifyEmail')}
|
||||
message={
|
||||
<div className="space-y-3">
|
||||
<p>{t('platform.confirmVerifyEmailMessage')}</p>
|
||||
{verifyEmailUser && (
|
||||
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{verifyEmailUser.name}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{verifyEmailUser.email}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platform.verifyEmailNote')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
confirmText={t('platform.verify')}
|
||||
cancelText={t('common.cancel')}
|
||||
variant="success"
|
||||
isLoading={isVerifying}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user