Compare commits

...

2 Commits

Author SHA1 Message Date
hyung-hwan 6a09f6a6a2 updated card edit dialog 2026-06-16 07:52:31 +09:00
hyung-hwan 61441eaf7c some board page refactoring 2026-06-16 07:21:38 +09:00
6 changed files with 233 additions and 70 deletions
+57 -2
View File
@@ -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) {
+32 -20
View File
@@ -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
+1
View File
@@ -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"`
}
+1
View File
@@ -1451,6 +1451,7 @@ export interface BlockComment {
created_by_name: string
created_at: number
updated_at: number
can_delete?: boolean
}
export interface BlockProperties {
+83 -28
View File
@@ -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>
+59 -20
View File
@@ -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>
</>
}