🔄 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:
Randall Stillwell 2025-07-25 10:29:45 -05:00
parent af7593d884
commit b917972f5e
8 changed files with 88 additions and 108 deletions

View file

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

View file

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

View file

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

View file

@ -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) {

View file

@ -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 *
`;

View file

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

View file

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

View file

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