diff --git a/src/components/BoardSettingsDropdown.tsx b/src/components/BoardSettingsDropdown.tsx index 21a547d..b7446e1 100644 --- a/src/components/BoardSettingsDropdown.tsx +++ b/src/components/BoardSettingsDropdown.tsx @@ -68,21 +68,21 @@ const BoardSettingsDropdown: React.FC = ({ className // Fetch board info const infoRes = await fetch(`${WORKER_URL}/boards/${boardId}/info`, { headers }); - const infoData = await infoRes.json(); + const infoData = await infoRes.json() as { board?: BoardInfo }; 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(); + const permData = await permRes.json() as { permission?: string; isGlobalAdmin?: boolean }; 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(); + const editorsData = await editorsRes.json() as { editors?: Editor[] }; setEditors(editorsData.editors || []); } } catch (error) { diff --git a/src/ui/components.tsx b/src/ui/components.tsx index 90d1c60..c9ffcfd 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -11,8 +11,8 @@ import { NetworkGraphPanel } from "../components/networking" import CryptIDDropdown from "../components/auth/CryptIDDropdown" import StarBoardButton from "../components/StarBoardButton" import ShareBoardButton from "../components/ShareBoardButton" -import BoardSettingsDropdown from "../components/BoardSettingsDropdown" import { SettingsDialog } from "./SettingsDialog" +import * as crypto from "../lib/auth/crypto" // import { VersionHistoryPanel } from "../components/history" // TODO: Re-enable when version reversion is ready import { useAuth } from "../context/AuthContext" import { PermissionLevel } from "../lib/auth/types" @@ -62,6 +62,15 @@ function CustomSharePanel() { const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle') const [requestMessage, setRequestMessage] = React.useState('') + // Board protection state + const [boardProtected, setBoardProtected] = React.useState(false) + const [protectionLoading, setProtectionLoading] = React.useState(false) + const [isGlobalAdmin, setIsGlobalAdmin] = React.useState(false) + const [isBoardAdmin, setIsBoardAdmin] = React.useState(false) + const [editors, setEditors] = React.useState>([]) + const [inviteInput, setInviteInput] = React.useState('') + const [inviteStatus, setInviteStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle') + // Refs for dropdown positioning const settingsButtonRef = React.useRef(null) const shortcutsButtonRef = React.useRef(null) @@ -207,6 +216,149 @@ function CustomSharePanel() { }) } + // Get auth headers for API calls + const getAuthHeaders = React.useCallback((): 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 + }, [session.authed, session.username]) + + // Fetch board info when settings dropdown opens + const fetchBoardInfo = React.useCallback(async () => { + if (!showSettingsDropdown) return + + setProtectionLoading(true) + try { + const headers = getAuthHeaders() + + // Fetch board info + const infoRes = await fetch(`${WORKER_URL}/boards/${boardId}/info`, { headers }) + if (infoRes.ok) { + const infoData = await infoRes.json() as { board?: { isProtected?: boolean } } + if (infoData.board) { + setBoardProtected(infoData.board.isProtected || false) + } + } + + // Fetch permission to check if admin + const permRes = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, { headers }) + if (permRes.ok) { + const permData = await permRes.json() as { permission?: string; isGlobalAdmin?: boolean } + setIsBoardAdmin(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 }) + if (editorsRes.ok) { + const editorsData = await editorsRes.json() as { editors?: Array<{ userId: string; username: string; permission: string }> } + setEditors(editorsData.editors || []) + } + } + } + } catch (error) { + console.error('Failed to fetch board data:', error) + } finally { + setProtectionLoading(false) + } + }, [showSettingsDropdown, boardId, getAuthHeaders]) + + // Fetch board info when dropdown opens + React.useEffect(() => { + if (showSettingsDropdown) { + fetchBoardInfo() + } + }, [showSettingsDropdown, fetchBoardInfo]) + + // Toggle board protection + const handleToggleProtection = async () => { + if (protectionLoading) return + + setProtectionLoading(true) + try { + const headers = getAuthHeaders() + const res = await fetch(`${WORKER_URL}/boards/${boardId}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ isProtected: !boardProtected }), + }) + + if (res.ok) { + setBoardProtected(!boardProtected) + // Refresh editors list if now protected + if (!boardProtected) { + const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers }) + if (editorsRes.ok) { + const editorsData = await editorsRes.json() as { editors?: Array<{ userId: string; username: string; permission: string }> } + setEditors(editorsData.editors || []) + } + } + } + } catch (error) { + console.error('Failed to toggle protection:', error) + } finally { + setProtectionLoading(false) + } + } + + // Invite user as editor + const handleInviteEditor = 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 + const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers }) + if (editorsRes.ok) { + const editorsData = await editorsRes.json() as { editors?: Array<{ userId: string; username: string; permission: string }> } + setEditors(editorsData.editors || []) + } + 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 handleRemoveEditor = 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) + } + } + // Helper to extract label string from tldraw label (can be string or {default, menu} object) const getLabelString = (label: any, fallback: string): string => { if (typeof label === 'string') return label @@ -325,13 +477,6 @@ function CustomSharePanel() { - {/* Board settings (protection toggle, editor management) */} -
- -
- - - {/* Star board button */}
@@ -568,6 +713,190 @@ function CustomSharePanel() {
+ {/* Board Protection Section - only for admins */} + {isBoardAdmin && ( +
+ {/* Section Header */} +
+ 🛡️ + Board Protection + {isGlobalAdmin && ( + + Global Admin + + )} +
+ + {/* Protection Toggle */} +
+
+
+ View-only Mode +
+
+ {boardProtected ? 'Only listed editors can make changes' : 'Anyone can edit this board'} +
+
+ +
+ + {/* Editor Management - only when protected */} + {boardProtected && ( +
+
+ Editors ({editors.length}) +
+ + {/* Add Editor Input */} +
0 ? '10px' : '0' }}> + setInviteInput(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') handleInviteEditor() + }} + 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 && ( +
+ {editors.map((editor) => ( +
+ + @{editor.username} + + +
+ ))} +
+ )} + + {editors.length === 0 && ( +
+ No editors added yet +
+ )} +
+ )} +
+ )} + + {isBoardAdmin && ( +
+ )} + {/* Appearance Toggle */}
{ try {