deckhearth/components/BulkInviteModal.js
Randall Stillwell b917972f5e 🔄 Simplified Permission Model & UX Improvements
🎯 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! 🚀
2025-07-25 10:29:45 -05:00

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