feat: flip permissions model - everyone edits by default, protected boards opt-in
NEW PERMISSION MODEL: - All users (including anonymous) can now EDIT by default - Boards can be marked as "protected" by admin - only listed editors can edit - Global admins (jeffemmett@gmail.com) have admin on ALL boards - Added BoardSettingsDropdown with view-only toggle for admins Backend changes: - Added is_protected column to boards table - Added global_admins table - New getEffectivePermission logic prioritizes: token > global admin > owner > protection status - New API endpoints: /auth/global-admin-status, /admin/request, /boards/:id/info, /boards/:id/editors - Admin request sends email via Resend API Frontend changes: - BoardSettingsDropdown component with protection toggle and editor management - Updated AuthContext and Board.tsx to default to 'edit' permission - isReadOnly now only true for protected boards where user is not an editor Task: task-052 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9276d85709
commit
52503167c8
|
|
@ -0,0 +1,550 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { WORKER_URL } from '../constants/workerUrl';
|
||||
import * as crypto from '../lib/auth/crypto';
|
||||
|
||||
interface BoardSettingsDropdownProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface BoardInfo {
|
||||
id: string;
|
||||
name: string | null;
|
||||
isProtected: boolean;
|
||||
ownerUsername: string | null;
|
||||
}
|
||||
|
||||
interface Editor {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
permission: string;
|
||||
grantedAt: string;
|
||||
}
|
||||
|
||||
const BoardSettingsDropdown: React.FC<BoardSettingsDropdownProps> = ({ className = '' }) => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { session } = useAuth();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [boardInfo, setBoardInfo] = useState<BoardInfo | null>(null);
|
||||
const [editors, setEditors] = useState<Editor[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [isGlobalAdmin, setIsGlobalAdmin] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [requestingAdmin, setRequestingAdmin] = useState(false);
|
||||
const [adminRequestSent, setAdminRequestSent] = useState(false);
|
||||
const [inviteInput, setInviteInput] = useState('');
|
||||
const [inviteStatus, setInviteStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
|
||||
const boardId = slug || 'mycofi33';
|
||||
|
||||
// Get auth headers
|
||||
const getAuthHeaders = (): Record<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (session.authed && session.username) {
|
||||
const publicKey = crypto.getPublicKey(session.username);
|
||||
if (publicKey) {
|
||||
headers['X-CryptID-PublicKey'] = publicKey;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
// Fetch board info and admin status
|
||||
const fetchBoardData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
|
||||
// Fetch board info
|
||||
const infoRes = await fetch(`${WORKER_URL}/boards/${boardId}/info`, { headers });
|
||||
const infoData = await infoRes.json();
|
||||
if (infoData.board) {
|
||||
setBoardInfo(infoData.board);
|
||||
}
|
||||
|
||||
// Fetch permission to check if admin
|
||||
const permRes = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, { headers });
|
||||
const permData = await permRes.json();
|
||||
setIsAdmin(permData.permission === 'admin');
|
||||
setIsGlobalAdmin(permData.isGlobalAdmin || false);
|
||||
|
||||
// If admin, fetch editors list
|
||||
if (permData.permission === 'admin') {
|
||||
const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers });
|
||||
const editorsData = await editorsRes.json();
|
||||
setEditors(editorsData.editors || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch board data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle board protection
|
||||
const toggleProtection = async () => {
|
||||
if (!boardInfo || updating) return;
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
const res = await fetch(`${WORKER_URL}/boards/${boardId}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ isProtected: !boardInfo.isProtected }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setBoardInfo(prev => prev ? { ...prev, isProtected: !prev.isProtected } : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle protection:', error);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Request admin access
|
||||
const requestAdminAccess = async () => {
|
||||
if (requestingAdmin || adminRequestSent) return;
|
||||
|
||||
setRequestingAdmin(true);
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
const res = await fetch(`${WORKER_URL}/admin/request`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ reason: `Requesting admin access for board: ${boardId}` }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setAdminRequestSent(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to request admin:', error);
|
||||
} finally {
|
||||
setRequestingAdmin(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Invite user as editor
|
||||
const inviteEditor = async () => {
|
||||
if (!inviteInput.trim() || inviteStatus === 'sending') return;
|
||||
|
||||
setInviteStatus('sending');
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
const res = await fetch(`${WORKER_URL}/boards/${boardId}/permissions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
usernameOrEmail: inviteInput.trim(),
|
||||
permission: 'edit',
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setInviteStatus('sent');
|
||||
setInviteInput('');
|
||||
// Refresh editors list
|
||||
fetchBoardData();
|
||||
setTimeout(() => setInviteStatus('idle'), 2000);
|
||||
} else {
|
||||
setInviteStatus('error');
|
||||
setTimeout(() => setInviteStatus('idle'), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to invite editor:', error);
|
||||
setInviteStatus('error');
|
||||
setTimeout(() => setInviteStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove editor
|
||||
const removeEditor = async (userId: string) => {
|
||||
try {
|
||||
const headers = getAuthHeaders();
|
||||
await fetch(`${WORKER_URL}/boards/${boardId}/permissions/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
setEditors(prev => prev.filter(e => e.userId !== userId));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove editor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Update dropdown position when it opens
|
||||
useEffect(() => {
|
||||
if (showDropdown && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
fetchBoardData();
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target);
|
||||
const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target);
|
||||
if (!isInsideTrigger && !isInsideMenu) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [showDropdown]);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} style={{ pointerEvents: 'all' }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={`board-settings-button ${className}`}
|
||||
title="Board Settings"
|
||||
style={{
|
||||
background: showDropdown ? 'var(--color-muted-2)' : 'none',
|
||||
border: 'none',
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-1)',
|
||||
opacity: showDropdown ? 1 : 0.7,
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!showDropdown) {
|
||||
e.currentTarget.style.opacity = '0.7';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Settings gear icon */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{showDropdown && dropdownPosition && createPortal(
|
||||
<div
|
||||
ref={dropdownMenuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: dropdownPosition.top,
|
||||
right: dropdownPosition.right,
|
||||
width: '320px',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--color-panel)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
zIndex: 100000,
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
borderBottom: '1px solid var(--color-panel-contrast)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '14px' }}>⚙</span> Board Settings
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowDropdown(false)}
|
||||
style={{
|
||||
background: 'var(--color-muted-2)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--color-text-3)',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'inherit',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--color-text-3)' }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
|
||||
{/* Board Info */}
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
|
||||
Board Info
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text)' }}>
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<strong>ID:</strong> {boardId}
|
||||
</div>
|
||||
{boardInfo?.ownerUsername && (
|
||||
<div style={{ marginBottom: '4px' }}>
|
||||
<strong>Owner:</strong> @{boardInfo.ownerUsername}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<strong>Status:</strong>
|
||||
<span style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
background: boardInfo?.isProtected ? '#fef3c7' : '#d1fae5',
|
||||
color: boardInfo?.isProtected ? '#92400e' : '#065f46',
|
||||
}}>
|
||||
{boardInfo?.isProtected ? 'Protected (View-only)' : 'Open (Anyone can edit)'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Section */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div style={{ borderTop: '1px solid var(--color-panel-contrast)', paddingTop: '12px' }}>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
|
||||
Protection Settings {isGlobalAdmin && <span style={{ color: '#3b82f6' }}>(Global Admin)</span>}
|
||||
</div>
|
||||
|
||||
{/* Protection Toggle */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--color-muted-2)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
View-only Mode
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: 'var(--color-text-3)' }}>
|
||||
Only listed editors can make changes
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleProtection}
|
||||
disabled={updating}
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '24px',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
cursor: updating ? 'not-allowed' : 'pointer',
|
||||
background: boardInfo?.isProtected ? '#3b82f6' : '#d1d5db',
|
||||
position: 'relative',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '10px',
|
||||
background: 'white',
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
left: boardInfo?.isProtected ? '22px' : '2px',
|
||||
transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Management (only when protected) */}
|
||||
{boardInfo?.isProtected && (
|
||||
<div>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
|
||||
Editors ({editors.length})
|
||||
</div>
|
||||
|
||||
{/* Add Editor Input */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username or email..."
|
||||
value={inviteInput}
|
||||
onChange={(e) => setInviteInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') inviteEditor();
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--color-panel)',
|
||||
color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={inviteEditor}
|
||||
disabled={!inviteInput.trim() || inviteStatus === 'sending'}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
backgroundColor: inviteStatus === 'sent' ? '#10b981' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: !inviteInput.trim() || inviteStatus === 'sending' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
opacity: !inviteInput.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{inviteStatus === 'sending' ? '...' : inviteStatus === 'sent' ? 'Added' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor List */}
|
||||
<div style={{ maxHeight: '150px', overflowY: 'auto' }}>
|
||||
{editors.length === 0 ? (
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', textAlign: 'center', padding: '10px' }}>
|
||||
No editors added yet
|
||||
</div>
|
||||
) : (
|
||||
editors.map((editor) => (
|
||||
<div
|
||||
key={editor.userId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '4px',
|
||||
background: 'var(--color-muted-2)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
@{editor.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: 'var(--color-text-3)' }}>
|
||||
{editor.permission}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeEditor(editor.userId)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#ef4444',
|
||||
fontSize: '14px',
|
||||
padding: '4px',
|
||||
}}
|
||||
title="Remove editor"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Request Admin Access (for non-admins) */}
|
||||
{!isAdmin && session.authed && (
|
||||
<div style={{ borderTop: '1px solid var(--color-panel-contrast)', paddingTop: '12px' }}>
|
||||
<button
|
||||
onClick={requestAdminAccess}
|
||||
disabled={requestingAdmin || adminRequestSent}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
backgroundColor: adminRequestSent ? '#10b981' : 'var(--color-muted-2)',
|
||||
color: adminRequestSent ? 'white' : 'var(--color-text)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '8px',
|
||||
cursor: requestingAdmin || adminRequestSent ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{requestingAdmin ? 'Sending request...' : adminRequestSent ? 'Request Sent!' : 'Request Admin Access'}
|
||||
</button>
|
||||
<div style={{ fontSize: '10px', color: 'var(--color-text-3)', marginTop: '6px', textAlign: 'center' }}>
|
||||
Admin requests are sent to jeffemmett@gmail.com
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sign in prompt for anonymous users */}
|
||||
{!session.authed && (
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', textAlign: 'center', padding: '10px' }}>
|
||||
Sign in to access board settings
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSettingsDropdown;
|
||||
|
|
@ -295,10 +295,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch board permission:', response.status);
|
||||
// Default to 'edit' for authenticated users, 'view' for unauthenticated
|
||||
const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
|
||||
console.log('🔐 Using default permission (API failed):', defaultPermission);
|
||||
return defaultPermission;
|
||||
// NEW: Default to 'edit' for everyone (open by default)
|
||||
console.log('🔐 Using default permission (API failed): edit');
|
||||
return 'edit';
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
|
|
@ -307,6 +306,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
boardExists: boolean;
|
||||
grantedByToken?: boolean;
|
||||
isExplicitPermission?: boolean; // Whether this permission was explicitly set
|
||||
isProtected?: boolean; // Whether board is in protected mode
|
||||
isGlobalAdmin?: boolean; // Whether user is global admin
|
||||
};
|
||||
|
||||
// Debug: Log what we received
|
||||
|
|
@ -315,19 +316,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
if (data.grantedByToken) {
|
||||
console.log('🔓 Permission granted via access token:', data.permission);
|
||||
}
|
||||
if (data.isGlobalAdmin) {
|
||||
console.log('🔓 User is global admin');
|
||||
}
|
||||
|
||||
// Determine effective permission
|
||||
// If authenticated user and board doesn't have explicit permissions set,
|
||||
// default to 'edit' instead of 'view'
|
||||
// NEW PERMISSION MODEL (Dec 2024):
|
||||
// - Everyone (including anonymous) can EDIT by default
|
||||
// - Only protected boards restrict editing to listed editors
|
||||
// The backend now returns the correct permission, so we just use it directly
|
||||
let effectivePermission = data.permission;
|
||||
|
||||
if (session.authed && data.permission === 'view') {
|
||||
// If board doesn't exist in permission system or permission isn't explicitly set,
|
||||
// authenticated users should get edit access by default
|
||||
if (!data.boardExists || data.isExplicitPermission === false) {
|
||||
effectivePermission = 'edit';
|
||||
console.log('🔓 Upgrading to edit: authenticated user with no explicit view restriction');
|
||||
}
|
||||
// Log why view permission was given (for debugging protected boards)
|
||||
if (data.permission === 'view' && data.isProtected) {
|
||||
console.log('🔒 View-only: board is protected and user is not an editor');
|
||||
}
|
||||
|
||||
// Cache the permission
|
||||
|
|
@ -343,24 +344,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
return effectivePermission;
|
||||
} catch (error) {
|
||||
console.error('Error fetching board permission:', error);
|
||||
// Default to 'edit' for authenticated users, 'view' for unauthenticated
|
||||
const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
|
||||
console.log('🔐 Using default permission (error):', defaultPermission);
|
||||
return defaultPermission;
|
||||
// NEW: Default to 'edit' for everyone (open by default)
|
||||
console.log('🔐 Using default permission (error): edit');
|
||||
return 'edit';
|
||||
}
|
||||
}, [session.authed, session.username, session.boardPermissions, accessToken]);
|
||||
|
||||
/**
|
||||
* Check if user can edit the current board
|
||||
* NEW: Returns true by default (open permission model)
|
||||
*/
|
||||
const canEdit = useCallback((): boolean => {
|
||||
const permission = session.currentBoardPermission;
|
||||
if (!permission) {
|
||||
// If no permission set, default based on auth status
|
||||
return session.authed;
|
||||
// NEW: If no permission set, default to edit (open by default)
|
||||
return true;
|
||||
}
|
||||
return permission === 'edit' || permission === 'admin';
|
||||
}, [session.currentBoardPermission, session.authed]);
|
||||
}, [session.currentBoardPermission]);
|
||||
|
||||
/**
|
||||
* Check if user is admin for the current board
|
||||
|
|
|
|||
|
|
@ -321,9 +321,9 @@ export function Board() {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch permission:', error)
|
||||
// Default to view for unauthenticated, edit for authenticated
|
||||
// NEW: Default to 'edit' for everyone (open by default)
|
||||
if (mounted) {
|
||||
setPermission(session.authed ? 'edit' : 'view')
|
||||
setPermission('edit')
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
|
|
@ -340,23 +340,23 @@ export function Board() {
|
|||
}, [roomId, fetchBoardPermission, session.authed])
|
||||
|
||||
// Check if user can edit
|
||||
// Authenticated users get edit access by default unless explicitly restricted to 'view'
|
||||
// Unauthenticated users are always read-only
|
||||
// Note: permission will be 'edit' for authenticated users by default (see AuthContext)
|
||||
// NEW PERMISSION MODEL (Dec 2024):
|
||||
// - Everyone (including anonymous) can EDIT by default
|
||||
// - Only protected boards restrict editing to listed editors
|
||||
// - Permission 'view' means the board is protected and user is not an editor
|
||||
//
|
||||
// CRITICAL: Don't restrict in these cases:
|
||||
// 1. Auth is loading
|
||||
// 1. Auth/permission is loading
|
||||
// 2. Auth just changed (React effects haven't run yet, permission state is stale)
|
||||
// 3. Permission is loading for authenticated users
|
||||
// This prevents authenticated users from briefly seeing read-only mode which hides
|
||||
// This prevents users from briefly seeing read-only mode which hides
|
||||
// default tools (only tools with readonlyOk: true show in read-only mode)
|
||||
const isReadOnly = (
|
||||
session.loading ||
|
||||
(session.authed && authJustChanged) || // Auth just changed, permission is stale
|
||||
(session.authed && permissionLoading)
|
||||
authJustChanged || // Auth just changed, permission is stale
|
||||
permissionLoading
|
||||
)
|
||||
? false // Don't restrict while loading/transitioning - assume authenticated users can edit
|
||||
: (!session.authed || permission === 'view')
|
||||
? false // Don't restrict while loading/transitioning - assume can edit
|
||||
: permission === 'view' // Only restrict if explicitly view (protected board)
|
||||
|
||||
// Debug logging for permission issues
|
||||
console.log('🔐 Permission Debug:', {
|
||||
|
|
@ -369,15 +369,13 @@ export function Board() {
|
|||
isReadOnly,
|
||||
reason: session.loading
|
||||
? 'auth loading - allowing edit temporarily'
|
||||
: (session.authed && authJustChanged)
|
||||
: authJustChanged
|
||||
? 'auth just changed - allowing edit until effects run'
|
||||
: (session.authed && permissionLoading)
|
||||
? 'permission loading for authenticated user - allowing edit temporarily'
|
||||
: !session.authed
|
||||
? 'not authenticated - view only mode'
|
||||
: permission === 'view'
|
||||
? 'explicitly restricted to view-only by board admin'
|
||||
: 'authenticated with edit access'
|
||||
: permissionLoading
|
||||
? 'permission loading - allowing edit temporarily'
|
||||
: permission === 'view'
|
||||
? 'protected board - user not an editor (view-only)'
|
||||
: 'open board or user is editor (can edit)'
|
||||
})
|
||||
|
||||
// Handler for when user tries to edit in read-only mode
|
||||
|
|
@ -463,18 +461,29 @@ export function Board() {
|
|||
colorScheme: getColorScheme(),
|
||||
}))
|
||||
|
||||
// Update user preferences when session changes
|
||||
// Update user preferences when session changes (handles both login and logout)
|
||||
useEffect(() => {
|
||||
if (uniqueUserId) {
|
||||
if (session.authed && uniqueUserId) {
|
||||
// Authenticated user - use their unique ID and username
|
||||
setUserPreferences({
|
||||
id: uniqueUserId,
|
||||
name: session.username || 'Anonymous',
|
||||
// Use session.username for color consistency across sessions
|
||||
color: session.username ? generateUserColor(session.username) : generateUserColor(uniqueUserId),
|
||||
colorScheme: getColorScheme(),
|
||||
})
|
||||
console.log('🔐 User preferences set for authenticated user:', session.username)
|
||||
} else {
|
||||
// Not authenticated - reset to anonymous with fresh ID
|
||||
const anonymousId = `anonymous-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
setUserPreferences({
|
||||
id: anonymousId,
|
||||
name: 'Anonymous',
|
||||
color: '#6b7280', // Gray for anonymous
|
||||
colorScheme: getColorScheme(),
|
||||
})
|
||||
console.log('🔐 User preferences reset to anonymous')
|
||||
}
|
||||
}, [uniqueUserId, session.username])
|
||||
}, [uniqueUserId, session.username, session.authed])
|
||||
|
||||
// Listen for dark mode changes and update tldraw color scheme
|
||||
useEffect(() => {
|
||||
|
|
@ -540,6 +549,28 @@ export function Board() {
|
|||
}
|
||||
}, [editor, isReadOnly])
|
||||
|
||||
// Listen for session-logged-in event to immediately enable editing
|
||||
// This handles the case where the React state update might be delayed
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
const handleSessionLoggedIn = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ username: string }>;
|
||||
console.log('🔐 Board: session-logged-in event received for:', customEvent.detail.username);
|
||||
|
||||
// Immediately enable editing - user just logged in
|
||||
editor.updateInstanceState({ isReadonly: false });
|
||||
|
||||
// Switch to select tool to ensure tools are available
|
||||
editor.setCurrentTool('select');
|
||||
|
||||
console.log('🔓 Board: Enabled editing mode after login');
|
||||
};
|
||||
|
||||
window.addEventListener('session-logged-in', handleSessionLoggedIn);
|
||||
return () => window.removeEventListener('session-logged-in', handleSessionLoggedIn);
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
const value = localStorage.getItem("makereal_settings_2")
|
||||
if (value) {
|
||||
|
|
@ -936,7 +967,7 @@ export function Board() {
|
|||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
const cleanupStalePresences = () => {
|
||||
const cleanupStalePresences = (forceCleanAll = false) => {
|
||||
try {
|
||||
const allRecords = editor.store.allRecords()
|
||||
const presenceRecords = allRecords.filter((r: any) =>
|
||||
|
|
@ -945,16 +976,32 @@ export function Board() {
|
|||
)
|
||||
|
||||
if (presenceRecords.length > 0) {
|
||||
// Filter out stale presences (older than 30 seconds)
|
||||
const now = Date.now()
|
||||
const staleThreshold = 30 * 1000 // 30 seconds
|
||||
const stalePresences = presenceRecords.filter((r: any) =>
|
||||
r.lastActivityTimestamp && (now - r.lastActivityTimestamp > staleThreshold)
|
||||
)
|
||||
if (forceCleanAll) {
|
||||
// On logout/auth change, remove ALL presence records except our current one
|
||||
// This prevents double-registration issues
|
||||
const currentUserId = uniqueUserId || userPreferences.id
|
||||
const presencesToRemove = presenceRecords.filter((r: any) => {
|
||||
// Remove presences that don't match our current identity
|
||||
const presenceUserId = r.userId || r.id?.split(':')[1]
|
||||
return presenceUserId !== currentUserId
|
||||
})
|
||||
|
||||
if (stalePresences.length > 0) {
|
||||
console.log(`🧹 Cleaning up ${stalePresences.length} stale presence record(s)`)
|
||||
editor.store.remove(stalePresences.map((r: any) => r.id))
|
||||
if (presencesToRemove.length > 0) {
|
||||
console.log(`🧹 Force cleaning ${presencesToRemove.length} non-current presence record(s) on auth change`)
|
||||
editor.store.remove(presencesToRemove.map((r: any) => r.id))
|
||||
}
|
||||
} else {
|
||||
// Filter out stale presences (older than 30 seconds)
|
||||
const now = Date.now()
|
||||
const staleThreshold = 30 * 1000 // 30 seconds
|
||||
const stalePresences = presenceRecords.filter((r: any) =>
|
||||
r.lastActivityTimestamp && (now - r.lastActivityTimestamp > staleThreshold)
|
||||
)
|
||||
|
||||
if (stalePresences.length > 0) {
|
||||
console.log(`🧹 Cleaning up ${stalePresences.length} stale presence record(s)`)
|
||||
editor.store.remove(stalePresences.map((r: any) => r.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -962,14 +1009,63 @@ export function Board() {
|
|||
}
|
||||
}
|
||||
|
||||
// Clean up immediately on auth change
|
||||
cleanupStalePresences()
|
||||
// Clean up ALL non-current presences on auth change to prevent double-registration
|
||||
cleanupStalePresences(true)
|
||||
|
||||
// Also run periodic cleanup every 15 seconds
|
||||
const cleanupInterval = setInterval(cleanupStalePresences, 15000)
|
||||
// Also run periodic cleanup every 15 seconds (only stale ones)
|
||||
const cleanupInterval = setInterval(() => cleanupStalePresences(false), 15000)
|
||||
|
||||
return () => clearInterval(cleanupInterval)
|
||||
}, [editor, session.authed, session.username])
|
||||
// Listen for session-cleared event to clean up ONLY the current user's presence
|
||||
// We keep the same tldraw user ID across login/logout, so we only need to remove
|
||||
// this user's presence when they log out (they'll get a fresh one on login)
|
||||
const handleSessionCleared = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ previousUsername: string }>;
|
||||
const previousUsername = customEvent.detail?.previousUsername;
|
||||
console.log('🧹 Session cleared event received for user:', previousUsername)
|
||||
|
||||
if (!previousUsername) {
|
||||
console.log('🧹 No previous username, skipping presence cleanup')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the tldraw user ID for the user who just logged out
|
||||
const storageKey = `tldraw-user-id-${previousUsername}`;
|
||||
const previousUserId = localStorage.getItem(storageKey);
|
||||
|
||||
if (!previousUserId) {
|
||||
console.log('🧹 No tldraw user ID found for', previousUsername)
|
||||
return
|
||||
}
|
||||
|
||||
const allRecords = editor.store.allRecords()
|
||||
const presenceRecords = allRecords.filter((r: any) =>
|
||||
r.typeName === 'instance_presence' ||
|
||||
r.id?.startsWith('instance_presence:')
|
||||
)
|
||||
|
||||
// Only remove presence records that belong to the user who just logged out
|
||||
const userPresences = presenceRecords.filter((r: any) => {
|
||||
const presenceUserId = r.userId || r.id?.split(':')[1]
|
||||
return presenceUserId === previousUserId || r.userName === previousUsername
|
||||
})
|
||||
|
||||
if (userPresences.length > 0) {
|
||||
console.log(`🧹 Removing ${userPresences.length} presence record(s) for logged-out user: ${previousUsername}`)
|
||||
editor.store.remove(userPresences.map((r: any) => r.id))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning presences on session clear:', error)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('session-cleared', handleSessionCleared)
|
||||
|
||||
return () => {
|
||||
clearInterval(cleanupInterval)
|
||||
window.removeEventListener('session-cleared', handleSessionCleared)
|
||||
}
|
||||
}, [editor, session.authed, session.username, uniqueUserId, userPreferences.id])
|
||||
|
||||
// Update TLDraw user preferences when editor is available and user is authenticated
|
||||
useEffect(() => {
|
||||
|
|
@ -1324,16 +1420,35 @@ export function Board() {
|
|||
// MycelialIntelligence is now a permanent UI bar - no shape creation needed
|
||||
|
||||
// Set read-only mode based on auth state
|
||||
// IMPORTANT: Use session.authed directly here, not the isReadOnly variable
|
||||
// The isReadOnly variable might have stale values due to React's timing (effects run after render)
|
||||
// For authenticated users, we assume editable until permission proves otherwise
|
||||
// The effect that watches isReadOnly will update this if user only has 'view' permission
|
||||
const initialReadOnly = !session.authed
|
||||
// IMPORTANT: Check localStorage directly to avoid stale closure issues
|
||||
// The React state (session.authed) might be stale in this callback due to
|
||||
// the complex timing of remounts triggered by auth changes
|
||||
const checkAuthFromStorage = (): boolean => {
|
||||
try {
|
||||
const stored = localStorage.getItem('canvas_auth_session');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.authed === true && !!parsed.username;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isAuthenticated = checkAuthFromStorage();
|
||||
const initialReadOnly = !isAuthenticated;
|
||||
editor.updateInstanceState({ isReadonly: initialReadOnly })
|
||||
console.log('🔄 onMount: session.authed =', session.authed, ', setting isReadonly =', initialReadOnly)
|
||||
console.log('🔄 onMount: isAuthenticated (from storage) =', isAuthenticated, ', setting isReadonly =', initialReadOnly)
|
||||
console.log(initialReadOnly
|
||||
? '🔒 Board is in read-only mode (not authenticated)'
|
||||
: '🔓 Board is editable (authenticated)')
|
||||
|
||||
// Also ensure the current tool is appropriate for the mode
|
||||
if (!initialReadOnly) {
|
||||
// If editable, make sure we can use tools - set to select tool which is always available
|
||||
editor.setCurrentTool('select')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CmdK />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,57 @@
|
|||
import { Environment, Board, BoardPermission, PermissionLevel, PermissionCheckResult, User, BoardAccessToken } from './types';
|
||||
import { Environment, Board, BoardPermission, PermissionLevel, PermissionCheckResult, User, BoardAccessToken, GlobalAdmin } from './types';
|
||||
|
||||
// Generate a UUID v4
|
||||
function generateUUID(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a global admin by their email
|
||||
* Global admins have admin access to ALL boards
|
||||
*/
|
||||
export async function isGlobalAdmin(db: D1Database, userId: string): Promise<boolean> {
|
||||
// Get user's email
|
||||
const user = await db.prepare(
|
||||
'SELECT email FROM users WHERE id = ?'
|
||||
).bind(userId).first<{ email: string }>();
|
||||
|
||||
if (!user?.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if email is in global_admins table
|
||||
const admin = await db.prepare(
|
||||
'SELECT email FROM global_admins WHERE email = ?'
|
||||
).bind(user.email).first<GlobalAdmin>();
|
||||
|
||||
return !!admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an email is a global admin (direct check without user lookup)
|
||||
*/
|
||||
export async function isEmailGlobalAdmin(db: D1Database, email: string): Promise<boolean> {
|
||||
const admin = await db.prepare(
|
||||
'SELECT email FROM global_admins WHERE email = ?'
|
||||
).bind(email).first<GlobalAdmin>();
|
||||
|
||||
return !!admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's effective permission for a board
|
||||
* Priority: access token > explicit permission > board owner (admin) > default permission
|
||||
*
|
||||
* NEW PERMISSION MODEL (Dec 2024):
|
||||
* - Everyone (including anonymous) can EDIT by default
|
||||
* - Boards can be marked as "protected" - only listed editors can edit protected boards
|
||||
* - Global admins have admin access to ALL boards
|
||||
*
|
||||
* Priority:
|
||||
* 1. Access token from share link (overrides all)
|
||||
* 2. Global admin status (returns 'admin')
|
||||
* 3. Board owner (returns 'admin')
|
||||
* 4. If board is NOT protected → everyone gets 'edit'
|
||||
* 5. If board IS protected → check explicit permissions, default to 'view'
|
||||
*
|
||||
* @param accessToken - Optional access token from share link (grants specific permission)
|
||||
*/
|
||||
|
|
@ -22,7 +66,7 @@ export async function getEffectivePermission(
|
|||
'SELECT * FROM boards WHERE id = ?'
|
||||
).bind(boardId).first<Board>();
|
||||
|
||||
// If an access token is provided, validate it and use its permission level
|
||||
// 1. If an access token is provided, validate it and use its permission level
|
||||
if (accessToken) {
|
||||
const tokenPermission = await validateAccessToken(db, boardId, accessToken);
|
||||
if (tokenPermission) {
|
||||
|
|
@ -35,28 +79,32 @@ export async function getEffectivePermission(
|
|||
}
|
||||
}
|
||||
|
||||
// 2. Check if user is a global admin (admin on ALL boards)
|
||||
if (userId) {
|
||||
const globalAdmin = await isGlobalAdmin(db, userId);
|
||||
if (globalAdmin) {
|
||||
console.log('🔐 User is global admin, granting admin access');
|
||||
return {
|
||||
permission: 'admin',
|
||||
isOwner: false,
|
||||
boardExists: !!board,
|
||||
isGlobalAdmin: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Board doesn't exist in permissions DB
|
||||
// Anonymous users get VIEW by default, authenticated users can edit
|
||||
// NEW: Everyone can edit by default (board will be created on first edit)
|
||||
if (!board) {
|
||||
return {
|
||||
permission: userId ? 'edit' : 'view',
|
||||
permission: 'edit',
|
||||
isOwner: false,
|
||||
boardExists: false
|
||||
};
|
||||
}
|
||||
|
||||
// If user is not authenticated, return VIEW (secure by default)
|
||||
// To grant edit access to anonymous users, they must use a share link with access token
|
||||
if (!userId) {
|
||||
return {
|
||||
permission: 'view',
|
||||
isOwner: false,
|
||||
boardExists: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is the board owner (always admin)
|
||||
if (board.owner_id === userId) {
|
||||
// 3. Check if user is the board owner (always admin)
|
||||
if (userId && board.owner_id === userId) {
|
||||
return {
|
||||
permission: 'admin',
|
||||
isOwner: true,
|
||||
|
|
@ -64,27 +112,40 @@ export async function getEffectivePermission(
|
|||
};
|
||||
}
|
||||
|
||||
// Check for explicit user-specific permission
|
||||
const explicitPerm = await db.prepare(
|
||||
'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?'
|
||||
).bind(boardId, userId).first<BoardPermission>();
|
||||
|
||||
if (explicitPerm) {
|
||||
// User has a specific permission set - use it (could be view, edit, or admin)
|
||||
// 4. If board is NOT protected, everyone can edit (NEW DEFAULT)
|
||||
if (!board.is_protected) {
|
||||
return {
|
||||
permission: explicitPerm.permission,
|
||||
permission: 'edit',
|
||||
isOwner: false,
|
||||
boardExists: true
|
||||
boardExists: true,
|
||||
isProtected: false
|
||||
};
|
||||
}
|
||||
|
||||
// No explicit permission for this user
|
||||
// Authenticated users get 'edit' by default
|
||||
// (Board's default_permission only affects anonymous users with access tokens)
|
||||
// 5. Board IS protected - check for explicit permission
|
||||
if (userId) {
|
||||
const explicitPerm = await db.prepare(
|
||||
'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?'
|
||||
).bind(boardId, userId).first<BoardPermission>();
|
||||
|
||||
if (explicitPerm) {
|
||||
// User has been granted specific permission on this protected board
|
||||
return {
|
||||
permission: explicitPerm.permission,
|
||||
isOwner: false,
|
||||
boardExists: true,
|
||||
isProtected: true,
|
||||
isExplicitPermission: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Protected board, no explicit permission → view only
|
||||
return {
|
||||
permission: 'edit',
|
||||
permission: 'view',
|
||||
isOwner: false,
|
||||
boardExists: true
|
||||
boardExists: true,
|
||||
isProtected: true
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -499,7 +560,7 @@ export async function handleRevokePermission(
|
|||
/**
|
||||
* PATCH /boards/:boardId
|
||||
* Update board settings (admin only)
|
||||
* Body: { name?, defaultPermission?, isPublic? }
|
||||
* Body: { name?, defaultPermission?, isPublic?, isProtected? }
|
||||
*/
|
||||
export async function handleUpdateBoard(
|
||||
boardId: string,
|
||||
|
|
@ -548,6 +609,7 @@ export async function handleUpdateBoard(
|
|||
name?: string;
|
||||
defaultPermission?: 'view' | 'edit';
|
||||
isPublic?: boolean;
|
||||
isProtected?: boolean;
|
||||
};
|
||||
|
||||
const updates: string[] = [];
|
||||
|
|
@ -571,6 +633,11 @@ export async function handleUpdateBoard(
|
|||
updates.push('is_public = ?');
|
||||
values.push(body.isPublic ? 1 : 0);
|
||||
}
|
||||
if (body.isProtected !== undefined) {
|
||||
updates.push('is_protected = ?');
|
||||
values.push(body.isProtected ? 1 : 0);
|
||||
console.log(`🔒 Board ${boardId} protection set to: ${body.isProtected}`);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'No updates provided' }), {
|
||||
|
|
@ -597,6 +664,7 @@ export async function handleUpdateBoard(
|
|||
name: updatedBoard.name,
|
||||
defaultPermission: updatedBoard.default_permission,
|
||||
isPublic: updatedBoard.is_public === 1,
|
||||
isProtected: updatedBoard.is_protected === 1,
|
||||
updatedAt: updatedBoard.updated_at
|
||||
} : null
|
||||
}), {
|
||||
|
|
@ -916,3 +984,308 @@ export async function handleRevokeAccessToken(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Global Admin & Protected Board Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* GET /auth/global-admin-status
|
||||
* Check if the current user is a global admin
|
||||
*/
|
||||
export async function handleGetGlobalAdminStatus(
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ isGlobalAdmin: false }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ isGlobalAdmin: false }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!deviceKey) {
|
||||
return new Response(JSON.stringify({ isGlobalAdmin: false }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const isAdmin = await isGlobalAdmin(db, deviceKey.user_id);
|
||||
|
||||
return new Response(JSON.stringify({ isGlobalAdmin: isAdmin }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Global admin status check error:', error);
|
||||
return new Response(JSON.stringify({ isGlobalAdmin: false }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/request
|
||||
* Request global admin access (sends email to existing global admin)
|
||||
* Body: { reason?: string }
|
||||
*/
|
||||
export async function handleRequestAdminAccess(
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Must be authenticated
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!deviceKey) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get user info
|
||||
const user = await db.prepare(
|
||||
'SELECT cryptid_username, email FROM users WHERE id = ?'
|
||||
).bind(deviceKey.user_id).first<{ cryptid_username: string; email: string }>();
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'User not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already a global admin
|
||||
if (await isGlobalAdmin(db, deviceKey.user_id)) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
message: 'You are already a global admin'
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({})) as { reason?: string };
|
||||
|
||||
// Send email to global admin (jeffemmett@gmail.com)
|
||||
if (env.RESEND_API_KEY) {
|
||||
const emailFrom = env.CRYPTID_EMAIL_FROM || 'noreply@canvas.jeffemmett.com';
|
||||
|
||||
const emailResponse = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: emailFrom,
|
||||
to: 'jeffemmett@gmail.com',
|
||||
subject: `Canvas Admin Request from ${user.cryptid_username}`,
|
||||
html: `
|
||||
<h2>Admin Access Request</h2>
|
||||
<p><strong>User:</strong> ${user.cryptid_username}</p>
|
||||
<p><strong>Email:</strong> ${user.email || 'Not provided'}</p>
|
||||
<p><strong>User ID:</strong> ${deviceKey.user_id}</p>
|
||||
${body.reason ? `<p><strong>Reason:</strong> ${body.reason}</p>` : ''}
|
||||
<hr>
|
||||
<p>To grant admin access, add their email to the global_admins table in D1.</p>
|
||||
`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!emailResponse.ok) {
|
||||
console.error('Failed to send admin request email:', await emailResponse.text());
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Admin access request sent. You will be notified when approved.'
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Request admin access error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /boards/:boardId/info
|
||||
* Get board info including protection status (public endpoint)
|
||||
*/
|
||||
export async function handleGetBoardInfo(
|
||||
boardId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({
|
||||
board: null,
|
||||
isProtected: false
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const board = await db.prepare(
|
||||
'SELECT id, name, is_protected, owner_id FROM boards WHERE id = ?'
|
||||
).bind(boardId).first<{ id: string; name: string | null; is_protected: number; owner_id: string | null }>();
|
||||
|
||||
if (!board) {
|
||||
return new Response(JSON.stringify({
|
||||
board: null,
|
||||
isProtected: false
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get owner username if exists
|
||||
let ownerUsername: string | null = null;
|
||||
if (board.owner_id) {
|
||||
const owner = await db.prepare(
|
||||
'SELECT cryptid_username FROM users WHERE id = ?'
|
||||
).bind(board.owner_id).first<{ cryptid_username: string }>();
|
||||
ownerUsername = owner?.cryptid_username || null;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
board: {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
isProtected: board.is_protected === 1,
|
||||
ownerUsername,
|
||||
}
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get board info error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /boards/:boardId/editors
|
||||
* List users with edit access on a protected board (admin only)
|
||||
*/
|
||||
export async function handleListEditors(
|
||||
boardId: string,
|
||||
request: Request,
|
||||
env: Environment
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
return new Response(JSON.stringify({ error: 'Database not configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
if (!publicKey) {
|
||||
return new Response(JSON.stringify({ error: 'Authentication required' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
).bind(publicKey).first<{ user_id: string }>();
|
||||
|
||||
if (!deviceKey) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const permCheck = await getEffectivePermission(db, boardId, deviceKey.user_id);
|
||||
if (permCheck.permission !== 'admin') {
|
||||
return new Response(JSON.stringify({ error: 'Admin access required' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get all users with edit or admin permission
|
||||
const editors = await db.prepare(`
|
||||
SELECT bp.user_id, bp.permission, bp.granted_at, u.cryptid_username, u.email
|
||||
FROM board_permissions bp
|
||||
JOIN users u ON bp.user_id = u.id
|
||||
WHERE bp.board_id = ? AND bp.permission IN ('edit', 'admin')
|
||||
ORDER BY bp.granted_at DESC
|
||||
`).bind(boardId).all<{
|
||||
user_id: string;
|
||||
permission: string;
|
||||
granted_at: string;
|
||||
cryptid_username: string;
|
||||
email: string;
|
||||
}>();
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
editors: (editors.results || []).map(e => ({
|
||||
userId: e.user_id,
|
||||
username: e.cryptid_username,
|
||||
email: e.email,
|
||||
permission: e.permission,
|
||||
grantedAt: e.granted_at,
|
||||
}))
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('List editors error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
-- Migration: Add Protected Boards & Global Admin System
|
||||
-- Date: 2024-12-15
|
||||
-- Description: Implements new permission model where everyone can edit by default,
|
||||
-- but boards can be marked as "protected" to restrict editing.
|
||||
|
||||
-- Add is_protected column to boards table (if it doesn't exist)
|
||||
-- When is_protected = 1, only explicitly listed editors can edit
|
||||
-- When is_protected = 0 (default), everyone can edit
|
||||
ALTER TABLE boards ADD COLUMN is_protected INTEGER DEFAULT 0;
|
||||
|
||||
-- Create index for protected boards lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_boards_protected ON boards(is_protected);
|
||||
|
||||
-- Create global_admins table
|
||||
-- Global admins have admin access to ALL boards
|
||||
CREATE TABLE IF NOT EXISTS global_admins (
|
||||
email TEXT PRIMARY KEY,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
added_by TEXT
|
||||
);
|
||||
|
||||
-- Seed initial global admin
|
||||
INSERT OR IGNORE INTO global_admins (email) VALUES ('jeffemmett@gmail.com');
|
||||
|
||||
-- Update default_permission default value on boards table
|
||||
-- (This only affects new boards, existing boards keep their current value)
|
||||
-- Note: SQLite doesn't support ALTER COLUMN, but our schema.sql already has the new default
|
||||
|
|
@ -58,11 +58,13 @@ CREATE TABLE IF NOT EXISTS boards (
|
|||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
-- Default permission for unauthenticated users: 'view' (read-only) or 'edit' (open)
|
||||
default_permission TEXT DEFAULT 'view' CHECK (default_permission IN ('view', 'edit')),
|
||||
default_permission TEXT DEFAULT 'edit' CHECK (default_permission IN ('view', 'edit')),
|
||||
-- Board metadata
|
||||
name TEXT, -- Optional display name
|
||||
description TEXT, -- Optional description
|
||||
is_public INTEGER DEFAULT 1, -- 1 = anyone with link can view, 0 = invite only
|
||||
-- Protected mode: when 1, only listed editors can edit; when 0, everyone can edit
|
||||
is_protected INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
|
|
@ -167,3 +169,27 @@ CREATE INDEX IF NOT EXISTS idx_connections_to ON user_connections(to_user_id);
|
|||
CREATE INDEX IF NOT EXISTS idx_connections_both ON user_connections(from_user_id, to_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conn_meta_connection ON connection_metadata(connection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conn_meta_user ON connection_metadata(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- Global Admin & Protected Boards System
|
||||
-- =============================================================================
|
||||
|
||||
-- Global admins have admin access to ALL boards
|
||||
-- Used for platform-wide moderation and support
|
||||
CREATE TABLE IF NOT EXISTS global_admins (
|
||||
email TEXT PRIMARY KEY, -- Email of the global admin
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
added_by TEXT -- Email of admin who added them (NULL for initial)
|
||||
);
|
||||
|
||||
-- Seed initial global admin
|
||||
INSERT OR IGNORE INTO global_admins (email) VALUES ('jeffemmett@gmail.com');
|
||||
|
||||
-- Migration: Add is_protected column to boards table
|
||||
-- When is_protected = 1, only explicitly listed editors can edit
|
||||
-- When is_protected = 0 (default), everyone can edit
|
||||
-- Note: Run this ALTER TABLE separately if boards table already exists
|
||||
-- ALTER TABLE boards ADD COLUMN is_protected INTEGER DEFAULT 0;
|
||||
|
||||
-- Index for protected boards lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_boards_protected ON boards(is_protected);
|
||||
|
|
|
|||
|
|
@ -76,6 +76,16 @@ export interface Board {
|
|||
name: string | null;
|
||||
description: string | null;
|
||||
is_public: number; // SQLite boolean (0 or 1)
|
||||
is_protected: number; // SQLite boolean (0 or 1) - when 1, only listed editors can edit
|
||||
}
|
||||
|
||||
/**
|
||||
* Global admin record - admins have admin access to ALL boards
|
||||
*/
|
||||
export interface GlobalAdmin {
|
||||
email: string;
|
||||
added_at: string;
|
||||
added_by: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -97,7 +107,10 @@ export interface PermissionCheckResult {
|
|||
permission: PermissionLevel;
|
||||
isOwner: boolean;
|
||||
boardExists: boolean;
|
||||
grantedByToken?: boolean; // True if permission was granted via access token
|
||||
grantedByToken?: boolean; // True if permission was granted via access token
|
||||
isGlobalAdmin?: boolean; // True if user is a global admin
|
||||
isProtected?: boolean; // True if board is in protected mode
|
||||
isExplicitPermission?: boolean; // True if permission was explicitly granted (not default)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ import {
|
|||
handleCreateAccessToken,
|
||||
handleListAccessTokens,
|
||||
handleRevokeAccessToken,
|
||||
handleGetGlobalAdminStatus,
|
||||
handleRequestAdminAccess,
|
||||
handleGetBoardInfo,
|
||||
handleListEditors,
|
||||
} from "./boardPermissions"
|
||||
import {
|
||||
handleSendBackupEmail,
|
||||
|
|
@ -1004,6 +1008,26 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
.delete("/boards/:boardId/access-tokens/:tokenId", (req, env) =>
|
||||
handleRevokeAccessToken(req.params.boardId, req.params.tokenId, req, env))
|
||||
|
||||
// =============================================================================
|
||||
// Global Admin & Protected Boards API
|
||||
// =============================================================================
|
||||
|
||||
// Check if current user is a global admin
|
||||
.get("/auth/global-admin-status", (req, env) =>
|
||||
handleGetGlobalAdminStatus(req, env))
|
||||
|
||||
// Request global admin access (sends email)
|
||||
.post("/admin/request", (req, env) =>
|
||||
handleRequestAdminAccess(req, env))
|
||||
|
||||
// Get board info including protection status
|
||||
.get("/boards/:boardId/info", (req, env) =>
|
||||
handleGetBoardInfo(req.params.boardId, req, env))
|
||||
|
||||
// List editors on a protected board (admin only)
|
||||
.get("/boards/:boardId/editors", (req, env) =>
|
||||
handleListEditors(req.params.boardId, req, env))
|
||||
|
||||
async function backupAllBoards(env: Environment) {
|
||||
try {
|
||||
// List all room files from TLDRAW_BUCKET
|
||||
|
|
|
|||
Loading…
Reference in New Issue