diff --git a/src/components/BoardSettingsDropdown.tsx b/src/components/BoardSettingsDropdown.tsx new file mode 100644 index 0000000..21a547d --- /dev/null +++ b/src/components/BoardSettingsDropdown.tsx @@ -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 = ({ className = '' }) => { + const { slug } = useParams<{ slug: string }>(); + const { session } = useAuth(); + const [showDropdown, setShowDropdown] = useState(false); + const [boardInfo, setBoardInfo] = useState(null); + const [editors, setEditors] = useState([]); + 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(null); + const dropdownMenuRef = useRef(null); + const triggerRef = useRef(null); + const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null); + + const boardId = slug || 'mycofi33'; + + // Get auth headers + const getAuthHeaders = (): Record => { + const headers: Record = { + '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 ( +
+ + + {/* Dropdown Menu */} + {showDropdown && dropdownPosition && createPortal( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {/* Header */} +
+ + Board Settings + + +
+ + {loading ? ( +
+ Loading... +
+ ) : ( +
+ + {/* Board Info */} +
+
+ Board Info +
+
+
+ ID: {boardId} +
+ {boardInfo?.ownerUsername && ( +
+ Owner: @{boardInfo.ownerUsername} +
+ )} +
+ Status: + + {boardInfo?.isProtected ? 'Protected (View-only)' : 'Open (Anyone can edit)'} + +
+
+
+ + {/* Admin Section */} + {isAdmin && ( + <> +
+
+ Protection Settings {isGlobalAdmin && (Global Admin)} +
+ + {/* Protection Toggle */} +
+
+
+ View-only Mode +
+
+ Only listed editors can make changes +
+
+ +
+
+ + {/* Editor Management (only when protected) */} + {boardInfo?.isProtected && ( +
+
+ Editors ({editors.length}) +
+ + {/* Add Editor Input */} +
+ 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', + }} + /> + +
+ + {/* Editor List */} +
+ {editors.length === 0 ? ( +
+ No editors added yet +
+ ) : ( + editors.map((editor) => ( +
+
+
+ @{editor.username} +
+
+ {editor.permission} +
+
+ +
+ )) + )} +
+
+ )} + + )} + + {/* Request Admin Access (for non-admins) */} + {!isAdmin && session.authed && ( +
+ +
+ Admin requests are sent to jeffemmett@gmail.com +
+
+ )} + + {/* Sign in prompt for anonymous users */} + {!session.authed && ( +
+ Sign in to access board settings +
+ )} +
+ )} +
, + document.body + )} +
+ ); +}; + +export default BoardSettingsDropdown; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 8e3d4bf..e56b88b 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -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 diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index d9c4a51..461e09d 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -321,9 +321,9 @@ export function Board() { } } catch (error) { console.error('Failed to fetch permission:', error) - // Default to view for unauthenticated, edit for authenticated + // NEW: Default to 'edit' for everyone (open by default) if (mounted) { - setPermission(session.authed ? 'edit' : 'view') + setPermission('edit') } } finally { if (mounted) { @@ -340,23 +340,23 @@ export function Board() { }, [roomId, fetchBoardPermission, session.authed]) // Check if user can edit - // Authenticated users get edit access by default unless explicitly restricted to 'view' - // Unauthenticated users are always read-only - // Note: permission will be 'edit' for authenticated users by default (see AuthContext) + // NEW PERMISSION MODEL (Dec 2024): + // - Everyone (including anonymous) can EDIT by default + // - Only protected boards restrict editing to listed editors + // - Permission 'view' means the board is protected and user is not an editor // // CRITICAL: Don't restrict in these cases: - // 1. Auth is loading + // 1. Auth/permission is loading // 2. Auth just changed (React effects haven't run yet, permission state is stale) - // 3. Permission is loading for authenticated users - // This prevents authenticated users from briefly seeing read-only mode which hides + // This prevents users from briefly seeing read-only mode which hides // default tools (only tools with readonlyOk: true show in read-only mode) const isReadOnly = ( session.loading || - (session.authed && authJustChanged) || // Auth just changed, permission is stale - (session.authed && permissionLoading) + authJustChanged || // Auth just changed, permission is stale + permissionLoading ) - ? false // Don't restrict while loading/transitioning - assume authenticated users can edit - : (!session.authed || permission === 'view') + ? false // Don't restrict while loading/transitioning - assume can edit + : permission === 'view' // Only restrict if explicitly view (protected board) // Debug logging for permission issues console.log('๐Ÿ” Permission Debug:', { @@ -369,15 +369,13 @@ export function Board() { isReadOnly, reason: session.loading ? 'auth loading - allowing edit temporarily' - : (session.authed && authJustChanged) + : authJustChanged ? 'auth just changed - allowing edit until effects run' - : (session.authed && permissionLoading) - ? 'permission loading for authenticated user - allowing edit temporarily' - : !session.authed - ? 'not authenticated - view only mode' - : permission === 'view' - ? 'explicitly restricted to view-only by board admin' - : 'authenticated with edit access' + : permissionLoading + ? 'permission loading - allowing edit temporarily' + : permission === 'view' + ? 'protected board - user not an editor (view-only)' + : 'open board or user is editor (can edit)' }) // Handler for when user tries to edit in read-only mode @@ -463,18 +461,29 @@ export function Board() { colorScheme: getColorScheme(), })) - // Update user preferences when session changes + // Update user preferences when session changes (handles both login and logout) useEffect(() => { - if (uniqueUserId) { + if (session.authed && uniqueUserId) { + // Authenticated user - use their unique ID and username setUserPreferences({ id: uniqueUserId, name: session.username || 'Anonymous', - // Use session.username for color consistency across sessions color: session.username ? generateUserColor(session.username) : generateUserColor(uniqueUserId), colorScheme: getColorScheme(), }) + console.log('๐Ÿ” User preferences set for authenticated user:', session.username) + } else { + // Not authenticated - reset to anonymous with fresh ID + const anonymousId = `anonymous-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + setUserPreferences({ + id: anonymousId, + name: 'Anonymous', + color: '#6b7280', // Gray for anonymous + colorScheme: getColorScheme(), + }) + console.log('๐Ÿ” User preferences reset to anonymous') } - }, [uniqueUserId, session.username]) + }, [uniqueUserId, session.username, session.authed]) // Listen for dark mode changes and update tldraw color scheme useEffect(() => { @@ -540,6 +549,28 @@ export function Board() { } }, [editor, isReadOnly]) + // Listen for session-logged-in event to immediately enable editing + // This handles the case where the React state update might be delayed + useEffect(() => { + if (!editor) return + + const handleSessionLoggedIn = (event: Event) => { + const customEvent = event as CustomEvent<{ username: string }>; + console.log('๐Ÿ” Board: session-logged-in event received for:', customEvent.detail.username); + + // Immediately enable editing - user just logged in + editor.updateInstanceState({ isReadonly: false }); + + // Switch to select tool to ensure tools are available + editor.setCurrentTool('select'); + + console.log('๐Ÿ”“ Board: Enabled editing mode after login'); + }; + + window.addEventListener('session-logged-in', handleSessionLoggedIn); + return () => window.removeEventListener('session-logged-in', handleSessionLoggedIn); + }, [editor]) + useEffect(() => { const value = localStorage.getItem("makereal_settings_2") if (value) { @@ -936,7 +967,7 @@ export function Board() { useEffect(() => { if (!editor) return - const cleanupStalePresences = () => { + const cleanupStalePresences = (forceCleanAll = false) => { try { const allRecords = editor.store.allRecords() const presenceRecords = allRecords.filter((r: any) => @@ -945,16 +976,32 @@ export function Board() { ) if (presenceRecords.length > 0) { - // Filter out stale presences (older than 30 seconds) - const now = Date.now() - const staleThreshold = 30 * 1000 // 30 seconds - const stalePresences = presenceRecords.filter((r: any) => - r.lastActivityTimestamp && (now - r.lastActivityTimestamp > staleThreshold) - ) + if (forceCleanAll) { + // On logout/auth change, remove ALL presence records except our current one + // This prevents double-registration issues + const currentUserId = uniqueUserId || userPreferences.id + const presencesToRemove = presenceRecords.filter((r: any) => { + // Remove presences that don't match our current identity + const presenceUserId = r.userId || r.id?.split(':')[1] + return presenceUserId !== currentUserId + }) - if (stalePresences.length > 0) { - console.log(`๐Ÿงน Cleaning up ${stalePresences.length} stale presence record(s)`) - editor.store.remove(stalePresences.map((r: any) => r.id)) + if (presencesToRemove.length > 0) { + console.log(`๐Ÿงน Force cleaning ${presencesToRemove.length} non-current presence record(s) on auth change`) + editor.store.remove(presencesToRemove.map((r: any) => r.id)) + } + } else { + // Filter out stale presences (older than 30 seconds) + const now = Date.now() + const staleThreshold = 30 * 1000 // 30 seconds + const stalePresences = presenceRecords.filter((r: any) => + r.lastActivityTimestamp && (now - r.lastActivityTimestamp > staleThreshold) + ) + + if (stalePresences.length > 0) { + console.log(`๐Ÿงน Cleaning up ${stalePresences.length} stale presence record(s)`) + editor.store.remove(stalePresences.map((r: any) => r.id)) + } } } } catch (error) { @@ -962,14 +1009,63 @@ export function Board() { } } - // Clean up immediately on auth change - cleanupStalePresences() + // Clean up ALL non-current presences on auth change to prevent double-registration + cleanupStalePresences(true) - // Also run periodic cleanup every 15 seconds - const cleanupInterval = setInterval(cleanupStalePresences, 15000) + // Also run periodic cleanup every 15 seconds (only stale ones) + const cleanupInterval = setInterval(() => cleanupStalePresences(false), 15000) - return () => clearInterval(cleanupInterval) - }, [editor, session.authed, session.username]) + // Listen for session-cleared event to clean up ONLY the current user's presence + // We keep the same tldraw user ID across login/logout, so we only need to remove + // this user's presence when they log out (they'll get a fresh one on login) + const handleSessionCleared = (event: Event) => { + const customEvent = event as CustomEvent<{ previousUsername: string }>; + const previousUsername = customEvent.detail?.previousUsername; + console.log('๐Ÿงน Session cleared event received for user:', previousUsername) + + if (!previousUsername) { + console.log('๐Ÿงน No previous username, skipping presence cleanup') + return + } + + try { + // Get the tldraw user ID for the user who just logged out + const storageKey = `tldraw-user-id-${previousUsername}`; + const previousUserId = localStorage.getItem(storageKey); + + if (!previousUserId) { + console.log('๐Ÿงน No tldraw user ID found for', previousUsername) + return + } + + const allRecords = editor.store.allRecords() + const presenceRecords = allRecords.filter((r: any) => + r.typeName === 'instance_presence' || + r.id?.startsWith('instance_presence:') + ) + + // Only remove presence records that belong to the user who just logged out + const userPresences = presenceRecords.filter((r: any) => { + const presenceUserId = r.userId || r.id?.split(':')[1] + return presenceUserId === previousUserId || r.userName === previousUsername + }) + + if (userPresences.length > 0) { + console.log(`๐Ÿงน Removing ${userPresences.length} presence record(s) for logged-out user: ${previousUsername}`) + editor.store.remove(userPresences.map((r: any) => r.id)) + } + } catch (error) { + console.error('Error cleaning presences on session clear:', error) + } + } + + window.addEventListener('session-cleared', handleSessionCleared) + + return () => { + clearInterval(cleanupInterval) + window.removeEventListener('session-cleared', handleSessionCleared) + } + }, [editor, session.authed, session.username, uniqueUserId, userPreferences.id]) // Update TLDraw user preferences when editor is available and user is authenticated useEffect(() => { @@ -1324,16 +1420,35 @@ export function Board() { // MycelialIntelligence is now a permanent UI bar - no shape creation needed // Set read-only mode based on auth state - // IMPORTANT: Use session.authed directly here, not the isReadOnly variable - // The isReadOnly variable might have stale values due to React's timing (effects run after render) - // For authenticated users, we assume editable until permission proves otherwise - // The effect that watches isReadOnly will update this if user only has 'view' permission - const initialReadOnly = !session.authed + // IMPORTANT: Check localStorage directly to avoid stale closure issues + // The React state (session.authed) might be stale in this callback due to + // the complex timing of remounts triggered by auth changes + const checkAuthFromStorage = (): boolean => { + try { + const stored = localStorage.getItem('canvas_auth_session'); + if (stored) { + const parsed = JSON.parse(stored); + return parsed.authed === true && !!parsed.username; + } + } catch { + // Ignore parse errors + } + return false; + }; + + const isAuthenticated = checkAuthFromStorage(); + const initialReadOnly = !isAuthenticated; editor.updateInstanceState({ isReadonly: initialReadOnly }) - console.log('๐Ÿ”„ onMount: session.authed =', session.authed, ', setting isReadonly =', initialReadOnly) + console.log('๐Ÿ”„ onMount: isAuthenticated (from storage) =', isAuthenticated, ', setting isReadonly =', initialReadOnly) console.log(initialReadOnly ? '๐Ÿ”’ Board is in read-only mode (not authenticated)' : '๐Ÿ”“ Board is editable (authenticated)') + + // Also ensure the current tool is appropriate for the mode + if (!initialReadOnly) { + // If editable, make sure we can use tools - set to select tool which is always available + editor.setCurrentTool('select') + } }} > diff --git a/worker/boardPermissions.ts b/worker/boardPermissions.ts index d94c09a..a3ef6cd 100644 --- a/worker/boardPermissions.ts +++ b/worker/boardPermissions.ts @@ -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 { + // 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(); + + return !!admin; +} + +/** + * Check if an email is a global admin (direct check without user lookup) + */ +export async function isEmailGlobalAdmin(db: D1Database, email: string): Promise { + const admin = await db.prepare( + 'SELECT email FROM global_admins WHERE email = ?' + ).bind(email).first(); + + 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(); - // If an access token is provided, validate it and use its permission level + // 1. If an access token is provided, validate it and use its permission level if (accessToken) { const tokenPermission = await validateAccessToken(db, boardId, accessToken); if (tokenPermission) { @@ -35,28 +79,32 @@ export async function getEffectivePermission( } } + // 2. Check if user is a global admin (admin on ALL boards) + if (userId) { + const globalAdmin = await isGlobalAdmin(db, userId); + if (globalAdmin) { + console.log('๐Ÿ” User is global admin, granting admin access'); + return { + permission: 'admin', + isOwner: false, + boardExists: !!board, + isGlobalAdmin: true + }; + } + } + // Board doesn't exist in permissions DB - // Anonymous users get VIEW by default, authenticated users can edit + // NEW: Everyone can edit by default (board will be created on first edit) if (!board) { return { - permission: userId ? 'edit' : 'view', + permission: 'edit', isOwner: false, boardExists: false }; } - // If user is not authenticated, return VIEW (secure by default) - // To grant edit access to anonymous users, they must use a share link with access token - if (!userId) { - return { - permission: 'view', - isOwner: false, - boardExists: true - }; - } - - // Check if user is the board owner (always admin) - if (board.owner_id === userId) { + // 3. Check if user is the board owner (always admin) + if (userId && board.owner_id === userId) { return { permission: 'admin', isOwner: true, @@ -64,27 +112,40 @@ export async function getEffectivePermission( }; } - // Check for explicit user-specific permission - const explicitPerm = await db.prepare( - 'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?' - ).bind(boardId, userId).first(); - - if (explicitPerm) { - // User has a specific permission set - use it (could be view, edit, or admin) + // 4. If board is NOT protected, everyone can edit (NEW DEFAULT) + if (!board.is_protected) { return { - permission: explicitPerm.permission, + permission: 'edit', isOwner: false, - boardExists: true + boardExists: true, + isProtected: false }; } - // No explicit permission for this user - // Authenticated users get 'edit' by default - // (Board's default_permission only affects anonymous users with access tokens) + // 5. Board IS protected - check for explicit permission + if (userId) { + const explicitPerm = await db.prepare( + 'SELECT * FROM board_permissions WHERE board_id = ? AND user_id = ?' + ).bind(boardId, userId).first(); + + if (explicitPerm) { + // User has been granted specific permission on this protected board + return { + permission: explicitPerm.permission, + isOwner: false, + boardExists: true, + isProtected: true, + isExplicitPermission: true + }; + } + } + + // 6. Protected board, no explicit permission โ†’ view only return { - permission: 'edit', + permission: 'view', isOwner: false, - boardExists: true + boardExists: true, + isProtected: true }; } @@ -499,7 +560,7 @@ export async function handleRevokePermission( /** * PATCH /boards/:boardId * Update board settings (admin only) - * Body: { name?, defaultPermission?, isPublic? } + * Body: { name?, defaultPermission?, isPublic?, isProtected? } */ export async function handleUpdateBoard( boardId: string, @@ -548,6 +609,7 @@ export async function handleUpdateBoard( name?: string; defaultPermission?: 'view' | 'edit'; isPublic?: boolean; + isProtected?: boolean; }; const updates: string[] = []; @@ -571,6 +633,11 @@ export async function handleUpdateBoard( updates.push('is_public = ?'); values.push(body.isPublic ? 1 : 0); } + if (body.isProtected !== undefined) { + updates.push('is_protected = ?'); + values.push(body.isProtected ? 1 : 0); + console.log(`๐Ÿ”’ Board ${boardId} protection set to: ${body.isProtected}`); + } if (updates.length === 0) { return new Response(JSON.stringify({ error: 'No updates provided' }), { @@ -597,6 +664,7 @@ export async function handleUpdateBoard( name: updatedBoard.name, defaultPermission: updatedBoard.default_permission, isPublic: updatedBoard.is_public === 1, + isProtected: updatedBoard.is_protected === 1, updatedAt: updatedBoard.updated_at } : null }), { @@ -916,3 +984,308 @@ export async function handleRevokeAccessToken( }); } } + +// ============================================================================= +// Global Admin & Protected Board Functions +// ============================================================================= + +/** + * GET /auth/global-admin-status + * Check if the current user is a global admin + */ +export async function handleGetGlobalAdminStatus( + request: Request, + env: Environment +): Promise { + 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 { + 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: ` +

Admin Access Request

+

User: ${user.cryptid_username}

+

Email: ${user.email || 'Not provided'}

+

User ID: ${deviceKey.user_id}

+ ${body.reason ? `

Reason: ${body.reason}

` : ''} +
+

To grant admin access, add their email to the global_admins table in D1.

+ `, + }), + }); + + 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 { + 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 { + 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' }, + }); + } +} diff --git a/worker/migrations/001_add_protected_boards.sql b/worker/migrations/001_add_protected_boards.sql new file mode 100644 index 0000000..97a0230 --- /dev/null +++ b/worker/migrations/001_add_protected_boards.sql @@ -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 diff --git a/worker/schema.sql b/worker/schema.sql index e44b875..ac3efce 100644 --- a/worker/schema.sql +++ b/worker/schema.sql @@ -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); diff --git a/worker/types.ts b/worker/types.ts index f1ad9aa..3920b5d 100644 --- a/worker/types.ts +++ b/worker/types.ts @@ -76,6 +76,16 @@ export interface Board { name: string | null; description: string | null; is_public: number; // SQLite boolean (0 or 1) + is_protected: number; // SQLite boolean (0 or 1) - when 1, only listed editors can edit +} + +/** + * Global admin record - admins have admin access to ALL boards + */ +export interface GlobalAdmin { + email: string; + added_at: string; + added_by: string | null; } /** @@ -97,7 +107,10 @@ export interface PermissionCheckResult { permission: PermissionLevel; isOwner: boolean; boardExists: boolean; - grantedByToken?: boolean; // True if permission was granted via access token + grantedByToken?: boolean; // True if permission was granted via access token + isGlobalAdmin?: boolean; // True if user is a global admin + isProtected?: boolean; // True if board is in protected mode + isExplicitPermission?: boolean; // True if permission was explicitly granted (not default) } /** diff --git a/worker/worker.ts b/worker/worker.ts index 8443cf1..9150382 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -26,6 +26,10 @@ import { handleCreateAccessToken, handleListAccessTokens, handleRevokeAccessToken, + handleGetGlobalAdminStatus, + handleRequestAdminAccess, + handleGetBoardInfo, + handleListEditors, } from "./boardPermissions" import { handleSendBackupEmail, @@ -1004,6 +1008,26 @@ const router = AutoRouter({ .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