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:
Jeff Emmett 2025-12-15 12:43:14 -05:00
parent 9276d85709
commit 52503167c8
8 changed files with 1230 additions and 101 deletions

View File

@ -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' }}>&#9881;</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',
}}
>
&#10005;
</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"
>
&#10005;
</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;

View File

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

View File

@ -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'
: permissionLoading
? 'permission loading - allowing edit temporarily'
: permission === 'view'
? 'explicitly restricted to view-only by board admin'
: 'authenticated with edit access'
? '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,6 +976,21 @@ export function Board() {
)
if (presenceRecords.length > 0) {
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 (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
@ -957,19 +1003,69 @@ export function Board() {
editor.store.remove(stalePresences.map((r: any) => r.id))
}
}
}
} catch (error) {
console.error('Error cleaning up stale presences:', error)
}
}
// 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 />

View File

@ -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
// 4. If board is NOT protected, everyone can edit (NEW DEFAULT)
if (!board.is_protected) {
return {
permission: 'edit',
isOwner: false,
boardExists: true,
isProtected: false
};
}
// 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 a specific permission set - use it (could be view, edit, or admin)
// User has been granted specific permission on this protected board
return {
permission: explicitPerm.permission,
isOwner: false,
boardExists: true
boardExists: true,
isProtected: true,
isExplicitPermission: true
};
}
}
// No explicit permission for this user
// Authenticated users get 'edit' by default
// (Board's default_permission only affects anonymous users with access tokens)
// 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' },
});
}
}

View File

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

View File

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

View File

@ -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;
}
/**
@ -98,6 +108,9 @@ export interface PermissionCheckResult {
isOwner: boolean;
boardExists: boolean;
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)
}
/**

View File

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