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
6b84d7109c
commit
2fe96faf53
|
|
@ -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) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch board permission:', response.status);
|
console.error('Failed to fetch board permission:', response.status);
|
||||||
// Default to 'edit' for authenticated users, 'view' for unauthenticated
|
// NEW: Default to 'edit' for everyone (open by default)
|
||||||
const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
|
console.log('🔐 Using default permission (API failed): edit');
|
||||||
console.log('🔐 Using default permission (API failed):', defaultPermission);
|
return 'edit';
|
||||||
return defaultPermission;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as {
|
const data = await response.json() as {
|
||||||
|
|
@ -307,6 +306,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
boardExists: boolean;
|
boardExists: boolean;
|
||||||
grantedByToken?: boolean;
|
grantedByToken?: boolean;
|
||||||
isExplicitPermission?: boolean; // Whether this permission was explicitly set
|
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
|
// Debug: Log what we received
|
||||||
|
|
@ -315,19 +316,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
if (data.grantedByToken) {
|
if (data.grantedByToken) {
|
||||||
console.log('🔓 Permission granted via access token:', data.permission);
|
console.log('🔓 Permission granted via access token:', data.permission);
|
||||||
}
|
}
|
||||||
|
if (data.isGlobalAdmin) {
|
||||||
|
console.log('🔓 User is global admin');
|
||||||
|
}
|
||||||
|
|
||||||
// Determine effective permission
|
// NEW PERMISSION MODEL (Dec 2024):
|
||||||
// If authenticated user and board doesn't have explicit permissions set,
|
// - Everyone (including anonymous) can EDIT by default
|
||||||
// default to 'edit' instead of 'view'
|
// - 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;
|
let effectivePermission = data.permission;
|
||||||
|
|
||||||
if (session.authed && data.permission === 'view') {
|
// Log why view permission was given (for debugging protected boards)
|
||||||
// If board doesn't exist in permission system or permission isn't explicitly set,
|
if (data.permission === 'view' && data.isProtected) {
|
||||||
// authenticated users should get edit access by default
|
console.log('🔒 View-only: board is protected and user is not an editor');
|
||||||
if (!data.boardExists || data.isExplicitPermission === false) {
|
|
||||||
effectivePermission = 'edit';
|
|
||||||
console.log('🔓 Upgrading to edit: authenticated user with no explicit view restriction');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the permission
|
// Cache the permission
|
||||||
|
|
@ -343,24 +344,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
return effectivePermission;
|
return effectivePermission;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching board permission:', error);
|
console.error('Error fetching board permission:', error);
|
||||||
// Default to 'edit' for authenticated users, 'view' for unauthenticated
|
// NEW: Default to 'edit' for everyone (open by default)
|
||||||
const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
|
console.log('🔐 Using default permission (error): edit');
|
||||||
console.log('🔐 Using default permission (error):', defaultPermission);
|
return 'edit';
|
||||||
return defaultPermission;
|
|
||||||
}
|
}
|
||||||
}, [session.authed, session.username, session.boardPermissions, accessToken]);
|
}, [session.authed, session.username, session.boardPermissions, accessToken]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user can edit the current board
|
* Check if user can edit the current board
|
||||||
|
* NEW: Returns true by default (open permission model)
|
||||||
*/
|
*/
|
||||||
const canEdit = useCallback((): boolean => {
|
const canEdit = useCallback((): boolean => {
|
||||||
const permission = session.currentBoardPermission;
|
const permission = session.currentBoardPermission;
|
||||||
if (!permission) {
|
if (!permission) {
|
||||||
// If no permission set, default based on auth status
|
// NEW: If no permission set, default to edit (open by default)
|
||||||
return session.authed;
|
return true;
|
||||||
}
|
}
|
||||||
return permission === 'edit' || permission === 'admin';
|
return permission === 'edit' || permission === 'admin';
|
||||||
}, [session.currentBoardPermission, session.authed]);
|
}, [session.currentBoardPermission]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user is admin for the current board
|
* Check if user is admin for the current board
|
||||||
|
|
|
||||||
|
|
@ -321,9 +321,9 @@ export function Board() {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch permission:', 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) {
|
if (mounted) {
|
||||||
setPermission(session.authed ? 'edit' : 'view')
|
setPermission('edit')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -340,23 +340,23 @@ export function Board() {
|
||||||
}, [roomId, fetchBoardPermission, session.authed])
|
}, [roomId, fetchBoardPermission, session.authed])
|
||||||
|
|
||||||
// Check if user can edit
|
// Check if user can edit
|
||||||
// Authenticated users get edit access by default unless explicitly restricted to 'view'
|
// NEW PERMISSION MODEL (Dec 2024):
|
||||||
// Unauthenticated users are always read-only
|
// - Everyone (including anonymous) can EDIT by default
|
||||||
// Note: permission will be 'edit' for authenticated users by default (see AuthContext)
|
// - 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:
|
// 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)
|
// 2. Auth just changed (React effects haven't run yet, permission state is stale)
|
||||||
// 3. Permission is loading for authenticated users
|
// This prevents users from briefly seeing read-only mode which hides
|
||||||
// This prevents authenticated users from briefly seeing read-only mode which hides
|
|
||||||
// default tools (only tools with readonlyOk: true show in read-only mode)
|
// default tools (only tools with readonlyOk: true show in read-only mode)
|
||||||
const isReadOnly = (
|
const isReadOnly = (
|
||||||
session.loading ||
|
session.loading ||
|
||||||
(session.authed && authJustChanged) || // Auth just changed, permission is stale
|
authJustChanged || // Auth just changed, permission is stale
|
||||||
(session.authed && permissionLoading)
|
permissionLoading
|
||||||
)
|
)
|
||||||
? false // Don't restrict while loading/transitioning - assume authenticated users can edit
|
? false // Don't restrict while loading/transitioning - assume can edit
|
||||||
: (!session.authed || permission === 'view')
|
: permission === 'view' // Only restrict if explicitly view (protected board)
|
||||||
|
|
||||||
// Debug logging for permission issues
|
// Debug logging for permission issues
|
||||||
console.log('🔐 Permission Debug:', {
|
console.log('🔐 Permission Debug:', {
|
||||||
|
|
@ -369,15 +369,13 @@ export function Board() {
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
reason: session.loading
|
reason: session.loading
|
||||||
? 'auth loading - allowing edit temporarily'
|
? 'auth loading - allowing edit temporarily'
|
||||||
: (session.authed && authJustChanged)
|
: authJustChanged
|
||||||
? 'auth just changed - allowing edit until effects run'
|
? 'auth just changed - allowing edit until effects run'
|
||||||
: (session.authed && permissionLoading)
|
: permissionLoading
|
||||||
? 'permission loading for authenticated user - allowing edit temporarily'
|
? 'permission loading - allowing edit temporarily'
|
||||||
: !session.authed
|
: permission === 'view'
|
||||||
? 'not authenticated - view only mode'
|
? 'protected board - user not an editor (view-only)'
|
||||||
: permission === 'view'
|
: 'open board or user is editor (can edit)'
|
||||||
? 'explicitly restricted to view-only by board admin'
|
|
||||||
: 'authenticated with edit access'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handler for when user tries to edit in read-only mode
|
// Handler for when user tries to edit in read-only mode
|
||||||
|
|
@ -463,18 +461,29 @@ export function Board() {
|
||||||
colorScheme: getColorScheme(),
|
colorScheme: getColorScheme(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Update user preferences when session changes
|
// Update user preferences when session changes (handles both login and logout)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (uniqueUserId) {
|
if (session.authed && uniqueUserId) {
|
||||||
|
// Authenticated user - use their unique ID and username
|
||||||
setUserPreferences({
|
setUserPreferences({
|
||||||
id: uniqueUserId,
|
id: uniqueUserId,
|
||||||
name: session.username || 'Anonymous',
|
name: session.username || 'Anonymous',
|
||||||
// Use session.username for color consistency across sessions
|
|
||||||
color: session.username ? generateUserColor(session.username) : generateUserColor(uniqueUserId),
|
color: session.username ? generateUserColor(session.username) : generateUserColor(uniqueUserId),
|
||||||
colorScheme: getColorScheme(),
|
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
|
// Listen for dark mode changes and update tldraw color scheme
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -540,6 +549,28 @@ export function Board() {
|
||||||
}
|
}
|
||||||
}, [editor, isReadOnly])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const value = localStorage.getItem("makereal_settings_2")
|
const value = localStorage.getItem("makereal_settings_2")
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|
@ -936,7 +967,7 @@ export function Board() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
const cleanupStalePresences = () => {
|
const cleanupStalePresences = (forceCleanAll = false) => {
|
||||||
try {
|
try {
|
||||||
const allRecords = editor.store.allRecords()
|
const allRecords = editor.store.allRecords()
|
||||||
const presenceRecords = allRecords.filter((r: any) =>
|
const presenceRecords = allRecords.filter((r: any) =>
|
||||||
|
|
@ -945,16 +976,32 @@ export function Board() {
|
||||||
)
|
)
|
||||||
|
|
||||||
if (presenceRecords.length > 0) {
|
if (presenceRecords.length > 0) {
|
||||||
// Filter out stale presences (older than 30 seconds)
|
if (forceCleanAll) {
|
||||||
const now = Date.now()
|
// On logout/auth change, remove ALL presence records except our current one
|
||||||
const staleThreshold = 30 * 1000 // 30 seconds
|
// This prevents double-registration issues
|
||||||
const stalePresences = presenceRecords.filter((r: any) =>
|
const currentUserId = uniqueUserId || userPreferences.id
|
||||||
r.lastActivityTimestamp && (now - r.lastActivityTimestamp > staleThreshold)
|
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) {
|
if (presencesToRemove.length > 0) {
|
||||||
console.log(`🧹 Cleaning up ${stalePresences.length} stale presence record(s)`)
|
console.log(`🧹 Force cleaning ${presencesToRemove.length} non-current presence record(s) on auth change`)
|
||||||
editor.store.remove(stalePresences.map((r: any) => r.id))
|
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) {
|
} catch (error) {
|
||||||
|
|
@ -962,14 +1009,63 @@ export function Board() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up immediately on auth change
|
// Clean up ALL non-current presences on auth change to prevent double-registration
|
||||||
cleanupStalePresences()
|
cleanupStalePresences(true)
|
||||||
|
|
||||||
// Also run periodic cleanup every 15 seconds
|
// Also run periodic cleanup every 15 seconds (only stale ones)
|
||||||
const cleanupInterval = setInterval(cleanupStalePresences, 15000)
|
const cleanupInterval = setInterval(() => cleanupStalePresences(false), 15000)
|
||||||
|
|
||||||
return () => clearInterval(cleanupInterval)
|
// Listen for session-cleared event to clean up ONLY the current user's presence
|
||||||
}, [editor, session.authed, session.username])
|
// 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
|
// Update TLDraw user preferences when editor is available and user is authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1324,16 +1420,35 @@ export function Board() {
|
||||||
// MycelialIntelligence is now a permanent UI bar - no shape creation needed
|
// MycelialIntelligence is now a permanent UI bar - no shape creation needed
|
||||||
|
|
||||||
// Set read-only mode based on auth state
|
// Set read-only mode based on auth state
|
||||||
// IMPORTANT: Use session.authed directly here, not the isReadOnly variable
|
// IMPORTANT: Check localStorage directly to avoid stale closure issues
|
||||||
// The isReadOnly variable might have stale values due to React's timing (effects run after render)
|
// The React state (session.authed) might be stale in this callback due to
|
||||||
// For authenticated users, we assume editable until permission proves otherwise
|
// the complex timing of remounts triggered by auth changes
|
||||||
// The effect that watches isReadOnly will update this if user only has 'view' permission
|
const checkAuthFromStorage = (): boolean => {
|
||||||
const initialReadOnly = !session.authed
|
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 })
|
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
|
console.log(initialReadOnly
|
||||||
? '🔒 Board is in read-only mode (not authenticated)'
|
? '🔒 Board is in read-only mode (not authenticated)'
|
||||||
: '🔓 Board is editable (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 />
|
<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
|
// Generate a UUID v4
|
||||||
function generateUUID(): string {
|
function generateUUID(): string {
|
||||||
return crypto.randomUUID();
|
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
|
* 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)
|
* @param accessToken - Optional access token from share link (grants specific permission)
|
||||||
*/
|
*/
|
||||||
|
|
@ -22,7 +66,7 @@ export async function getEffectivePermission(
|
||||||
'SELECT * FROM boards WHERE id = ?'
|
'SELECT * FROM boards WHERE id = ?'
|
||||||
).bind(boardId).first<Board>();
|
).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) {
|
if (accessToken) {
|
||||||
const tokenPermission = await validateAccessToken(db, boardId, accessToken);
|
const tokenPermission = await validateAccessToken(db, boardId, accessToken);
|
||||||
if (tokenPermission) {
|
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
|
// 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) {
|
if (!board) {
|
||||||
return {
|
return {
|
||||||
permission: userId ? 'edit' : 'view',
|
permission: 'edit',
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
boardExists: false
|
boardExists: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user is not authenticated, return VIEW (secure by default)
|
// 3. Check if user is the board owner (always admin)
|
||||||
// To grant edit access to anonymous users, they must use a share link with access token
|
if (userId && board.owner_id === userId) {
|
||||||
if (!userId) {
|
|
||||||
return {
|
|
||||||
permission: 'view',
|
|
||||||
isOwner: false,
|
|
||||||
boardExists: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is the board owner (always admin)
|
|
||||||
if (board.owner_id === userId) {
|
|
||||||
return {
|
return {
|
||||||
permission: 'admin',
|
permission: 'admin',
|
||||||
isOwner: true,
|
isOwner: true,
|
||||||
|
|
@ -64,27 +112,40 @@ export async function getEffectivePermission(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for explicit user-specific permission
|
// 4. If board is NOT protected, everyone can edit (NEW DEFAULT)
|
||||||
const explicitPerm = await db.prepare(
|
if (!board.is_protected) {
|
||||||
'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)
|
|
||||||
return {
|
return {
|
||||||
permission: explicitPerm.permission,
|
permission: 'edit',
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
boardExists: true
|
boardExists: true,
|
||||||
|
isProtected: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// No explicit permission for this user
|
// 5. Board IS protected - check for explicit permission
|
||||||
// Authenticated users get 'edit' by default
|
if (userId) {
|
||||||
// (Board's default_permission only affects anonymous users with access tokens)
|
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 {
|
return {
|
||||||
permission: 'edit',
|
permission: 'view',
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
boardExists: true
|
boardExists: true,
|
||||||
|
isProtected: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -499,7 +560,7 @@ export async function handleRevokePermission(
|
||||||
/**
|
/**
|
||||||
* PATCH /boards/:boardId
|
* PATCH /boards/:boardId
|
||||||
* Update board settings (admin only)
|
* Update board settings (admin only)
|
||||||
* Body: { name?, defaultPermission?, isPublic? }
|
* Body: { name?, defaultPermission?, isPublic?, isProtected? }
|
||||||
*/
|
*/
|
||||||
export async function handleUpdateBoard(
|
export async function handleUpdateBoard(
|
||||||
boardId: string,
|
boardId: string,
|
||||||
|
|
@ -548,6 +609,7 @@ export async function handleUpdateBoard(
|
||||||
name?: string;
|
name?: string;
|
||||||
defaultPermission?: 'view' | 'edit';
|
defaultPermission?: 'view' | 'edit';
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
|
isProtected?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updates: string[] = [];
|
const updates: string[] = [];
|
||||||
|
|
@ -571,6 +633,11 @@ export async function handleUpdateBoard(
|
||||||
updates.push('is_public = ?');
|
updates.push('is_public = ?');
|
||||||
values.push(body.isPublic ? 1 : 0);
|
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) {
|
if (updates.length === 0) {
|
||||||
return new Response(JSON.stringify({ error: 'No updates provided' }), {
|
return new Response(JSON.stringify({ error: 'No updates provided' }), {
|
||||||
|
|
@ -597,6 +664,7 @@ export async function handleUpdateBoard(
|
||||||
name: updatedBoard.name,
|
name: updatedBoard.name,
|
||||||
defaultPermission: updatedBoard.default_permission,
|
defaultPermission: updatedBoard.default_permission,
|
||||||
isPublic: updatedBoard.is_public === 1,
|
isPublic: updatedBoard.is_public === 1,
|
||||||
|
isProtected: updatedBoard.is_protected === 1,
|
||||||
updatedAt: updatedBoard.updated_at
|
updatedAt: updatedBoard.updated_at
|
||||||
} : null
|
} : 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')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
-- Default permission for unauthenticated users: 'view' (read-only) or 'edit' (open)
|
-- 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
|
-- Board metadata
|
||||||
name TEXT, -- Optional display name
|
name TEXT, -- Optional display name
|
||||||
description TEXT, -- Optional description
|
description TEXT, -- Optional description
|
||||||
is_public INTEGER DEFAULT 1, -- 1 = anyone with link can view, 0 = invite only
|
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
|
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_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_connection ON connection_metadata(connection_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_conn_meta_user ON connection_metadata(user_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;
|
name: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
is_public: number; // SQLite boolean (0 or 1)
|
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;
|
permission: PermissionLevel;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
boardExists: 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,
|
handleCreateAccessToken,
|
||||||
handleListAccessTokens,
|
handleListAccessTokens,
|
||||||
handleRevokeAccessToken,
|
handleRevokeAccessToken,
|
||||||
|
handleGetGlobalAdminStatus,
|
||||||
|
handleRequestAdminAccess,
|
||||||
|
handleGetBoardInfo,
|
||||||
|
handleListEditors,
|
||||||
} from "./boardPermissions"
|
} from "./boardPermissions"
|
||||||
import {
|
import {
|
||||||
handleSendBackupEmail,
|
handleSendBackupEmail,
|
||||||
|
|
@ -1004,6 +1008,26 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
.delete("/boards/:boardId/access-tokens/:tokenId", (req, env) =>
|
.delete("/boards/:boardId/access-tokens/:tokenId", (req, env) =>
|
||||||
handleRevokeAccessToken(req.params.boardId, req.params.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) {
|
async function backupAllBoards(env: Environment) {
|
||||||
try {
|
try {
|
||||||
// List all room files from TLDRAW_BUCKET
|
// List all room files from TLDRAW_BUCKET
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue