Compare commits
2 Commits
b029179d1d
...
6a09f6a6a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a09f6a6a2 | |||
| 61441eaf7c |
@@ -11,6 +11,10 @@ import "codit/internal/models"
|
||||
func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
var comments []models.BlockComment
|
||||
var err error
|
||||
var user models.User
|
||||
var ok bool
|
||||
var role string
|
||||
var i int
|
||||
|
||||
if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
|
||||
return
|
||||
@@ -23,6 +27,16 @@ func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params
|
||||
if comments == nil {
|
||||
comments = []models.BlockComment{}
|
||||
}
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if ok {
|
||||
role, err = api.boardRoleForUser(r, params["boardId"], user)
|
||||
if err != nil {
|
||||
role = ""
|
||||
}
|
||||
for i = 0; i < len(comments); i++ {
|
||||
comments[i].CanDelete = user.IsAdmin || comments[i].CreatedBy == user.ID || boardRoleAllows(role, "admin")
|
||||
}
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, comments)
|
||||
}
|
||||
|
||||
@@ -63,10 +77,51 @@ func (api *API) CreateBlockComment(w http.ResponseWriter, r *http.Request, param
|
||||
}
|
||||
|
||||
func (api *API) DeleteBlockComment(w http.ResponseWriter, r *http.Request, params map[string]string) {
|
||||
if !api.requireBoardRole(w, r, params["boardId"], "editor") {
|
||||
var err error
|
||||
var user models.User
|
||||
var ok bool
|
||||
var role string
|
||||
var comments []models.BlockComment
|
||||
var i int
|
||||
var found bool
|
||||
var canDelete bool
|
||||
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "requires user account")
|
||||
return
|
||||
}
|
||||
if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
|
||||
return
|
||||
}
|
||||
comments, err = api.store(r).ListBlockComments(params["boardId"], params["blockId"])
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for i = 0; i < len(comments); i++ {
|
||||
if comments[i].ID == params["commentId"] {
|
||||
found = true
|
||||
canDelete = comments[i].CreatedBy == user.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "comment not found")
|
||||
return
|
||||
}
|
||||
if !canDelete {
|
||||
role, err = api.boardRoleForUser(r, params["boardId"], user)
|
||||
if err != nil {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "comment deletion requires the comment author or board admin")
|
||||
return
|
||||
}
|
||||
canDelete = user.IsAdmin || boardRoleAllows(role, "admin")
|
||||
}
|
||||
if !canDelete {
|
||||
WriteJSONWithErrorReason(w, r, http.StatusForbidden, "comment deletion requires the comment author or board admin")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
err = api.store(r).DeleteBlockComment(params["boardId"], params["blockId"], params["commentId"])
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrCommentNotFound) {
|
||||
|
||||
@@ -29,6 +29,36 @@ func boardRoleAllows(actual, required string) bool {
|
||||
return boardRoleRank(actual) >= boardRoleRank(required)
|
||||
}
|
||||
|
||||
func (api *API) boardRoleForUser(r *http.Request, boardID string, user models.User) (string, error) {
|
||||
var role string
|
||||
var boardRole string
|
||||
var boardErr error
|
||||
var projectID string
|
||||
var projRole string
|
||||
var err error
|
||||
|
||||
if user.IsAdmin {
|
||||
return "admin", nil
|
||||
}
|
||||
projectID, err = api.store(r).GetBoardProjectID(boardID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
projRole, err = api.store(r).GetProjectRoleForUser(projectID, user.ID)
|
||||
if err == nil {
|
||||
role = projectRoleToBoardRole(projRole)
|
||||
} else if err != sql.ErrNoRows {
|
||||
return "", err
|
||||
}
|
||||
boardRole, boardErr = api.store(r).GetBoardMemberRole(boardID, user.ID)
|
||||
if boardErr == nil && boardRoleRank(boardRole) > boardRoleRank(role) {
|
||||
role = boardRole
|
||||
} else if boardErr != nil && boardErr != sql.ErrNoRows {
|
||||
return "", boardErr
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func projectRoleToBoardRole(projectRole string) string {
|
||||
switch projectRole {
|
||||
case "admin":
|
||||
@@ -58,35 +88,17 @@ func (api *API) requireBoardRole(w http.ResponseWriter, r *http.Request, boardID
|
||||
var principal models.ServicePrincipal
|
||||
var ok bool
|
||||
var role string
|
||||
var boardRole string
|
||||
var boardErr error
|
||||
var err error
|
||||
var projectID string
|
||||
var projRole string
|
||||
var err error
|
||||
|
||||
user, ok = middleware.UserFromContext(r.Context())
|
||||
if ok && user.IsAdmin {
|
||||
return true
|
||||
}
|
||||
if ok {
|
||||
// Always resolve the project role so an explicit board membership
|
||||
// can never downgrade higher project-level access.
|
||||
projectID, err = api.store(r).GetBoardProjectID(boardID)
|
||||
role, err = api.boardRoleForUser(r, boardID, user)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
projRole, err = api.store(r).GetProjectRoleForUser(projectID, user.ID)
|
||||
if err == nil {
|
||||
role = projectRoleToBoardRole(projRole)
|
||||
}
|
||||
boardRole, boardErr = api.store(r).GetBoardMemberRole(boardID, user.ID)
|
||||
if boardErr == nil && boardRoleRank(boardRole) > boardRoleRank(role) {
|
||||
role = boardRole
|
||||
} else if boardErr != nil && boardErr != sql.ErrNoRows {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
if role == "" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return false
|
||||
|
||||
@@ -788,6 +788,7 @@ type BlockComment struct {
|
||||
CreatedByName string `json:"created_by_name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
CanDelete bool `json:"can_delete"`
|
||||
DeleteAt int64 `json:"delete_at,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1451,6 +1451,7 @@ export interface BlockComment {
|
||||
created_by_name: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
can_delete?: boolean
|
||||
}
|
||||
|
||||
export interface BlockProperties {
|
||||
|
||||
@@ -17,9 +17,13 @@ import {
|
||||
MenuItem,
|
||||
TextField,
|
||||
Tooltip,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { api, Block, BlockComment, BlockProperties, BlockPropertiesPayload, BoardFieldValue } from '../api'
|
||||
import ModalDialog from './ModalDialog'
|
||||
|
||||
@@ -68,6 +72,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
const [loadingProps, setLoadingProps] = useState(false)
|
||||
const [savingProps, setSavingProps] = useState(false)
|
||||
const [propError, setPropError] = useState<string | null>(null)
|
||||
const [descriptionMode, setDescriptionMode] = useState<'write' | 'preview'>('write')
|
||||
|
||||
const [comments, setComments] = useState<BlockComment[]>([])
|
||||
const [loadingComments, setLoadingComments] = useState(false)
|
||||
@@ -99,6 +104,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
setConfirmDelete(false)
|
||||
setNewComment('')
|
||||
setCompletedAt(block.completed_at || 0)
|
||||
setDescriptionMode('write')
|
||||
|
||||
setLoadingProps(true)
|
||||
api.getBlockProperties(boardId, block.id)
|
||||
@@ -263,7 +269,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
const isCompleted: boolean = completedAt > 0
|
||||
|
||||
return (
|
||||
<ModalDialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<ModalDialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle sx={{ pb: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
|
||||
{currentTypeColor ? (
|
||||
@@ -344,16 +350,58 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
{/* Left: description + comments */}
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<Box>
|
||||
<TextField
|
||||
label="Description"
|
||||
value={props_.description}
|
||||
onChange={(e) => setProps({ ...props_, description: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveDescription() }}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={4}
|
||||
disabled={savingProps}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 0.75 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Description
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={descriptionMode}
|
||||
onChange={(_, value: 'write' | 'preview' | null) => {
|
||||
if (value) setDescriptionMode(value)
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="write">Write</ToggleButton>
|
||||
<ToggleButton value="preview">Preview</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
{descriptionMode === 'write' ? (
|
||||
<TextField
|
||||
label="Description (Markdown)"
|
||||
value={props_.description}
|
||||
onChange={(e) => setProps({ ...props_, description: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveDescription() }}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={8}
|
||||
disabled={savingProps}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 236,
|
||||
p: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.default',
|
||||
overflow: 'auto',
|
||||
'& > :first-of-type': { mt: 0 },
|
||||
'& > :last-child': { mb: 0 },
|
||||
'& code': { fontFamily: 'monospace', fontSize: '0.9em' },
|
||||
'& pre': { overflow: 'auto' },
|
||||
}}
|
||||
>
|
||||
{props_.description.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{props_.description}</ReactMarkdown>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
No description.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{descriptionDirty ? (
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Button size="small" variant="contained" onClick={saveDescription} disabled={savingProps}>
|
||||
@@ -388,14 +436,16 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{relativeTime(c.created_at)}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ ml: 'auto', cursor: 'pointer', '&:hover': { color: 'error.main' } }}
|
||||
onClick={() => handleDeleteComment(c.id)}
|
||||
>
|
||||
Delete
|
||||
</Typography>
|
||||
{c.can_delete ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ ml: 'auto', cursor: 'pointer', '&:hover': { color: 'error.main' } }}
|
||||
onClick={() => handleDeleteComment(c.id)}
|
||||
>
|
||||
Delete
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ mt: 0.25, whiteSpace: 'pre-wrap' }}>
|
||||
{c.content}
|
||||
@@ -557,9 +607,13 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
|
||||
<Divider sx={{ mt: 'auto' }} />
|
||||
|
||||
{confirmDelete ? (
|
||||
<Box>
|
||||
<Typography variant="caption" color="error">Delete this card?</Typography>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="caption" color="error" sx={{ fontWeight: 700 }}>
|
||||
Danger zone
|
||||
</Typography>
|
||||
{confirmDelete ? (
|
||||
<Box>
|
||||
<Typography variant="caption" color="error">Delete this card?</Typography>
|
||||
{deleteError ? <Alert severity="error" sx={{ my: 1 }}>{deleteError}</Alert> : null}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<Button size="small" color="error" variant="contained" onClick={handleDelete} disabled={deleting}>
|
||||
@@ -567,12 +621,13 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setConfirmDelete(false)} disabled={deleting}>Cancel</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Button size="small" color="error" onClick={() => setConfirmDelete(true)} sx={{ alignSelf: 'flex-start' }}>
|
||||
Delete card
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Button size="small" color="error" variant="outlined" onClick={() => setConfirmDelete(true)}>
|
||||
Delete card
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import BarChartIcon from '@mui/icons-material/BarChart'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import TableRowsIcon from '@mui/icons-material/TableRows'
|
||||
import ViewKanbanIcon from '@mui/icons-material/ViewKanban'
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Select,
|
||||
Tooltip,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MouseEvent, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api, Block, BlockProperties, BlockPropertiesPayload, Board, BoardFieldValue, GroupBy } from '../api'
|
||||
import BoardFormDialog from '../components/BoardFormDialog'
|
||||
@@ -89,6 +94,7 @@ export default function BoardPage() {
|
||||
const [fieldValues, setFieldValues] = useState<FieldValuesMap>({})
|
||||
const [fieldManagerField, setFieldManagerField] = useState<GroupBy | null>(null)
|
||||
const [fieldsMenuAnchor, setFieldsMenuAnchor] = useState<HTMLElement | null>(null)
|
||||
const [boardActionsAnchor, setBoardActionsAnchor] = useState<HTMLElement | null>(null)
|
||||
|
||||
const [selectedCard, setSelectedCard] = useState<Block | null>(null)
|
||||
|
||||
@@ -221,18 +227,6 @@ export default function BoardPage() {
|
||||
<ContextToolbar
|
||||
kind="Board"
|
||||
label={board?.title ?? 'Board'}
|
||||
nav={
|
||||
<ToggleButtonGroup
|
||||
value={view}
|
||||
exclusive
|
||||
size="small"
|
||||
onChange={(_, v) => { if (v) setView(v) }}
|
||||
>
|
||||
<ToggleButton value="board"><ViewKanbanIcon fontSize="small" sx={{ mr: 0.5 }} />Board</ToggleButton>
|
||||
<ToggleButton value="table"><TableRowsIcon fontSize="small" sx={{ mr: 0.5 }} />Table</ToggleButton>
|
||||
<ToggleButton value="chart"><BarChartIcon fontSize="small" sx={{ mr: 0.5 }} />Charts</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
@@ -250,10 +244,20 @@ export default function BoardPage() {
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
value={view}
|
||||
exclusive
|
||||
size="small"
|
||||
onChange={(_, v) => { if (v) setView(v) }}
|
||||
>
|
||||
<ToggleButton value="board"><ViewKanbanIcon fontSize="small" sx={{ mr: 0.5 }} />Board</ToggleButton>
|
||||
<ToggleButton value="table"><TableRowsIcon fontSize="small" sx={{ mr: 0.5 }} />Table</ToggleButton>
|
||||
<ToggleButton value="chart"><BarChartIcon fontSize="small" sx={{ mr: 0.5 }} />Charts</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<ListRowActions>
|
||||
<ListRowActionButton
|
||||
startIcon={<SettingsIcon />}
|
||||
onClick={(e) => setFieldsMenuAnchor(e.currentTarget)}
|
||||
onClick={(e: MouseEvent<HTMLElement>) => setFieldsMenuAnchor(e.currentTarget)}
|
||||
>
|
||||
Fields
|
||||
</ListRowActionButton>
|
||||
@@ -268,12 +272,47 @@ export default function BoardPage() {
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
<ListRowActionButton onClick={() => { setEditBoardOpen(true); setEditBoardError(null) }}>
|
||||
Edit
|
||||
</ListRowActionButton>
|
||||
<ListRowActionButton color="error" onClick={() => { setDeleteBoardOpen(true); setDeleteBoardError(null); setDeleteBoardConfirmValue('') }}>
|
||||
Delete
|
||||
</ListRowActionButton>
|
||||
<Tooltip title="Board actions">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Board actions"
|
||||
onClick={(e: MouseEvent<HTMLElement>) => setBoardActionsAnchor(e.currentTarget)}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={boardActionsAnchor}
|
||||
open={!!boardActionsAnchor}
|
||||
onClose={() => setBoardActionsAnchor(null)}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setBoardActionsAnchor(null)
|
||||
setEditBoardOpen(true)
|
||||
setEditBoardError(null)
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<EditIcon fontSize="small" />
|
||||
Edit board
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
sx={{ color: 'error.main' }}
|
||||
onClick={() => {
|
||||
setBoardActionsAnchor(null)
|
||||
setDeleteBoardOpen(true)
|
||||
setDeleteBoardError(null)
|
||||
setDeleteBoardConfirmValue('')
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
Delete board
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</ListRowActions>
|
||||
</>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user