🔄 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! 🚀
This commit is contained in:
parent
af7593d884
commit
b917972f5e
8 changed files with 88 additions and 108 deletions
|
|
@ -3,7 +3,7 @@ import { useState } from 'react';
|
|||
export default function BulkInviteModal({ collectionId, isOpen, onClose, onSuccess }) {
|
||||
const [inviteData, setInviteData] = useState({
|
||||
emails: '',
|
||||
role: 'viewer',
|
||||
role: 'editor',
|
||||
message: ''
|
||||
});
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
|
@ -63,7 +63,7 @@ export default function BulkInviteModal({ collectionId, isOpen, onClose, onSucce
|
|||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setInviteData({ emails: '', role: 'viewer', message: '' });
|
||||
setInviteData({ emails: '', role: 'editor', message: '' });
|
||||
setResults(null);
|
||||
onClose();
|
||||
};
|
||||
|
|
@ -121,9 +121,8 @@ user4@example.com"
|
|||
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>
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export default function CollaborationManager({ collectionId, isOwner }) {
|
|||
const [showBulkInviteModal, setShowBulkInviteModal] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
role: 'viewer',
|
||||
role: 'editor',
|
||||
message: ''
|
||||
});
|
||||
const [inviting, setInviting] = useState(false);
|
||||
|
|
@ -48,7 +48,7 @@ export default function CollaborationManager({ collectionId, isOwner }) {
|
|||
|
||||
if (response.ok) {
|
||||
setShowInviteModal(false);
|
||||
setInviteForm({ email: '', role: 'viewer', message: '' });
|
||||
setInviteForm({ email: '', role: 'editor', message: '' });
|
||||
fetchPermissions(); // Refresh the list
|
||||
} else {
|
||||
const error = await response.json();
|
||||
|
|
@ -153,15 +153,15 @@ export default function CollaborationManager({ collectionId, isOwner }) {
|
|||
</h3>
|
||||
{isOwner && (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<span>Invite User</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium gradient-bg-purple text-white hover:shadow-lg transform hover:scale-105 transition-all duration-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>Invite Collaborator</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBulkInviteModal(true)}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium border transition-all duration-200 hover:shadow-md"
|
||||
|
|
@ -254,6 +254,9 @@ export default function CollaborationManager({ collectionId, isOwner }) {
|
|||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
Invite Collaborator
|
||||
</h2>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Invite someone to help manage and add cards to this collection
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleInviteUser} className="space-y-4">
|
||||
<div>
|
||||
|
|
@ -289,9 +292,8 @@ export default function CollaborationManager({ collectionId, isOwner }) {
|
|||
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>
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
export default function PermissionIndicator({ userRole, visibility, showTooltip = true }) {
|
||||
export default function PermissionIndicator({ userRole, isPublic, showTooltip = true }) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const getRoleInfo = (role) => {
|
||||
|
|
@ -36,41 +36,26 @@ export default function PermissionIndicator({ userRole, visibility, showTooltip
|
|||
}
|
||||
};
|
||||
|
||||
const getVisibilityInfo = (vis) => {
|
||||
switch (vis) {
|
||||
case 'public':
|
||||
return {
|
||||
icon: '🌍',
|
||||
label: 'Public',
|
||||
color: '#10b981',
|
||||
description: 'Anyone can view this collection'
|
||||
};
|
||||
case 'invite-only':
|
||||
return {
|
||||
icon: '👥',
|
||||
label: 'Invite Only',
|
||||
color: '#f59e0b',
|
||||
description: 'Only invited users can access'
|
||||
};
|
||||
case 'private':
|
||||
return {
|
||||
icon: '🔒',
|
||||
label: 'Private',
|
||||
color: '#6b7280',
|
||||
description: 'Only you can access this collection'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: '❓',
|
||||
label: 'Unknown',
|
||||
color: '#6b7280',
|
||||
description: 'Visibility not set'
|
||||
};
|
||||
const getVisibilityInfo = (isPublic) => {
|
||||
if (isPublic) {
|
||||
return {
|
||||
icon: '🌍',
|
||||
label: 'Public',
|
||||
color: '#10b981',
|
||||
description: 'Visible in community, invite-only editing'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
icon: '🔒',
|
||||
label: 'Private',
|
||||
color: '#6b7280',
|
||||
description: 'Hidden from community, invite-only editing'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const roleInfo = getRoleInfo(userRole);
|
||||
const visibilityInfo = getVisibilityInfo(visibility);
|
||||
const visibilityInfo = getVisibilityInfo(isPublic);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -114,7 +99,7 @@ export default function PermissionIndicator({ userRole, visibility, showTooltip
|
|||
)}
|
||||
|
||||
{/* Visibility Badge */}
|
||||
{visibility && (
|
||||
{isPublic !== undefined && (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="flex items-center space-x-1 px-2 py-1 rounded-full text-xs font-medium cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export async function checkCollectionPermission(collectionId, userId, requiredPe
|
|||
SELECT
|
||||
c.id,
|
||||
c.user_id as owner_id,
|
||||
c.visibility,
|
||||
c.is_public,
|
||||
cp.role,
|
||||
cp.status
|
||||
FROM collections c
|
||||
|
|
@ -69,12 +69,9 @@ export async function checkCollectionPermission(collectionId, userId, requiredPe
|
|||
return { hasAccess: true, role: 'owner', collection };
|
||||
}
|
||||
|
||||
// Public collections - everyone can view
|
||||
if (collection.visibility === 'public') {
|
||||
if (requiredPermission === 'viewer') {
|
||||
return { hasAccess: true, role: 'viewer', collection };
|
||||
}
|
||||
// For edit/delete operations on public collections, need explicit permission
|
||||
// Public collections - everyone can view (but not edit)
|
||||
if (collection.is_public && requiredPermission === 'viewer') {
|
||||
return { hasAccess: true, role: 'viewer', collection };
|
||||
}
|
||||
|
||||
// Check explicit permissions
|
||||
|
|
@ -85,7 +82,7 @@ export async function checkCollectionPermission(collectionId, userId, requiredPe
|
|||
}
|
||||
}
|
||||
|
||||
// Private or invite-only collections without permission
|
||||
// Collections without explicit permission
|
||||
return { hasAccess: false, reason: 'Access denied' };
|
||||
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -17,23 +17,28 @@ export default async function handler(req, res) {
|
|||
// Get user ID from auth (for now, hardcoded to 1)
|
||||
const currentUserId = 1;
|
||||
|
||||
// Get collections based on visibility and user permissions
|
||||
// Get collections based on ownership, collaboration, or public visibility
|
||||
const result = await sql`
|
||||
SELECT DISTINCT
|
||||
c.*,
|
||||
u.email as creator_email,
|
||||
COUNT(cc.card_id) as card_count,
|
||||
COALESCE(SUM(cards.market_price * cc.quantity), 0) as total_value,
|
||||
cp.role as user_role
|
||||
cp.role as user_role,
|
||||
CASE
|
||||
WHEN c.user_id = ${currentUserId} THEN 'owner'
|
||||
WHEN cp.role IS NOT NULL THEN cp.role
|
||||
ELSE NULL
|
||||
END as effective_role
|
||||
FROM collections c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
LEFT JOIN collection_cards cc ON c.id = cc.collection_id
|
||||
LEFT JOIN cards ON cc.card_id = cards.id
|
||||
LEFT JOIN collection_permissions cp ON c.id = cp.collection_id AND cp.user_id = ${currentUserId} AND cp.status = 'active'
|
||||
WHERE
|
||||
c.visibility = 'public' OR
|
||||
c.user_id = ${currentUserId} OR
|
||||
cp.id IS NOT NULL
|
||||
cp.id IS NOT NULL OR
|
||||
(c.is_public = true)
|
||||
GROUP BY c.id, u.email, cp.role
|
||||
ORDER BY c.updated_at DESC
|
||||
`;
|
||||
|
|
@ -47,10 +52,10 @@ export default async function handler(req, res) {
|
|||
value: parseFloat(collection.total_value) || 0,
|
||||
lastViewed: collection.updated_at,
|
||||
createdAt: collection.created_at,
|
||||
visibility: collection.visibility || 'private',
|
||||
isPublic: collection.is_public || false,
|
||||
tags: collection.tags ? collection.tags.split(',') : [],
|
||||
creator: collection.creator_email,
|
||||
userRole: collection.user_role || (collection.user_id === currentUserId ? 'owner' : null)
|
||||
userRole: collection.effective_role
|
||||
}));
|
||||
|
||||
res.status(200).json(collections);
|
||||
|
|
@ -61,22 +66,18 @@ export default async function handler(req, res) {
|
|||
}
|
||||
} else if (req.method === 'POST') {
|
||||
try {
|
||||
const { name, description, tcg = 'MTG', visibility = 'private', image = '', tags = [] } = req.body;
|
||||
const { name, description, tcg = 'MTG', isPublic = false, image = '', tags = [] } = req.body;
|
||||
|
||||
if (!name || !description) {
|
||||
return res.status(400).json({ error: 'Name and description are required' });
|
||||
}
|
||||
|
||||
if (!['private', 'invite-only', 'public'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility type' });
|
||||
}
|
||||
|
||||
// For now, use user_id = 1 (should be from auth token in real implementation)
|
||||
const userId = 1;
|
||||
|
||||
const result = await sql`
|
||||
INSERT INTO collections (name, description, tcg, visibility, image, tags, user_id)
|
||||
VALUES (${name}, ${description}, ${tcg}, ${visibility}, ${image}, ${tags.join(',')}, ${userId})
|
||||
INSERT INTO collections (name, description, tcg, is_public, image, tags, user_id)
|
||||
VALUES (${name}, ${description}, ${tcg}, ${isPublic}, ${image}, ${tags.join(',')}, ${userId})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -72,18 +72,14 @@ async function handler(req, res) {
|
|||
} else if (req.method === 'PUT') {
|
||||
// Update collection - requires editor permissions
|
||||
try {
|
||||
const { name, description, visibility } = req.body;
|
||||
|
||||
if (!['private', 'invite-only', 'public'].includes(visibility)) {
|
||||
return res.status(400).json({ error: 'Invalid visibility type' });
|
||||
}
|
||||
const { name, description, isPublic } = req.body;
|
||||
|
||||
const result = await sql`
|
||||
UPDATE collections
|
||||
SET
|
||||
name = ${name},
|
||||
description = ${description},
|
||||
visibility = ${visibility},
|
||||
is_public = ${isPublic},
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING *
|
||||
|
|
@ -93,7 +89,7 @@ async function handler(req, res) {
|
|||
await logCollectionActivity(id, req.user.userId, 'collection_updated', {
|
||||
name,
|
||||
description,
|
||||
visibility
|
||||
isPublic
|
||||
});
|
||||
|
||||
res.status(200).json(result.rows[0]);
|
||||
|
|
|
|||
|
|
@ -137,22 +137,17 @@ export default async function handler(req, res) {
|
|||
` : ''}
|
||||
|
||||
<div style="margin: 30px 0;">
|
||||
<h3 style="color: #333;">What you can do as a ${role}:</h3>
|
||||
<h3 style="color: #333;">What you can do as a ${role === 'editor' ? 'collaborator' : role}:</h3>
|
||||
<ul style="color: #666; line-height: 1.8;">
|
||||
${role === 'owner' ? `
|
||||
<li>Full control over the collection</li>
|
||||
<li>Add and remove cards</li>
|
||||
<li>Edit collection details</li>
|
||||
<li>Manage permissions and invite others</li>
|
||||
<li>Delete the collection</li>
|
||||
` : role === 'editor' ? `
|
||||
<li>Add and remove cards</li>
|
||||
<li>Edit collection details</li>
|
||||
<li>View all collection content</li>
|
||||
${role === 'editor' ? `
|
||||
<li>Add and remove cards from the collection</li>
|
||||
<li>Edit collection details and description</li>
|
||||
<li>View and search all collection content</li>
|
||||
<li>Help build and organize the collection</li>
|
||||
` : `
|
||||
<li>View all collection content</li>
|
||||
<li>Browse and search cards</li>
|
||||
<li>Export collection data</li>
|
||||
<li>See collection statistics and details</li>
|
||||
`}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export default function Collections() {
|
|||
const [newCollection, setNewCollection] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'private',
|
||||
isPublic: false,
|
||||
image: '',
|
||||
tags: []
|
||||
});
|
||||
|
|
@ -148,7 +148,7 @@ export default function Collections() {
|
|||
body: JSON.stringify({
|
||||
name: newCollection.name,
|
||||
description: newCollection.description,
|
||||
visibility: newCollection.visibility,
|
||||
isPublic: newCollection.isPublic,
|
||||
image: newCollection.image,
|
||||
tags: newCollection.tags
|
||||
})
|
||||
|
|
@ -164,7 +164,7 @@ export default function Collections() {
|
|||
setNewCollection({
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'private',
|
||||
isPublic: false,
|
||||
image: '',
|
||||
tags: []
|
||||
});
|
||||
|
|
@ -328,7 +328,7 @@ export default function Collections() {
|
|||
</h3>
|
||||
<PermissionIndicator
|
||||
userRole={collection.userRole}
|
||||
visibility={collection.visibility}
|
||||
isPublic={collection.isPublic}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -460,19 +460,24 @@ export default function Collections() {
|
|||
Add a hero image for your collection
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary-light)' }}>
|
||||
Visibility
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border" style={{ borderColor: 'var(--border-light)' }}>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-primary-light)' }}>
|
||||
Show in Community
|
||||
</label>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-secondary-light)' }}>
|
||||
Make this collection discoverable by other users
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={newCollection.isPublic}
|
||||
onChange={(e) => setNewCollection({...newCollection, isPublic: e.target.checked})}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={newCollection.visibility}
|
||||
onChange={(e) => setNewCollection({...newCollection, visibility: e.target.value})}
|
||||
>
|
||||
<option value="private">🔒 Private - Only you can access</option>
|
||||
<option value="invite-only">👥 Invite Only - Controlled collaboration</option>
|
||||
<option value="public">🌍 Public - Anyone can view</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3 mt-6">
|
||||
|
|
|
|||
Loading…
Reference in a new issue