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) { func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params map[string]string) {
var comments []models.BlockComment var comments []models.BlockComment
var err error var err error
var user models.User
var ok bool
var role string
var i int
if !api.requireBoardRole(w, r, params["boardId"], "viewer") { if !api.requireBoardRole(w, r, params["boardId"], "viewer") {
return return
@@ -23,6 +27,16 @@ func (api *API) ListBlockComments(w http.ResponseWriter, r *http.Request, params
if comments == nil { if comments == nil {
comments = []models.BlockComment{} 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) 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) { 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 return
} }
var err error
err = api.store(r).DeleteBlockComment(params["boardId"], params["blockId"], params["commentId"]) err = api.store(r).DeleteBlockComment(params["boardId"], params["blockId"], params["commentId"])
if err != nil { if err != nil {
if errors.Is(err, db.ErrCommentNotFound) { 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) 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 { func projectRoleToBoardRole(projectRole string) string {
switch projectRole { switch projectRole {
case "admin": case "admin":
@@ -58,35 +88,17 @@ func (api *API) requireBoardRole(w http.ResponseWriter, r *http.Request, boardID
var principal models.ServicePrincipal var principal models.ServicePrincipal
var ok bool var ok bool
var role string var role string
var boardRole string var err error
var boardErr error
var projectID string var projectID string
var projRole string var projRole string
var err error
user, ok = middleware.UserFromContext(r.Context()) user, ok = middleware.UserFromContext(r.Context())
if ok && user.IsAdmin {
return true
}
if ok { if ok {
// Always resolve the project role so an explicit board membership role, err = api.boardRoleForUser(r, boardID, user)
// can never downgrade higher project-level access.
projectID, err = api.store(r).GetBoardProjectID(boardID)
if err != nil { if err != nil {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
return false 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 == "" { if role == "" {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
return false return false
+1
View File
@@ -788,6 +788,7 @@ type BlockComment struct {
CreatedByName string `json:"created_by_name"` CreatedByName string `json:"created_by_name"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
CanDelete bool `json:"can_delete"`
DeleteAt int64 `json:"delete_at,omitempty"` DeleteAt int64 `json:"delete_at,omitempty"`
} }
+1
View File
@@ -1451,6 +1451,7 @@ export interface BlockComment {
created_by_name: string created_by_name: string
created_at: number created_at: number
updated_at: number updated_at: number
can_delete?: boolean
} }
export interface BlockProperties { export interface BlockProperties {
+83 -28
View File
@@ -17,9 +17,13 @@ import {
MenuItem, MenuItem,
TextField, TextField,
Tooltip, Tooltip,
ToggleButton,
ToggleButtonGroup,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { useEffect, useRef, useState } from 'react' 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 { api, Block, BlockComment, BlockProperties, BlockPropertiesPayload, BoardFieldValue } from '../api'
import ModalDialog from './ModalDialog' import ModalDialog from './ModalDialog'
@@ -68,6 +72,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
const [loadingProps, setLoadingProps] = useState(false) const [loadingProps, setLoadingProps] = useState(false)
const [savingProps, setSavingProps] = useState(false) const [savingProps, setSavingProps] = useState(false)
const [propError, setPropError] = useState<string | null>(null) const [propError, setPropError] = useState<string | null>(null)
const [descriptionMode, setDescriptionMode] = useState<'write' | 'preview'>('write')
const [comments, setComments] = useState<BlockComment[]>([]) const [comments, setComments] = useState<BlockComment[]>([])
const [loadingComments, setLoadingComments] = useState(false) const [loadingComments, setLoadingComments] = useState(false)
@@ -99,6 +104,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
setConfirmDelete(false) setConfirmDelete(false)
setNewComment('') setNewComment('')
setCompletedAt(block.completed_at || 0) setCompletedAt(block.completed_at || 0)
setDescriptionMode('write')
setLoadingProps(true) setLoadingProps(true)
api.getBlockProperties(boardId, block.id) api.getBlockProperties(boardId, block.id)
@@ -263,7 +269,7 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
const isCompleted: boolean = completedAt > 0 const isCompleted: boolean = completedAt > 0
return ( return (
<ModalDialog open={open} onClose={onClose} maxWidth="md" fullWidth> <ModalDialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle sx={{ pb: 0 }}> <DialogTitle sx={{ pb: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
{currentTypeColor ? ( {currentTypeColor ? (
@@ -344,16 +350,58 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
{/* Left: description + comments */} {/* Left: description + comments */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}> <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Box> <Box>
<TextField <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 0.75 }}>
label="Description" <Typography variant="subtitle2" color="text.secondary">
value={props_.description} Description
onChange={(e) => setProps({ ...props_, description: e.target.value })} </Typography>
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveDescription() }} <ToggleButtonGroup
fullWidth size="small"
multiline exclusive
minRows={4} value={descriptionMode}
disabled={savingProps} 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 ? ( {descriptionDirty ? (
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}> <Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
<Button size="small" variant="contained" onClick={saveDescription} disabled={savingProps}> <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"> <Typography variant="caption" color="text.secondary">
{relativeTime(c.created_at)} {relativeTime(c.created_at)}
</Typography> </Typography>
<Typography {c.can_delete ? (
variant="caption" <Typography
color="text.secondary" variant="caption"
sx={{ ml: 'auto', cursor: 'pointer', '&:hover': { color: 'error.main' } }} color="text.secondary"
onClick={() => handleDeleteComment(c.id)} sx={{ ml: 'auto', cursor: 'pointer', '&:hover': { color: 'error.main' } }}
> onClick={() => handleDeleteComment(c.id)}
Delete >
</Typography> Delete
</Typography>
) : null}
</Box> </Box>
<Typography variant="body2" sx={{ mt: 0.25, whiteSpace: 'pre-wrap' }}> <Typography variant="body2" sx={{ mt: 0.25, whiteSpace: 'pre-wrap' }}>
{c.content} {c.content}
@@ -557,9 +607,13 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
<Divider sx={{ mt: 'auto' }} /> <Divider sx={{ mt: 'auto' }} />
{confirmDelete ? ( <Box sx={{ display: 'grid', gap: 1 }}>
<Box> <Typography variant="caption" color="error" sx={{ fontWeight: 700 }}>
<Typography variant="caption" color="error">Delete this card?</Typography> Danger zone
</Typography>
{confirmDelete ? (
<Box>
<Typography variant="caption" color="error">Delete this card?</Typography>
{deleteError ? <Alert severity="error" sx={{ my: 1 }}>{deleteError}</Alert> : null} {deleteError ? <Alert severity="error" sx={{ my: 1 }}>{deleteError}</Alert> : null}
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}> <Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Button size="small" color="error" variant="contained" onClick={handleDelete} disabled={deleting}> <Button size="small" color="error" variant="contained" onClick={handleDelete} disabled={deleting}>
@@ -567,12 +621,13 @@ export default function CardDetailDialog(props: CardDetailDialogProps) {
</Button> </Button>
<Button size="small" onClick={() => setConfirmDelete(false)} disabled={deleting}>Cancel</Button> <Button size="small" onClick={() => setConfirmDelete(false)} disabled={deleting}>Cancel</Button>
</Box> </Box>
</Box> </Box>
) : ( ) : (
<Button size="small" color="error" onClick={() => setConfirmDelete(true)} sx={{ alignSelf: 'flex-start' }}> <Button size="small" color="error" variant="outlined" onClick={() => setConfirmDelete(true)}>
Delete card Delete card
</Button> </Button>
)} )}
</Box>
</Box> </Box>
</Box> </Box>
</DialogContent> </DialogContent>
+59 -20
View File
@@ -1,17 +1,22 @@
import BarChartIcon from '@mui/icons-material/BarChart' 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 SettingsIcon from '@mui/icons-material/Settings'
import TableRowsIcon from '@mui/icons-material/TableRows' import TableRowsIcon from '@mui/icons-material/TableRows'
import ViewKanbanIcon from '@mui/icons-material/ViewKanban' import ViewKanbanIcon from '@mui/icons-material/ViewKanban'
import { import {
Box, Box,
IconButton,
Menu, Menu,
MenuItem, MenuItem,
Select, Select,
Tooltip,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Typography, Typography,
} from '@mui/material' } from '@mui/material'
import { useEffect, useState } from 'react' import { MouseEvent, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api, Block, BlockProperties, BlockPropertiesPayload, Board, BoardFieldValue, GroupBy } from '../api' import { api, Block, BlockProperties, BlockPropertiesPayload, Board, BoardFieldValue, GroupBy } from '../api'
import BoardFormDialog from '../components/BoardFormDialog' import BoardFormDialog from '../components/BoardFormDialog'
@@ -89,6 +94,7 @@ export default function BoardPage() {
const [fieldValues, setFieldValues] = useState<FieldValuesMap>({}) const [fieldValues, setFieldValues] = useState<FieldValuesMap>({})
const [fieldManagerField, setFieldManagerField] = useState<GroupBy | null>(null) const [fieldManagerField, setFieldManagerField] = useState<GroupBy | null>(null)
const [fieldsMenuAnchor, setFieldsMenuAnchor] = useState<HTMLElement | null>(null) const [fieldsMenuAnchor, setFieldsMenuAnchor] = useState<HTMLElement | null>(null)
const [boardActionsAnchor, setBoardActionsAnchor] = useState<HTMLElement | null>(null)
const [selectedCard, setSelectedCard] = useState<Block | null>(null) const [selectedCard, setSelectedCard] = useState<Block | null>(null)
@@ -221,18 +227,6 @@ export default function BoardPage() {
<ContextToolbar <ContextToolbar
kind="Board" kind="Board"
label={board?.title ?? '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={ actions={
<> <>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
@@ -250,10 +244,20 @@ export default function BoardPage() {
))} ))}
</Select> </Select>
</Box> </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> <ListRowActions>
<ListRowActionButton <ListRowActionButton
startIcon={<SettingsIcon />} startIcon={<SettingsIcon />}
onClick={(e) => setFieldsMenuAnchor(e.currentTarget)} onClick={(e: MouseEvent<HTMLElement>) => setFieldsMenuAnchor(e.currentTarget)}
> >
Fields Fields
</ListRowActionButton> </ListRowActionButton>
@@ -268,12 +272,47 @@ export default function BoardPage() {
</MenuItem> </MenuItem>
))} ))}
</Menu> </Menu>
<ListRowActionButton onClick={() => { setEditBoardOpen(true); setEditBoardError(null) }}> <Tooltip title="Board actions">
Edit <IconButton
</ListRowActionButton> size="small"
<ListRowActionButton color="error" onClick={() => { setDeleteBoardOpen(true); setDeleteBoardError(null); setDeleteBoardConfirmValue('') }}> aria-label="Board actions"
Delete onClick={(e: MouseEvent<HTMLElement>) => setBoardActionsAnchor(e.currentTarget)}
</ListRowActionButton> >
<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> </ListRowActions>
</> </>
} }