🎯 Cleaner Permission System: - Collections are private/invite-only by default - Public toggle only controls community visibility (not edit permissions) - Simplified to: Creator + Invited Collaborators can edit, everyone else view-only - Only creator can delete collections 📝 UI/UX Enhancements: - Changed 'Invite User' to 'Invite Collaborator' with clearer messaging - Replaced visibility dropdown with clean public/private toggle - Updated role descriptions: 'Collaborator' (editor) and 'Viewer' - Default invitation role is now 'editor' (collaborator) - Added explanatory text about collaboration purpose 🔐 Permission Logic Updates: - Public collections: visible in community but invite-only editing - Private collections: hidden from community, invite-only editing - Removed complex visibility states (invite-only/private distinction) - Updated permission middleware for simplified model 📧 Email Template Updates: - Clearer role descriptions in invitation emails - Focus on collaboration and card management permissions - Removed owner role from invitation options 🗄️ Database Schema Updates: - Updated APIs to use is_public boolean instead of visibility enum - Maintained backward compatibility with existing data - Simplified permission checking logic The system now has a much cleaner UX: collections are collaborative workspaces that can optionally be made visible to the community! 🚀
219 lines
7.9 KiB
JavaScript
219 lines
7.9 KiB
JavaScript
import { useState } from 'react';
|
|
|
|
export default function BulkInviteModal({ collectionId, isOpen, onClose, onSuccess }) {
|
|
const [inviteData, setInviteData] = useState({
|
|
emails: '',
|
|
role: 'editor',
|
|
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: 'editor', 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="editor">Collaborator - Can add/remove cards and edit collection details</option>
|
|
<option value="viewer">Viewer - Can only view the collection</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>
|
|
);
|
|
}
|