feat: integrate board protection settings into existing settings dropdown

- Remove separate BoardSettingsDropdown button from UI panel
- Add board protection toggle and editor management to existing settings dropdown
- Show protection section only for admins (board owner or global admin)
- Add ability to toggle view-only mode for protected boards
- Add editor management UI with invite and remove functionality
- Fix TypeScript type annotations for API responses

🤖 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 13:05:45 -05:00
parent 6d96c2bbe2
commit fedd62c87b
3 changed files with 341 additions and 12 deletions

View File

@ -68,21 +68,21 @@ const BoardSettingsDropdown: React.FC<BoardSettingsDropdownProps> = ({ className
// Fetch board info // Fetch board info
const infoRes = await fetch(`${WORKER_URL}/boards/${boardId}/info`, { headers }); 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) { if (infoData.board) {
setBoardInfo(infoData.board); setBoardInfo(infoData.board);
} }
// Fetch permission to check if admin // Fetch permission to check if admin
const permRes = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, { headers }); 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'); setIsAdmin(permData.permission === 'admin');
setIsGlobalAdmin(permData.isGlobalAdmin || false); setIsGlobalAdmin(permData.isGlobalAdmin || false);
// If admin, fetch editors list // If admin, fetch editors list
if (permData.permission === 'admin') { if (permData.permission === 'admin') {
const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers }); 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 || []); setEditors(editorsData.editors || []);
} }
} catch (error) { } catch (error) {

View File

@ -11,8 +11,8 @@ import { NetworkGraphPanel } from "../components/networking"
import CryptIDDropdown from "../components/auth/CryptIDDropdown" import CryptIDDropdown from "../components/auth/CryptIDDropdown"
import StarBoardButton from "../components/StarBoardButton" import StarBoardButton from "../components/StarBoardButton"
import ShareBoardButton from "../components/ShareBoardButton" import ShareBoardButton from "../components/ShareBoardButton"
import BoardSettingsDropdown from "../components/BoardSettingsDropdown"
import { SettingsDialog } from "./SettingsDialog" 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 { VersionHistoryPanel } from "../components/history" // TODO: Re-enable when version reversion is ready
import { useAuth } from "../context/AuthContext" import { useAuth } from "../context/AuthContext"
import { PermissionLevel } from "../lib/auth/types" import { PermissionLevel } from "../lib/auth/types"
@ -62,6 +62,15 @@ function CustomSharePanel() {
const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle') const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [requestMessage, setRequestMessage] = React.useState('') 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<Array<{ userId: string; username: string; permission: string }>>([])
const [inviteInput, setInviteInput] = React.useState('')
const [inviteStatus, setInviteStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
// Refs for dropdown positioning // Refs for dropdown positioning
const settingsButtonRef = React.useRef<HTMLButtonElement>(null) const settingsButtonRef = React.useRef<HTMLButtonElement>(null)
const shortcutsButtonRef = React.useRef<HTMLButtonElement>(null) const shortcutsButtonRef = React.useRef<HTMLButtonElement>(null)
@ -207,6 +216,149 @@ function CustomSharePanel() {
}) })
} }
// Get auth headers for API calls
const getAuthHeaders = React.useCallback((): 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
}, [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) // Helper to extract label string from tldraw label (can be string or {default, menu} object)
const getLabelString = (label: any, fallback: string): string => { const getLabelString = (label: any, fallback: string): string => {
if (typeof label === 'string') return label if (typeof label === 'string') return label
@ -325,13 +477,6 @@ function CustomSharePanel() {
<Separator /> <Separator />
{/* Board settings (protection toggle, editor management) */}
<div style={{ padding: '0 2px' }}>
<BoardSettingsDropdown className="share-panel-btn" />
</div>
<Separator />
{/* Star board button */} {/* Star board button */}
<div style={{ padding: '0 2px' }}> <div style={{ padding: '0 2px' }}>
<StarBoardButton className="share-panel-btn" /> <StarBoardButton className="share-panel-btn" />
@ -568,6 +713,190 @@ function CustomSharePanel() {
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} /> <div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
{/* Board Protection Section - only for admins */}
{isBoardAdmin && (
<div style={{ padding: '12px 16px' }}>
{/* Section Header */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px',
}}>
<span style={{ fontSize: '14px' }}>🛡</span>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>Board Protection</span>
{isGlobalAdmin && (
<span style={{
fontSize: '9px',
padding: '2px 6px',
borderRadius: '10px',
background: '#3b82f620',
color: '#3b82f6',
fontWeight: 600,
}}>
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',
border: '1px solid var(--color-panel-contrast)',
marginBottom: boardProtected ? '12px' : '0',
}}>
<div>
<div style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
View-only Mode
</div>
<div style={{ fontSize: '10px', color: 'var(--color-text-3)' }}>
{boardProtected ? 'Only listed editors can make changes' : 'Anyone can edit this board'}
</div>
</div>
<button
onClick={handleToggleProtection}
disabled={protectionLoading}
style={{
width: '44px',
height: '24px',
borderRadius: '12px',
border: 'none',
cursor: protectionLoading ? 'not-allowed' : 'pointer',
background: boardProtected ? '#3b82f6' : '#d1d5db',
position: 'relative',
transition: 'background 0.2s',
opacity: protectionLoading ? 0.5 : 1,
}}
>
<div style={{
width: '20px',
height: '20px',
borderRadius: '10px',
background: 'white',
position: 'absolute',
top: '2px',
left: boardProtected ? '22px' : '2px',
transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
{/* Editor Management - only when protected */}
{boardProtected && (
<div style={{
padding: '10px 12px',
background: 'var(--color-muted-2)',
borderRadius: '8px',
border: '1px solid var(--color-panel-contrast)',
}}>
<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: editors.length > 0 ? '10px' : '0' }}>
<input
type="text"
placeholder="Username or email..."
value={inviteInput}
onChange={(e) => 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',
}}
/>
<button
onClick={handleInviteEditor}
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 */}
{editors.length > 0 && (
<div style={{ maxHeight: '120px', overflowY: 'auto' }}>
{editors.map((editor) => (
<div
key={editor.userId}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 8px',
borderRadius: '6px',
marginBottom: '4px',
background: 'var(--color-panel)',
}}
>
<span style={{ fontSize: '12px', fontWeight: 500, color: 'var(--color-text)' }}>
@{editor.username}
</span>
<button
onClick={() => handleRemoveEditor(editor.userId)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#ef4444',
fontSize: '12px',
padding: '2px 6px',
opacity: 0.7,
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7' }}
title="Remove editor"
>
×
</button>
</div>
))}
</div>
)}
{editors.length === 0 && (
<div style={{ fontSize: '10px', color: 'var(--color-text-3)', textAlign: 'center', padding: '4px' }}>
No editors added yet
</div>
)}
</div>
)}
</div>
)}
{isBoardAdmin && (
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '0' }} />
)}
{/* Appearance Toggle */} {/* Appearance Toggle */}
<div style={{ padding: '12px 16px' }}> <div style={{ padding: '12px 16px' }}>
<div style={{ <div style={{

View File

@ -1151,7 +1151,7 @@ export async function handleRequestAdminAccess(
*/ */
export async function handleGetBoardInfo( export async function handleGetBoardInfo(
boardId: string, boardId: string,
request: Request, _request: Request,
env: Environment env: Environment
): Promise<Response> { ): Promise<Response> {
try { try {