Compare commits

...

3 Commits

15 changed files with 569 additions and 29 deletions
+57
View File
@@ -0,0 +1,57 @@
package db
import "database/sql"
import "time"
// TransferRepo reparents a repository to another project by changing its owning
// project_id. Physical storage is keyed by the numeric repo id (not the project),
// so no files move. The caller is responsible for enforcing the destination-project
// name/type uniqueness (idx_repos_project_name_type) before calling — a collision
// surfaces here as a constraint error.
func (s *Store) TransferRepo(repoPublicID string, destProjectID string) error {
var result sql.Result
var rows int64
var err error
result, err = s.Exec(`UPDATE repos
SET project_id = (SELECT id FROM projects WHERE public_id = ?)
WHERE public_id = ?`, destProjectID, repoPublicID)
if err != nil { return err }
rows, err = result.RowsAffected()
if err != nil { return err }
if rows <= 0 { return sql.ErrNoRows }
return nil
}
// ClearRepoSigningKey detaches a repo's signing key. Used when a transfer moves a
// repo away from a project whose project-scoped GPG key is no longer selectable.
func (s *Store) ClearRepoSigningKey(repoPublicID string) error {
var err error
_, err = s.Exec(`UPDATE repos SET signing_key_id = NULL WHERE public_id = ?`, repoPublicID)
return err
}
// TransferBoard reparents a board to another project. Board child rows (blocks,
// comments, etc.) are keyed by board id and follow automatically; explicit
// board_members that are not members of the destination project are pruned by the
// caller after this returns.
func (s *Store) TransferBoard(boardPublicID string, destProjectID string, updatedBy string) error {
var result sql.Result
var rows int64
var err error
result, err = s.Exec(`UPDATE boards
SET project_id = (SELECT id FROM projects WHERE public_id = ?),
updated_by = COALESCE((SELECT id FROM users WHERE public_id = ?), updated_by),
updated_at = ?
WHERE public_id = ? AND delete_at = 0`,
destProjectID, updatedBy, time.Now().UTC().Unix(), boardPublicID)
if err != nil {
return err
}
rows, err = result.RowsAffected()
if err != nil { return err }
if rows <= 0 { return sql.ErrNoRows }
return nil
}
+259
View File
@@ -0,0 +1,259 @@
package handlers
import "database/sql"
import "net/http"
import "strings"
import "codit/internal/middleware"
import "codit/internal/models"
// transferRequest carries the destination project for a repo/board transfer.
type transferRequest struct {
TargetProjectID string `json:"target_project_id"`
}
// transferRepoResponse is the repo response plus a flag telling the UI that the
// signing key was dropped because it was scoped to the old project.
type transferRepoResponse struct {
repoResponse
SigningKeyCleared bool `json:"signing_key_cleared"`
}
// transferBoardResponse is the moved board plus any board members that were
// pruned because they are not members of the destination project.
type transferBoardResponse struct {
models.Board
PrunedMembers []string `json:"pruned_members,omitempty"`
}
// TransferRepo reparents a repository to another project. The caller must be an
// admin on BOTH the source (owning) and destination projects. Physical storage is
// keyed by the numeric repo id, so nothing moves on disk; but the public clone /
// RPM / Docker URLs (which embed the project slug) change, and access re-scopes to
// the destination project's members. A project-scoped signing key that is not
// selectable in the destination is dropped.
func (api *API) TransferRepo(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var req transferRequest
var destProject models.Project
var destID string
var prevSigningKeyID string
var signingKeyCleared bool
var exists bool
var err error
var cloneURL string
var rpmURL string
var dockerURL string
repo, err = api.getRepoResolved(api.store(r), params["id"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "repo not found")
return
}
// Admin on the source (owning) project.
if !api.requireProjectRole(w, r, repo.ProjectID, models.RoleAdmin) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
destID = strings.TrimSpace(req.TargetProjectID)
if destID == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "target_project_id required")
return
}
if destID == repo.ProjectID {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "repository is already in this project")
return
}
destProject, err = api.store(r).GetProject(destID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "target project not found")
return
}
// Admin on the destination project too.
if !api.requireProjectRole(w, r, destProject.ID, models.RoleAdmin) {
return
}
// Hard-block a name/type collision in the destination (idx_repos_project_name_type).
exists, err = api.store(r).RepoNameExists(destProject.ID, repo.Name, repo.Type)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if exists {
WriteJSONWithErrorReason(w, r, http.StatusConflict, "destination project already has a "+repo.Type+" repository named \""+repo.Name+"\"")
return
}
err = api.store(r).TransferRepo(repo.ID, destProject.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
// If the repo was also foreign-attached to the destination, that attachment is
// now redundant (it owns it). Drop it (no-op if it was never attached).
err = api.store(r).DetachRepoFromProject(destProject.ID, repo.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
// Re-scope the signing key: a key scoped to the old project is not selectable
// in the destination, so clear it rather than leave signing misconfigured.
prevSigningKeyID = strings.TrimSpace(repo.SigningKeyID)
if prevSigningKeyID != "" {
var selectable bool
selectable, err = api.store(r).GPGKeySelectableByProject(prevSigningKeyID, destProject.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
if !selectable {
err = api.store(r).ClearRepoSigningKey(repo.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
signingKeyCleared = true
}
}
repo, err = api.getRepoResolved(api.store(r), repo.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
// A cleared signing key on an RPM repo means its published signature/key are
// stale; rebuild repodata so the on-disk metadata reflects the new state.
if signingKeyCleared && repo.Type == models.RepoTypeRPM {
api.scheduleRepoRepodataRebuilds(repo.Path)
}
if repo.Type == models.RepoTypeGit {
cloneURL = api.cloneURL(destProject.Slug, repo.Name)
}
if repo.Type == models.RepoTypeRPM {
rpmURL = api.rpmURL(destProject.Slug, repo.Name)
}
if repo.Type == models.RepoTypeDocker {
dockerURL = api.dockerURL(destProject.Slug, repo.Name)
}
WriteJSON(w, http.StatusOK, transferRepoResponse{
repoResponse: repoResponse{
Repo: repo,
CloneURL: cloneURL,
RPMURL: rpmURL,
DockerURL: dockerURL,
OwnerProjectName: destProject.Name,
OwnerProject: destProject.ID,
OwnerSlug: destProject.Slug,
},
SigningKeyCleared: signingKeyCleared,
})
}
// TransferBoard reparents a board to another project. The caller must be an admin
// on BOTH the source and destination projects. Board child rows follow the board
// id automatically; explicit board members that are not members of the destination
// project are pruned (they would otherwise retain access after the move).
func (api *API) TransferBoard(w http.ResponseWriter, r *http.Request, params map[string]string) {
var board models.Board
var req transferRequest
var destProject models.Project
var destID string
var updatedBy string
var members []models.BoardMember
var pruned []string
var m models.BoardMember
var user models.User
var ok bool
var err error
board, err = api.store(r).GetBoard(params["boardId"])
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "board not found")
return
}
// Admin on the source project.
if !api.requireProjectRole(w, r, board.ProjectID, models.RoleAdmin) {
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "invalid json")
return
}
destID = strings.TrimSpace(req.TargetProjectID)
if destID == "" {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "target_project_id required")
return
}
if destID == board.ProjectID {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, "board is already in this project")
return
}
destProject, err = api.store(r).GetProject(destID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusNotFound, "target project not found")
return
}
// Admin on the destination project too.
if !api.requireProjectRole(w, r, destProject.ID, models.RoleAdmin) {
return
}
members, err = api.store(r).ListBoardMembers(board.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
user, ok = middleware.UserFromContext(r.Context())
if ok {
updatedBy = user.ID
}
err = api.store(r).TransferBoard(board.ID, destProject.ID, updatedBy)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
// Prune explicit board members who have no role in the destination project;
// otherwise they would keep board access across the move.
for _, m = range members {
var role string
var rerr error
role, rerr = api.store(r).GetProjectRoleForUser(destProject.ID, m.UserID)
if rerr == sql.ErrNoRows || (rerr == nil && role == "") {
err = api.store(r).DeleteBoardMember(board.ID, m.UserID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
pruned = append(pruned, m.Username)
continue
}
if rerr != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, rerr.Error())
return
}
}
board, err = api.store(r).GetBoard(board.ID)
if err != nil {
WriteJSONWithErrorReason(w, r, http.StatusInternalServerError, err.Error())
return
}
WriteJSON(w, http.StatusOK, transferBoardResponse{Board: board, PrunedMembers: pruned})
}
+2 -2
View File
@@ -91,7 +91,7 @@ func buildTeamsPayload(event models.Event) teamsMessageCard {
}
func (d *Dispatcher) deliverTeams(ctx context.Context, target models.DeliveryTarget, event models.Event) error {
return d.postJSON(ctx, target.ChannelName, target.Address, buildTeamsPayload(event))
return d.postJSON(ctx, target.ChannelName, target.Address, buildTeamsPayload(d.withOriginTitle(event)))
}
// slackMessage uses the attachment form so the severity color renders as a bar.
@@ -122,5 +122,5 @@ func buildSlackPayload(event models.Event) slackMessage {
}
func (d *Dispatcher) deliverSlack(ctx context.Context, target models.DeliveryTarget, event models.Event) error {
return d.postJSON(ctx, target.ChannelName, target.Address, buildSlackPayload(event))
return d.postJSON(ctx, target.ChannelName, target.Address, buildSlackPayload(d.withOriginTitle(event)))
}
+3 -3
View File
@@ -54,7 +54,7 @@ func TestDeliverSlackPostsPayload(t *testing.T) {
}))
defer srv.Close()
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
err = d.deliverSlack(context.Background(), models.DeliveryTarget{ChannelName: "alerts", TransportType: models.TransportTypeSlack, Address: srv.URL}, models.Event{Title: "down", Body: "refused", Severity: models.EventSeverityCritical})
if err != nil {
t.Fatalf("deliverSlack error: %v", err)
@@ -75,7 +75,7 @@ func TestDeliverTeamsErrorsOnBadStatus(t *testing.T) {
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
err = d.deliverTeams(context.Background(), models.DeliveryTarget{ChannelName: "t", TransportType: models.TransportTypeMSTeams, Address: srv.URL}, models.Event{Title: "x"})
if err == nil {
t.Fatalf("expected error on 400 status")
@@ -85,7 +85,7 @@ func TestDeliverTeamsErrorsOnBadStatus(t *testing.T) {
func TestPostJSONErrorsOnEmptyURL(t *testing.T) {
var d *Dispatcher
var err error
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
err = d.postJSON(context.Background(), "c", "", map[string]string{})
if err == nil {
t.Fatalf("expected error on empty URL")
+44 -10
View File
@@ -8,6 +8,7 @@ package notify
import "context"
import "encoding/json"
import "net/http"
import "strings"
import "time"
import "codit/internal/db"
@@ -30,23 +31,29 @@ func (d *Dispatcher) webhookTimeout() time.Duration {
// Dispatcher delivers events to the channels attached to their source monitor.
type Dispatcher struct {
store *db.Store
logger codit_logger.Logger
secrets *secretcrypto.Box
policy monitor.EgressPolicy
client *http.Client
store *db.Store
logger codit_logger.Logger
secrets *secretcrypto.Box
policy monitor.EgressPolicy
client *http.Client
serverID string // this deployment's service id, stamped on outbound alerts
siteName string // this deployment's human site name, stamped on outbound alerts
}
// NewDispatcher builds a dispatcher. The HTTP client dials through the egress
// policy so webhook delivery is subject to the same SSRF guard as the probes;
// dataDir locates the deployment key used to decrypt transport secrets. The
// policy is retained so per-transport TLS variants can reuse the same dialer.
func NewDispatcher(store *db.Store, policy monitor.EgressPolicy, dataDir string, logger codit_logger.Logger) *Dispatcher {
// serverID and siteName identify this deployment and are stamped onto alerts
// sent to external channels (never onto the persisted event / in-app feed).
func NewDispatcher(store *db.Store, serverID string, siteName string, policy monitor.EgressPolicy, dataDir string, logger codit_logger.Logger) *Dispatcher {
return &Dispatcher{
store: store,
logger: logger,
secrets: secretcrypto.New(dataDir),
policy: policy,
store: store,
logger: logger,
secrets: secretcrypto.New(dataDir),
policy: policy,
serverID: strings.TrimSpace(serverID),
siteName: strings.TrimSpace(siteName),
client: &http.Client{
Transport: &http.Transport{
DialContext: policy.DialContext,
@@ -56,6 +63,33 @@ func NewDispatcher(store *db.Store, policy monitor.EgressPolicy, dataDir string,
}
}
// originLabel identifies the sending deployment for external recipients as
// "<service-id> (<site-name>)" — the app's own self-identity convention, kept
// ASCII so it is safe in an email Subject header. Drops whichever part is empty;
// returns "" if both are empty (then no decoration is applied).
func (d *Dispatcher) originLabel() string {
if d.serverID != "" && d.siteName != "" {
return d.siteName + " (" + d.serverID + ")"
}
if d.siteName != "" {
return d.siteName
}
return d.serverID
}
// withOriginTitle returns a copy of the event whose Title is prefixed with the
// sending deployment's identity, so external recipients can tell at a glance
// which system raised the alert. The persisted event is passed by value, so the
// in-app feed and other channels are unaffected.
func (d *Dispatcher) withOriginTitle(event models.Event) models.Event {
var origin string
origin = d.originLabel()
if origin != "" {
event.Title = "[" + origin + "] " + event.Title
}
return event
}
// secretMap decrypts a stored transport secret blob into its field map.
func (d *Dispatcher) secretMap(encrypted string) map[string]string {
var plain string
+1 -1
View File
@@ -165,7 +165,7 @@ func (d *Dispatcher) deliverMSGraph(ctx context.Context, target models.DeliveryT
}
sendURL = "https://" + graphHost + "/v1.0/users/" + url.PathEscape(strings.TrimSpace(cfg.Sender)) + "/sendMail"
payload, err = json.Marshal(buildGraphSendMail(event, recipients))
payload, err = json.Marshal(buildGraphSendMail(d.withOriginTitle(event), recipients))
if err != nil {
return err
}
+1 -1
View File
@@ -187,7 +187,7 @@ func (d *Dispatcher) deliverSMTP(ctx context.Context, target models.DeliveryTarg
if err != nil {
return err
}
_, err = wc.Write(buildEmailMessage(cfg.From, recipients, event.Title, event.Body, time.Unix(event.CreatedAt, 0).UTC()))
_, err = wc.Write(buildEmailMessage(cfg.From, recipients, d.withOriginTitle(event).Title, event.Body, time.Unix(event.CreatedAt, 0).UTC()))
if err != nil {
wc.Close()
return err
+6
View File
@@ -98,6 +98,10 @@ type webhookPayload struct {
Payload string `json:"payload,omitempty"`
Channel string `json:"channel"`
CreatedAt int64 `json:"created_at"`
// Sending-deployment identity so a receiver can tell which system raised the
// alert without inspecting the URL it was delivered to.
ServiceID string `json:"service_id,omitempty"`
SiteName string `json:"site_name,omitempty"`
}
// deliverWebhook POSTs the event as JSON to the channel's address. The client's
@@ -143,6 +147,8 @@ func (d *Dispatcher) deliverWebhook(ctx context.Context, target models.DeliveryT
Payload: event.Payload,
Channel: target.ChannelName,
CreatedAt: event.CreatedAt,
ServiceID: d.serverID,
SiteName: d.siteName,
})
if err != nil {
return err
+6 -6
View File
@@ -44,7 +44,7 @@ func TestDeliverWebhookPostsJSONWithAuthHeader(t *testing.T) {
}))
defer srv.Close()
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
target = models.DeliveryTarget{
ChannelName: "ops-webhook",
TransportType: models.TransportTypeWebhook,
@@ -79,7 +79,7 @@ func TestDeliverWebhookErrorsOnBadStatus(t *testing.T) {
}))
defer srv.Close()
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
err = d.deliverWebhook(context.Background(), models.DeliveryTarget{
ChannelName: "ops-webhook",
TransportType: models.TransportTypeWebhook,
@@ -94,7 +94,7 @@ func TestClientForDefaultsToSharedClient(t *testing.T) {
var d *Dispatcher
var c *http.Client
var err error
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
c, err = d.clientFor(webhookConfig{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -108,7 +108,7 @@ func TestClientForInsecureSkipVerifyBuildsClient(t *testing.T) {
var d *Dispatcher
var c *http.Client
var err error
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
c, err = d.clientFor(webhookConfig{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -123,7 +123,7 @@ func TestClientForFailsClosedWhenCAUnresolvable(t *testing.T) {
var err error
// nil store => the pinned CA cannot be resolved; must fail closed rather
// than fall back to system roots.
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
_, err = d.clientFor(webhookConfig{CARef: "ca-123"})
if err == nil {
t.Fatalf("expected fail-closed error when CA cannot be resolved")
@@ -133,7 +133,7 @@ func TestClientForFailsClosedWhenCAUnresolvable(t *testing.T) {
func TestDeliverWebhookErrorsOnEmptyAddress(t *testing.T) {
var d *Dispatcher
var err error
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
err = d.deliverWebhook(context.Background(), models.DeliveryTarget{ChannelName: "x", TransportType: models.TransportTypeWebhook}, map[string]string{}, testEvent())
if err == nil {
t.Fatalf("expected error on empty address, got nil")
+3 -1
View File
@@ -699,7 +699,7 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
// External-delivery relay: drains the events outbox and dispatches each event
// to its matching notification channels (rules + per-monitor attachment). One
// path for every producer (monitoring, RPM mirror, …).
app.notify_relay = notify.NewRelay(app.store, notify.NewDispatcher(app.store, monitor.DefaultEgressPolicy(), cfg.DataDir, logger), logger)
app.notify_relay = notify.NewRelay(app.store, notify.NewDispatcher(app.store, app.serverId, app.siteName, monitor.DefaultEgressPolicy(), cfg.DataDir, logger), logger)
app.upload_store = &storage.FileStore{BaseDir: app.uploads_base_dir}
app.repo_locks = repolock.NewManager()
@@ -1239,6 +1239,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/boards", txRead(api.ListAllBoards))
router.Handle("GET", "/api/boards/:boardId", txRead(api.GetBoard))
router.Handle("PATCH", "/api/boards/:boardId", txWrite(api.UpdateBoard))
router.Handle("POST", "/api/boards/:boardId/transfer", txWrite(api.TransferBoard))
router.Handle("DELETE", "/api/boards/:boardId", txWrite(api.DeleteBoard))
router.Handle("GET", "/api/boards/:boardId/blocks", txRead(api.ListBlocks))
router.Handle("POST", "/api/boards/:boardId/blocks", txWrite(api.CreateBlock))
@@ -1284,6 +1285,7 @@ func (app *Server) initHandlers() (*handlers.API, *http.ServeMux, error) {
router.Handle("GET", "/api/repos", txRead(api.ListAllRepos))
router.Handle("GET", "/api/repos/:id", txRead(api.GetRepo))
router.Handle("PATCH", "/api/repos/:id", txWrite(api.UpdateRepo))
router.Handle("POST", "/api/repos/:id/transfer", txWrite(api.TransferRepo))
router.Handle("DELETE", "/api/repos/:id", api.DeleteRepo)
router.Handle("GET", "/api/repos/:id/branches", api.RepoBranches)
router.Handle("GET", "/api/repos/:id/branches/info", api.RepoBranchesInfo)
+16
View File
@@ -132,6 +132,12 @@ export interface Repo {
owner_slug?: string
}
export interface TransferRepoResult extends Repo {
// true when a project-scoped signing key was dropped because it is not
// selectable in the destination project.
signing_key_cleared?: boolean
}
export interface RepoStats {
repo_id: string
branches: number
@@ -1563,6 +1569,12 @@ export interface Board {
delete_at?: number
}
export interface TransferBoardResult extends Board {
// usernames of board members pruned because they are not members of the
// destination project.
pruned_members?: string[]
}
export type BoardCreatePayload = {
title: string
description?: string
@@ -2477,6 +2489,8 @@ export const api = {
})
},
deleteRepo: (repoId: string) => request<void>(`/api/repos/${repoId}`, { method: 'DELETE' }),
transferRepo: (repoId: string, targetProjectId: string) =>
request<TransferRepoResult>(`/api/repos/${repoId}/transfer`, { method: 'POST', body: JSON.stringify({ target_project_id: targetProjectId }) }),
listSelectableSigningKeys: (projectId: string) => request<GPGKey[]>(`/api/projects/${projectId}/selectable-signing-keys`),
listAvailableRepos: (projectId: string, query?: string, limit?: number, offset?: number) => {
const params = new URLSearchParams()
@@ -2742,6 +2756,8 @@ export const api = {
updateBoard: (boardId: string, payload: BoardUpdatePayload) =>
request<Board>(`/api/boards/${boardId}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteBoard: (boardId: string) => request<void>(`/api/boards/${boardId}`, { method: 'DELETE' }),
transferBoard: (boardId: string, targetProjectId: string) =>
request<TransferBoardResult>(`/api/boards/${boardId}/transfer`, { method: 'POST', body: JSON.stringify({ target_project_id: targetProjectId }) }),
listBlocks: (boardId: string, type?: string) => {
const params = new URLSearchParams()
@@ -0,0 +1,109 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import Autocomplete from './Autocomplete'
import FormDialogContent from './FormDialogContent'
import { useEffect, useState } from 'react'
import { api, Project } from '../api'
type TransferProjectDialogProps = {
open: boolean
// what is being moved — drives the copy and warnings.
kind: 'repository' | 'board'
itemName: string
// owning project, excluded from the candidate list.
currentProjectId: string
repoType?: 'git' | 'rpm' | 'docker'
onClose: () => void
// performs the transfer; the dialog owns busy/error UI around it.
onConfirm: (targetProjectId: string) => Promise<void>
}
export default function TransferProjectDialog(props: TransferProjectDialogProps) {
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState<boolean>(false)
const [target, setTarget] = useState<Project | null>(null)
const [busy, setBusy] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!props.open) return
setTarget(null)
setError(null)
setLoading(true)
api.listProjects(500)
.then((page) => setProjects((page.items || []).filter((p) => p.id !== props.currentProjectId)))
.catch(() => setProjects([]))
.finally(() => setLoading(false))
}, [props.open, props.currentProjectId])
const handleConfirm = async (): Promise<void> => {
if (!target) {
setError('Select a destination project.')
return
}
setBusy(true)
setError(null)
try {
await props.onConfirm(target.id)
props.onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Transfer failed')
} finally {
setBusy(false)
}
}
return (
<Dialog open={props.open} onClose={props.onClose} maxWidth="xs" fullWidth>
<DialogTitle>Transfer {props.kind}</DialogTitle>
<FormDialogContent>
{error ? <Alert severity="error">{error}</Alert> : null}
<Typography variant="body2" color="text.secondary">
Move <strong>{props.itemName}</strong> to another project. You must be an admin of both this project and the destination.
</Typography>
<Autocomplete<Project, false, false, false>
options={projects}
loading={loading}
value={target}
onChange={(_event, value) => setTarget(value)}
getOptionLabel={(option) => option.name || option.slug}
isOptionEqualToValue={(option, value) => option.id === value.id}
renderInput={(params) => <TextField {...params} label="Destination project" margin="dense" />}
/>
<Alert severity="warning" sx={{ mt: 0.5 }}>
<Box sx={{ display: 'grid', gap: 0.5 }}>
{props.kind === 'repository' ? (
<>
<span>
Clone / RPM / Docker URLs change to the destination project's slug existing remotes,
image references, and <code>.repo</code> baseurls will stop working.
</span>
<span>Access changes to the destination project's members.</span>
{props.repoType === 'git' || props.repoType === 'rpm' ? (
<span>A signing key scoped to this project will be removed.</span>
) : null}
</>
) : (
<>
<span>Access changes to the destination project's members.</span>
<span>Board members who are not members of the destination project will be removed.</span>
</>
)}
</Box>
</Alert>
</FormDialogContent>
<DialogActions>
<Button onClick={handleConfirm} variant="contained" color="warning" disabled={busy || !target}>
{busy ? 'Transferring...' : 'Transfer'}
</Button>
<Button onClick={props.onClose} disabled={busy}>Cancel</Button>
</DialogActions>
</Dialog>
)
}
+3 -3
View File
@@ -556,7 +556,7 @@ function TextConverterWidget(props: UtilityWidgetProps) {
defaultRatio={0.45}
sx={{ flex: 1, minHeight: 0 }}
left={
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5, mb: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Input</Typography>
<Box sx={{ flex: 1, minHeight: 0 }}>
<LazyCodeEditorField value={input} onChange={setInput} language="plaintext" height="100%" ariaLabel="Input" placeholder="Text to convert" />
@@ -625,7 +625,7 @@ function JsonFormatterWidget(props: UtilityWidgetProps) {
defaultRatio={0.45}
sx={{ flex: 1, minHeight: 0 }}
left={
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5, mb: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Input JSON</Typography>
<Box sx={{ flex: 1, minHeight: 0 }}>
<LazyCodeEditorField value={input} onChange={setInput} language="json" height="100%" ariaLabel="Input JSON" placeholder="Paste or type JSON" />
@@ -708,7 +708,7 @@ function XmlFormatterWidget(props: UtilityWidgetProps) {
defaultRatio={0.45}
sx={{ flex: 1, minHeight: 0 }}
left={
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, gap: 0.5, mb: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Input XML</Typography>
<Box sx={{ flex: 1, minHeight: 0 }}>
<LazyCodeEditorField value={input} onChange={setInput} language="markup" height="100%" ariaLabel="Input XML" placeholder="Paste or type XML" />
+30 -1
View File
@@ -1,5 +1,6 @@
import AddIcon from '@mui/icons-material/Add'
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined'
import EditIcon from '@mui/icons-material/Edit'
import {
Box,
@@ -17,6 +18,7 @@ import PageAlert from '../components/PageAlert'
import ProjectPageFrame from '../components/ProjectPageFrame'
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
import SectionCard from '../components/SectionCard'
import TransferProjectDialog from '../components/TransferProjectDialog'
export default function ProjectBoardsPage() {
const { projectId } = useParams()
@@ -25,6 +27,8 @@ export default function ProjectBoardsPage() {
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [canWrite, setCanWrite] = useState(false)
const [canAdmin, setCanAdmin] = useState(false)
const [transferBoard, setTransferBoard] = useState<Board | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
@@ -62,12 +66,17 @@ export default function ProjectBoardsPage() {
.then(([me, members]) => {
if (me.is_admin) {
setCanWrite(true)
setCanAdmin(true)
return
}
const match = members.find((member) => member.user_id === me.id)
setCanWrite(Boolean(match && (match.role === 'admin' || match.role === 'writer')))
setCanAdmin(Boolean(match && match.role === 'admin'))
})
.catch(() => {
setCanWrite(false)
setCanAdmin(false)
})
.catch(() => setCanWrite(false))
}, [projectId])
useEffect(() => {
@@ -173,6 +182,13 @@ export default function ProjectBoardsPage() {
>
<EditIcon fontSize="small" />
</ListRowIconAction>
<ListRowIconAction
title={canAdmin ? 'Transfer to another project' : 'Only project admins can transfer'}
disabled={!canAdmin}
onClick={() => setTransferBoard(board)}
>
<DriveFileMoveOutlinedIcon fontSize="small" />
</ListRowIconAction>
<ListRowIconAction
title={canWrite ? 'Delete board' : 'You do not have permission to delete'}
color="error"
@@ -250,6 +266,19 @@ export default function ProjectBoardsPage() {
onClose={() => { setDeleteBoard(null); setDeleteError(null); setDeleteConfirmValue('') }}
onConfirm={handleDelete}
/>
<TransferProjectDialog
open={Boolean(transferBoard)}
kind="board"
itemName={transferBoard?.title || ''}
currentProjectId={projectId ?? ''}
onClose={() => setTransferBoard(null)}
onConfirm={async (targetProjectId) => {
if (!transferBoard) return
await api.transferBoard(transferBoard.id, targetProjectId)
load()
}}
/>
</ProjectPageFrame>
)
}
+29 -1
View File
@@ -1,6 +1,7 @@
import AddIcon from '@mui/icons-material/Add'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined'
import EditIcon from '@mui/icons-material/Edit'
import LinkOffIcon from '@mui/icons-material/LinkOff'
import LinkIcon from '@mui/icons-material/Link'
@@ -28,6 +29,7 @@ import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortab
import RepositoryFormDialog from '../components/RepositoryFormDialog'
import SectionCard from '../components/SectionCard'
import SelectField from '../components/SelectField'
import TransferProjectDialog from '../components/TransferProjectDialog'
type RepoAccessInfo = {
label: string
@@ -91,6 +93,8 @@ export default function ProjectReposPage() {
const [page, setPage] = useState(0)
const [pageSize, setPageSize] = useState(20)
const [canWrite, setCanWrite] = useState(false)
const [canAdmin, setCanAdmin] = useState(false)
const [transferRepo, setTransferRepo] = useState<Repo | null>(null)
useEffect(() => {
if (!projectId) return
@@ -146,12 +150,17 @@ export default function ProjectReposPage() {
.then(([me, members]) => {
if (me.is_admin) {
setCanWrite(true)
setCanAdmin(true)
return
}
const match = members.find((member) => member.user_id === me.id)
setCanWrite(Boolean(match && (match.role === 'admin' || match.role === 'writer')))
setCanAdmin(Boolean(match && match.role === 'admin'))
})
.catch(() => {
setCanWrite(false)
setCanAdmin(false)
})
.catch(() => setCanWrite(false))
}, [projectId])
useEffect(() => {
@@ -561,6 +570,9 @@ export default function ProjectReposPage() {
<ListRowIconAction title={canWrite ? 'Edit repository' : 'You do not have permission to edit'} disabled={!canWrite} onClick={() => openEdit(repo)}>
<EditIcon fontSize="small" />
</ListRowIconAction>
<ListRowIconAction title={canAdmin ? 'Transfer to another project' : 'Only project admins can transfer'} disabled={!canAdmin} onClick={() => setTransferRepo(repo)}>
<DriveFileMoveOutlinedIcon fontSize="small" />
</ListRowIconAction>
<ListRowIconAction title={canWrite ? 'Delete repository' : 'You do not have permission to delete'} color="error" disabled={!canWrite} onClick={() => openDelete(repo)}>
<DeleteOutlinedIcon fontSize="small" />
</ListRowIconAction>
@@ -800,6 +812,22 @@ export default function ProjectReposPage() {
onErrorClose={() => setForeignError(null)}
onClose={closeForeign}
/>
<TransferProjectDialog
open={Boolean(transferRepo)}
kind="repository"
itemName={transferRepo?.name || ''}
currentProjectId={projectId ?? ''}
repoType={transferRepo?.type}
onClose={() => setTransferRepo(null)}
onConfirm={async (targetProjectId) => {
if (!transferRepo) return
const movedId = transferRepo.id
await api.transferRepo(movedId, targetProjectId)
setRepos((prev) => prev.filter((repo) => repo.id !== movedId))
setAllRepos((prev) => prev.filter((repo) => repo.id !== movedId))
setTotalRepos((prev) => (prev > 0 ? prev - 1 : 0))
}}
/>
</ProjectPageFrame>
)
}