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:
poduck
2025-12-02 11:26:47 -05:00
parent 42988c0f88
commit dc3210927a
4 changed files with 226 additions and 19 deletions

View 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;

View File

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

View File

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

View File

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