221 lines
8 KiB
JavaScript
221 lines
8 KiB
JavaScript
|
|
import { useState } from 'react';
|
||
|
|
|
||
|
|
export default function BulkInviteModal({ collectionId, isOpen, onClose, onSuccess }) {
|
||
|
|
const [inviteData, setInviteData] = useState({
|
||
|
|
emails: '',
|
||
|
|
role: 'viewer',
|
||
|
|
message: ''
|
||
|
|
});
|
||
|
|
const [processing, setProcessing] = useState(false);
|
||
|
|
const [results, setResults] = useState(null);
|
||
|
|
|
||
|
|
const handleSubmit = async (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
setProcessing(true);
|
||
|
|
setResults(null);
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Parse emails (split by comma, semicolon, or newline)
|
||
|
|
const emailList = inviteData.emails
|
||
|
|
.split(/[,;\n]/)
|
||
|
|
.map(email => email.trim())
|
||
|
|
.filter(email => email && email.includes('@'));
|
||
|
|
|
||
|
|
if (emailList.length === 0) {
|
||
|
|
alert('Please enter at least one valid email address');
|
||
|
|
setProcessing(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Send invitations
|
||
|
|
const invitePromises = emailList.map(email =>
|
||
|
|
fetch(`/api/collections/${collectionId}/permissions`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
},
|
||
|
|
body: JSON.stringify({
|
||
|
|
email,
|
||
|
|
role: inviteData.role,
|
||
|
|
message: inviteData.message
|
||
|
|
})
|
||
|
|
}).then(async response => ({
|
||
|
|
email,
|
||
|
|
success: response.ok,
|
||
|
|
error: response.ok ? null : (await response.json()).error
|
||
|
|
}))
|
||
|
|
);
|
||
|
|
|
||
|
|
const results = await Promise.all(invitePromises);
|
||
|
|
setResults(results);
|
||
|
|
|
||
|
|
const successCount = results.filter(r => r.success).length;
|
||
|
|
if (successCount > 0 && onSuccess) {
|
||
|
|
onSuccess();
|
||
|
|
}
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Bulk invite error:', error);
|
||
|
|
alert('An error occurred while sending invitations');
|
||
|
|
} finally {
|
||
|
|
setProcessing(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleClose = () => {
|
||
|
|
setInviteData({ emails: '', role: 'viewer', message: '' });
|
||
|
|
setResults(null);
|
||
|
|
onClose();
|
||
|
|
};
|
||
|
|
|
||
|
|
const successCount = results ? results.filter(r => r.success).length : 0;
|
||
|
|
const failureCount = results ? results.filter(r => !r.success).length : 0;
|
||
|
|
|
||
|
|
if (!isOpen) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
|
|
<div className="p-6 rounded-2xl shadow-lg max-w-2xl w-full mx-4 max-h-90vh overflow-y-auto" style={{ backgroundColor: 'var(--bg-secondary)' }}>
|
||
|
|
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
Bulk Invite Collaborators
|
||
|
|
</h2>
|
||
|
|
|
||
|
|
{!results ? (
|
||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
Email Addresses
|
||
|
|
</label>
|
||
|
|
<textarea
|
||
|
|
required
|
||
|
|
value={inviteData.emails}
|
||
|
|
onChange={(e) => setInviteData({ ...inviteData, emails: e.target.value })}
|
||
|
|
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-primary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
rows="6"
|
||
|
|
placeholder="Enter email addresses separated by commas, semicolons, or new lines:
|
||
|
|
user1@example.com, user2@example.com
|
||
|
|
user3@example.com
|
||
|
|
user4@example.com"
|
||
|
|
/>
|
||
|
|
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
Separate multiple email addresses with commas, semicolons, or new lines
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
Permission Level
|
||
|
|
</label>
|
||
|
|
<select
|
||
|
|
value={inviteData.role}
|
||
|
|
onChange={(e) => setInviteData({ ...inviteData, role: e.target.value })}
|
||
|
|
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-primary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<option value="viewer">Viewer - Can view cards and collection</option>
|
||
|
|
<option value="editor">Editor - Can add/remove cards and edit details</option>
|
||
|
|
<option value="owner">Owner - Full control including permissions</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
Personal Message (Optional)
|
||
|
|
</label>
|
||
|
|
<textarea
|
||
|
|
value={inviteData.message}
|
||
|
|
onChange={(e) => setInviteData({ ...inviteData, message: e.target.value })}
|
||
|
|
className="w-full px-4 py-3 rounded-lg border transition-all duration-200"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-primary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
rows="3"
|
||
|
|
placeholder="Add a personal message to all invitations..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex space-x-3 mt-6">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={handleClose}
|
||
|
|
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
|
||
|
|
style={{
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-secondary)'
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="submit"
|
||
|
|
disabled={processing || !inviteData.emails.trim()}
|
||
|
|
className="flex-1 px-4 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200 disabled:opacity-50"
|
||
|
|
>
|
||
|
|
{processing ? 'Sending Invitations...' : 'Send Invitations'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="text-center py-4">
|
||
|
|
<div className="text-4xl mb-4">
|
||
|
|
{failureCount === 0 ? '🎉' : '⚠️'}
|
||
|
|
</div>
|
||
|
|
<h3 className="text-xl font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
Invitations Sent
|
||
|
|
</h3>
|
||
|
|
<p style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
{successCount} successful, {failureCount} failed
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="max-h-60 overflow-y-auto space-y-2">
|
||
|
|
{results.map((result, index) => (
|
||
|
|
<div
|
||
|
|
key={index}
|
||
|
|
className="flex items-center justify-between p-3 rounded-lg"
|
||
|
|
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||
|
|
>
|
||
|
|
<div className="flex items-center space-x-3">
|
||
|
|
<span className="text-lg">
|
||
|
|
{result.success ? '✅' : '❌'}
|
||
|
|
</span>
|
||
|
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
{result.email}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
{result.error && (
|
||
|
|
<span className="text-sm text-red-500">
|
||
|
|
{result.error}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex justify-center mt-6">
|
||
|
|
<button
|
||
|
|
onClick={handleClose}
|
||
|
|
className="px-6 py-2 rounded-xl font-medium gradient-bg-purple text-white hover:shadow-lg transition-all duration-200"
|
||
|
|
>
|
||
|
|
Close
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|