Compare commits
3 Commits
acad913bf2
...
25c0d44218
| Author | SHA1 | Date | |
|---|---|---|---|
| 25c0d44218 | |||
| 130b070a79 | |||
| 197b067d80 |
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ func buildTeamsPayload(event models.Event) teamsMessageCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) deliverTeams(ctx context.Context, target models.DeliveryTarget, event models.Event) error {
|
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.
|
// 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 {
|
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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestDeliverSlackPostsPayload(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
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})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("deliverSlack error: %v", err)
|
t.Fatalf("deliverSlack error: %v", err)
|
||||||
@@ -75,7 +75,7 @@ func TestDeliverTeamsErrorsOnBadStatus(t *testing.T) {
|
|||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
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"})
|
err = d.deliverTeams(context.Background(), models.DeliveryTarget{ChannelName: "t", TransportType: models.TransportTypeMSTeams, Address: srv.URL}, models.Event{Title: "x"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected error on 400 status")
|
t.Fatalf("expected error on 400 status")
|
||||||
@@ -85,7 +85,7 @@ func TestDeliverTeamsErrorsOnBadStatus(t *testing.T) {
|
|||||||
func TestPostJSONErrorsOnEmptyURL(t *testing.T) {
|
func TestPostJSONErrorsOnEmptyURL(t *testing.T) {
|
||||||
var d *Dispatcher
|
var d *Dispatcher
|
||||||
var err error
|
var err error
|
||||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||||
err = d.postJSON(context.Background(), "c", "", map[string]string{})
|
err = d.postJSON(context.Background(), "c", "", map[string]string{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected error on empty URL")
|
t.Fatalf("expected error on empty URL")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package notify
|
|||||||
import "context"
|
import "context"
|
||||||
import "encoding/json"
|
import "encoding/json"
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
import "strings"
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
import "codit/internal/db"
|
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.
|
// Dispatcher delivers events to the channels attached to their source monitor.
|
||||||
type Dispatcher struct {
|
type Dispatcher struct {
|
||||||
store *db.Store
|
store *db.Store
|
||||||
logger codit_logger.Logger
|
logger codit_logger.Logger
|
||||||
secrets *secretcrypto.Box
|
secrets *secretcrypto.Box
|
||||||
policy monitor.EgressPolicy
|
policy monitor.EgressPolicy
|
||||||
client *http.Client
|
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
|
// 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;
|
// 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
|
// dataDir locates the deployment key used to decrypt transport secrets. The
|
||||||
// policy is retained so per-transport TLS variants can reuse the same dialer.
|
// 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{
|
return &Dispatcher{
|
||||||
store: store,
|
store: store,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
secrets: secretcrypto.New(dataDir),
|
secrets: secretcrypto.New(dataDir),
|
||||||
policy: policy,
|
policy: policy,
|
||||||
|
serverID: strings.TrimSpace(serverID),
|
||||||
|
siteName: strings.TrimSpace(siteName),
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
DialContext: policy.DialContext,
|
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.
|
// secretMap decrypts a stored transport secret blob into its field map.
|
||||||
func (d *Dispatcher) secretMap(encrypted string) map[string]string {
|
func (d *Dispatcher) secretMap(encrypted string) map[string]string {
|
||||||
var plain string
|
var plain string
|
||||||
|
|||||||
@@ -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"
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ func (d *Dispatcher) deliverSMTP(ctx context.Context, target models.DeliveryTarg
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
wc.Close()
|
wc.Close()
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ type webhookPayload struct {
|
|||||||
Payload string `json:"payload,omitempty"`
|
Payload string `json:"payload,omitempty"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
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
|
// 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,
|
Payload: event.Payload,
|
||||||
Channel: target.ChannelName,
|
Channel: target.ChannelName,
|
||||||
CreatedAt: event.CreatedAt,
|
CreatedAt: event.CreatedAt,
|
||||||
|
ServiceID: d.serverID,
|
||||||
|
SiteName: d.siteName,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestDeliverWebhookPostsJSONWithAuthHeader(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||||
target = models.DeliveryTarget{
|
target = models.DeliveryTarget{
|
||||||
ChannelName: "ops-webhook",
|
ChannelName: "ops-webhook",
|
||||||
TransportType: models.TransportTypeWebhook,
|
TransportType: models.TransportTypeWebhook,
|
||||||
@@ -79,7 +79,7 @@ func TestDeliverWebhookErrorsOnBadStatus(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||||
err = d.deliverWebhook(context.Background(), models.DeliveryTarget{
|
err = d.deliverWebhook(context.Background(), models.DeliveryTarget{
|
||||||
ChannelName: "ops-webhook",
|
ChannelName: "ops-webhook",
|
||||||
TransportType: models.TransportTypeWebhook,
|
TransportType: models.TransportTypeWebhook,
|
||||||
@@ -94,7 +94,7 @@ func TestClientForDefaultsToSharedClient(t *testing.T) {
|
|||||||
var d *Dispatcher
|
var d *Dispatcher
|
||||||
var c *http.Client
|
var c *http.Client
|
||||||
var err error
|
var err error
|
||||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||||
c, err = d.clientFor(webhookConfig{})
|
c, err = d.clientFor(webhookConfig{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -108,7 +108,7 @@ func TestClientForInsecureSkipVerifyBuildsClient(t *testing.T) {
|
|||||||
var d *Dispatcher
|
var d *Dispatcher
|
||||||
var c *http.Client
|
var c *http.Client
|
||||||
var err error
|
var err error
|
||||||
d = NewDispatcher(nil, monitor.DefaultEgressPolicy(), "", nil)
|
d = NewDispatcher(nil, "", "", monitor.DefaultEgressPolicy(), "", nil)
|
||||||
c, err = d.clientFor(webhookConfig{InsecureSkipVerify: true})
|
c, err = d.clientFor(webhookConfig{InsecureSkipVerify: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -123,7 +123,7 @@ func TestClientForFailsClosedWhenCAUnresolvable(t *testing.T) {
|
|||||||
var err error
|
var err error
|
||||||
// nil store => the pinned CA cannot be resolved; must fail closed rather
|
// nil store => the pinned CA cannot be resolved; must fail closed rather
|
||||||
// than fall back to system roots.
|
// 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"})
|
_, err = d.clientFor(webhookConfig{CARef: "ca-123"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected fail-closed error when CA cannot be resolved")
|
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) {
|
func TestDeliverWebhookErrorsOnEmptyAddress(t *testing.T) {
|
||||||
var d *Dispatcher
|
var d *Dispatcher
|
||||||
var err error
|
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())
|
err = d.deliverWebhook(context.Background(), models.DeliveryTarget{ChannelName: "x", TransportType: models.TransportTypeWebhook}, map[string]string{}, testEvent())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected error on empty address, got nil")
|
t.Fatalf("expected error on empty address, got nil")
|
||||||
|
|||||||
+3
-1
@@ -699,7 +699,7 @@ func newServerWithInternalConfig(cfg *codit_config.Config, logger Logger, identi
|
|||||||
// External-delivery relay: drains the events outbox and dispatches each event
|
// External-delivery relay: drains the events outbox and dispatches each event
|
||||||
// to its matching notification channels (rules + per-monitor attachment). One
|
// to its matching notification channels (rules + per-monitor attachment). One
|
||||||
// path for every producer (monitoring, RPM mirror, …).
|
// 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.upload_store = &storage.FileStore{BaseDir: app.uploads_base_dir}
|
||||||
app.repo_locks = repolock.NewManager()
|
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", txRead(api.ListAllBoards))
|
||||||
router.Handle("GET", "/api/boards/:boardId", txRead(api.GetBoard))
|
router.Handle("GET", "/api/boards/:boardId", txRead(api.GetBoard))
|
||||||
router.Handle("PATCH", "/api/boards/:boardId", txWrite(api.UpdateBoard))
|
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("DELETE", "/api/boards/:boardId", txWrite(api.DeleteBoard))
|
||||||
router.Handle("GET", "/api/boards/:boardId/blocks", txRead(api.ListBlocks))
|
router.Handle("GET", "/api/boards/:boardId/blocks", txRead(api.ListBlocks))
|
||||||
router.Handle("POST", "/api/boards/:boardId/blocks", txWrite(api.CreateBlock))
|
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", txRead(api.ListAllRepos))
|
||||||
router.Handle("GET", "/api/repos/:id", txRead(api.GetRepo))
|
router.Handle("GET", "/api/repos/:id", txRead(api.GetRepo))
|
||||||
router.Handle("PATCH", "/api/repos/:id", txWrite(api.UpdateRepo))
|
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("DELETE", "/api/repos/:id", api.DeleteRepo)
|
||||||
router.Handle("GET", "/api/repos/:id/branches", api.RepoBranches)
|
router.Handle("GET", "/api/repos/:id/branches", api.RepoBranches)
|
||||||
router.Handle("GET", "/api/repos/:id/branches/info", api.RepoBranchesInfo)
|
router.Handle("GET", "/api/repos/:id/branches/info", api.RepoBranchesInfo)
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ export interface Repo {
|
|||||||
owner_slug?: string
|
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 {
|
export interface RepoStats {
|
||||||
repo_id: string
|
repo_id: string
|
||||||
branches: number
|
branches: number
|
||||||
@@ -1563,6 +1569,12 @@ export interface Board {
|
|||||||
delete_at?: number
|
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 = {
|
export type BoardCreatePayload = {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
@@ -2477,6 +2489,8 @@ export const api = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
deleteRepo: (repoId: string) => request<void>(`/api/repos/${repoId}`, { method: 'DELETE' }),
|
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`),
|
listSelectableSigningKeys: (projectId: string) => request<GPGKey[]>(`/api/projects/${projectId}/selectable-signing-keys`),
|
||||||
listAvailableRepos: (projectId: string, query?: string, limit?: number, offset?: number) => {
|
listAvailableRepos: (projectId: string, query?: string, limit?: number, offset?: number) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
@@ -2742,6 +2756,8 @@ export const api = {
|
|||||||
updateBoard: (boardId: string, payload: BoardUpdatePayload) =>
|
updateBoard: (boardId: string, payload: BoardUpdatePayload) =>
|
||||||
request<Board>(`/api/boards/${boardId}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
request<Board>(`/api/boards/${boardId}`, { method: 'PATCH', body: JSON.stringify(payload) }),
|
||||||
deleteBoard: (boardId: string) => request<void>(`/api/boards/${boardId}`, { method: 'DELETE' }),
|
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) => {
|
listBlocks: (boardId: string, type?: string) => {
|
||||||
const params = new URLSearchParams()
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -556,7 +556,7 @@ function TextConverterWidget(props: UtilityWidgetProps) {
|
|||||||
defaultRatio={0.45}
|
defaultRatio={0.45}
|
||||||
sx={{ flex: 1, minHeight: 0 }}
|
sx={{ flex: 1, minHeight: 0 }}
|
||||||
left={
|
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>
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Input</Typography>
|
||||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||||
<LazyCodeEditorField value={input} onChange={setInput} language="plaintext" height="100%" ariaLabel="Input" placeholder="Text to convert" />
|
<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}
|
defaultRatio={0.45}
|
||||||
sx={{ flex: 1, minHeight: 0 }}
|
sx={{ flex: 1, minHeight: 0 }}
|
||||||
left={
|
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>
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Input JSON</Typography>
|
||||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||||
<LazyCodeEditorField value={input} onChange={setInput} language="json" height="100%" ariaLabel="Input JSON" placeholder="Paste or type JSON" />
|
<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}
|
defaultRatio={0.45}
|
||||||
sx={{ flex: 1, minHeight: 0 }}
|
sx={{ flex: 1, minHeight: 0 }}
|
||||||
left={
|
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>
|
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>Input XML</Typography>
|
||||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
<Box sx={{ flex: 1, minHeight: 0 }}>
|
||||||
<LazyCodeEditorField value={input} onChange={setInput} language="markup" height="100%" ariaLabel="Input XML" placeholder="Paste or type XML" />
|
<LazyCodeEditorField value={input} onChange={setInput} language="markup" height="100%" ariaLabel="Input XML" placeholder="Paste or type XML" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
|
||||||
|
import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -17,6 +18,7 @@ import PageAlert from '../components/PageAlert'
|
|||||||
import ProjectPageFrame from '../components/ProjectPageFrame'
|
import ProjectPageFrame from '../components/ProjectPageFrame'
|
||||||
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
|
import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortableTable'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
|
import TransferProjectDialog from '../components/TransferProjectDialog'
|
||||||
|
|
||||||
export default function ProjectBoardsPage() {
|
export default function ProjectBoardsPage() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
@@ -25,6 +27,8 @@ export default function ProjectBoardsPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
const [canWrite, setCanWrite] = useState(false)
|
const [canWrite, setCanWrite] = useState(false)
|
||||||
|
const [canAdmin, setCanAdmin] = useState(false)
|
||||||
|
const [transferBoard, setTransferBoard] = useState<Board | null>(null)
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [createError, setCreateError] = useState<string | null>(null)
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
@@ -62,12 +66,17 @@ export default function ProjectBoardsPage() {
|
|||||||
.then(([me, members]) => {
|
.then(([me, members]) => {
|
||||||
if (me.is_admin) {
|
if (me.is_admin) {
|
||||||
setCanWrite(true)
|
setCanWrite(true)
|
||||||
|
setCanAdmin(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const match = members.find((member) => member.user_id === me.id)
|
const match = members.find((member) => member.user_id === me.id)
|
||||||
setCanWrite(Boolean(match && (match.role === 'admin' || match.role === 'writer')))
|
setCanWrite(Boolean(match && (match.role === 'admin' || match.role === 'writer')))
|
||||||
|
setCanAdmin(Boolean(match && match.role === 'admin'))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setCanWrite(false)
|
||||||
|
setCanAdmin(false)
|
||||||
})
|
})
|
||||||
.catch(() => setCanWrite(false))
|
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,6 +182,13 @@ export default function ProjectBoardsPage() {
|
|||||||
>
|
>
|
||||||
<EditIcon fontSize="small" />
|
<EditIcon fontSize="small" />
|
||||||
</ListRowIconAction>
|
</ListRowIconAction>
|
||||||
|
<ListRowIconAction
|
||||||
|
title={canAdmin ? 'Transfer to another project' : 'Only project admins can transfer'}
|
||||||
|
disabled={!canAdmin}
|
||||||
|
onClick={() => setTransferBoard(board)}
|
||||||
|
>
|
||||||
|
<DriveFileMoveOutlinedIcon fontSize="small" />
|
||||||
|
</ListRowIconAction>
|
||||||
<ListRowIconAction
|
<ListRowIconAction
|
||||||
title={canWrite ? 'Delete board' : 'You do not have permission to delete'}
|
title={canWrite ? 'Delete board' : 'You do not have permission to delete'}
|
||||||
color="error"
|
color="error"
|
||||||
@@ -250,6 +266,19 @@ export default function ProjectBoardsPage() {
|
|||||||
onClose={() => { setDeleteBoard(null); setDeleteError(null); setDeleteConfirmValue('') }}
|
onClose={() => { setDeleteBoard(null); setDeleteError(null); setDeleteConfirmValue('') }}
|
||||||
onConfirm={handleDelete}
|
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>
|
</ProjectPageFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'
|
||||||
|
import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import LinkOffIcon from '@mui/icons-material/LinkOff'
|
import LinkOffIcon from '@mui/icons-material/LinkOff'
|
||||||
import LinkIcon from '@mui/icons-material/Link'
|
import LinkIcon from '@mui/icons-material/Link'
|
||||||
@@ -28,6 +29,7 @@ import ResizableSortableTable, { RSTColumn } from '../components/ResizableSortab
|
|||||||
import RepositoryFormDialog from '../components/RepositoryFormDialog'
|
import RepositoryFormDialog from '../components/RepositoryFormDialog'
|
||||||
import SectionCard from '../components/SectionCard'
|
import SectionCard from '../components/SectionCard'
|
||||||
import SelectField from '../components/SelectField'
|
import SelectField from '../components/SelectField'
|
||||||
|
import TransferProjectDialog from '../components/TransferProjectDialog'
|
||||||
|
|
||||||
type RepoAccessInfo = {
|
type RepoAccessInfo = {
|
||||||
label: string
|
label: string
|
||||||
@@ -91,6 +93,8 @@ export default function ProjectReposPage() {
|
|||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
const [canWrite, setCanWrite] = useState(false)
|
const [canWrite, setCanWrite] = useState(false)
|
||||||
|
const [canAdmin, setCanAdmin] = useState(false)
|
||||||
|
const [transferRepo, setTransferRepo] = useState<Repo | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return
|
if (!projectId) return
|
||||||
@@ -146,12 +150,17 @@ export default function ProjectReposPage() {
|
|||||||
.then(([me, members]) => {
|
.then(([me, members]) => {
|
||||||
if (me.is_admin) {
|
if (me.is_admin) {
|
||||||
setCanWrite(true)
|
setCanWrite(true)
|
||||||
|
setCanAdmin(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const match = members.find((member) => member.user_id === me.id)
|
const match = members.find((member) => member.user_id === me.id)
|
||||||
setCanWrite(Boolean(match && (match.role === 'admin' || match.role === 'writer')))
|
setCanWrite(Boolean(match && (match.role === 'admin' || match.role === 'writer')))
|
||||||
|
setCanAdmin(Boolean(match && match.role === 'admin'))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setCanWrite(false)
|
||||||
|
setCanAdmin(false)
|
||||||
})
|
})
|
||||||
.catch(() => setCanWrite(false))
|
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
|
|
||||||
useEffect(() => {
|
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)}>
|
<ListRowIconAction title={canWrite ? 'Edit repository' : 'You do not have permission to edit'} disabled={!canWrite} onClick={() => openEdit(repo)}>
|
||||||
<EditIcon fontSize="small" />
|
<EditIcon fontSize="small" />
|
||||||
</ListRowIconAction>
|
</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)}>
|
<ListRowIconAction title={canWrite ? 'Delete repository' : 'You do not have permission to delete'} color="error" disabled={!canWrite} onClick={() => openDelete(repo)}>
|
||||||
<DeleteOutlinedIcon fontSize="small" />
|
<DeleteOutlinedIcon fontSize="small" />
|
||||||
</ListRowIconAction>
|
</ListRowIconAction>
|
||||||
@@ -800,6 +812,22 @@ export default function ProjectReposPage() {
|
|||||||
onErrorClose={() => setForeignError(null)}
|
onErrorClose={() => setForeignError(null)}
|
||||||
onClose={closeForeign}
|
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>
|
</ProjectPageFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user