mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
0c68bbc73d
* fix(store): an admin resend revokes and re-creates in one transaction that re-reads the workspace (BUG-3817) handleAdminResendInvitation checked the workspace was live, then revoked the invitation and created a new one in two separate transactions. A workspace deleted between the check and the create still got a fresh code and an email, and a create refused after the revoke (a disabled inviter) left the invitee with no invitation. ResendInvitationAdmin now does both in one transaction under the workspace lock, and createInvitationTx, which CreateInvitation shares, reads the workspace's liveness FOR SHARE, a lock a soft delete's update conflicts with. Both handlers map the new refusals to 404. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * fix(store): a resend revokes only after its user and workspace row locks (BUG-3817) Codex r1: the resend revoked the old invitation before createInvitationTx locked the inviter and the workspace. Account deletion locks the user, soft-deletes their workspaces and then deletes the invitations they sent, so the two took the rows in opposite orders and deadlocked. The revoke moves inside createInvitationTx, after those locks. The race tests wait for the resend's own liveness read, not for any lock wait in the database, and a new test replays the deletion's order against a resend. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(store): a resend of an accepted invitation is refused; the disable fixture uses the guarded door (BUG-3817) The lead's r1 M2 condition: a sequential test that resends an accepted invitation and asserts ErrInvitationNotPending, with the accepted row and the membership kept. With the pre-transaction check and the in-transaction re-read both removed, it stays green on the revoke's affected-row count, and goes red only once that DELETE stops filtering accepted_at IS NULL. Rebased onto main past BUG-3826 (#2196), which removed Store.DisableUser: the refused-resend fixture now disables through DisableUserAndRevokeAccess. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
171 lines
4.8 KiB
Go
171 lines
4.8 KiB
Go
package server
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/email"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// handleAdminListInvitations returns all pending invitations across all workspaces.
|
|
// GET /api/v1/admin/invitations?q=search
|
|
func (s *Server) handleAdminListInvitations(w http.ResponseWriter, r *http.Request) {
|
|
if !requireAdmin(w, r) {
|
|
return
|
|
}
|
|
|
|
query := r.URL.Query().Get("q")
|
|
invitations, err := s.store.ListPendingInvitationsAdmin(query)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if invitations == nil {
|
|
invitations = []store.AdminInvitation{}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"invitations": invitations,
|
|
})
|
|
}
|
|
|
|
// handleAdminResendInvitation revokes the old invitation and creates a fresh one
|
|
// with a new code, then sends the invitation email.
|
|
// POST /api/v1/admin/invitations/{invID}/resend
|
|
func (s *Server) handleAdminResendInvitation(w http.ResponseWriter, r *http.Request) {
|
|
if !requireAdmin(w, r) {
|
|
return
|
|
}
|
|
|
|
invID := chi.URLParam(r, "invID")
|
|
old, err := s.store.GetInvitation(invID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if old == nil || old.AcceptedAt != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
|
|
return
|
|
}
|
|
// An invitation can outlive its workspace (BUG-3104): DeleteAccountAtomic
|
|
// removes only the invitations the departing user SENT. Resending one would
|
|
// mint a live code for a deleted workspace and email the invitee about it,
|
|
// so it answers as not found — the same answer the code lookup now gives.
|
|
// GetWorkspaceByID reads only live workspaces.
|
|
ws, err := s.store.GetWorkspaceByID(old.WorkspaceID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
if ws == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
|
|
return
|
|
}
|
|
|
|
caller := currentUser(r)
|
|
inviterID := old.InvitedBy
|
|
if caller != nil {
|
|
inviterID = caller.ID
|
|
}
|
|
|
|
// The revoke and the new invitation are one transaction that re-reads the
|
|
// workspace's liveness under its lock (BUG-3817): a refused create can no
|
|
// longer lose the original, and a workspace deleted since the check above
|
|
// gets no new code.
|
|
inv, err := s.store.ResendInvitationAdmin(invID, inviterID)
|
|
if writeAccountDisabledIf(w, err) {
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrInvitationNotPending) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Invitation is no longer pending")
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrInvitationWorkspaceDeleted) {
|
|
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// Send email
|
|
joinURL := ""
|
|
if s.baseURL != "" {
|
|
joinURL = s.baseURL + "/join/" + inv.Code
|
|
}
|
|
|
|
if s.email != nil && joinURL != "" {
|
|
// Respect email opt-out preferences
|
|
if optedOut, err := s.store.IsEmailOptedOut(inv.Email); err == nil && optedOut {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"method": "code",
|
|
"message": "Invitation recreated but email not sent (recipient opted out)",
|
|
})
|
|
return
|
|
}
|
|
|
|
inviterName := "An administrator"
|
|
wsName := "a workspace"
|
|
if caller != nil {
|
|
inviterName = caller.Name
|
|
}
|
|
if ws, err := s.store.GetWorkspaceByID(old.WorkspaceID); err == nil && ws != nil {
|
|
wsName = ws.Name
|
|
}
|
|
unsubURL := email.UnsubscribeURL(s.baseURL, inv.Email, s.unsubscribeSecret())
|
|
if err := s.email.SendInvitation(r.Context(), inv.Email, inviterName, wsName, invitationEmailURL(joinURL, inv.Proof), unsubURL); err != nil {
|
|
slog.Error("failed to resend invitation email", "error", err, "email", inv.Email)
|
|
writeError(w, http.StatusInternalServerError, "email_failed", "Invitation recreated but failed to send email")
|
|
return
|
|
}
|
|
}
|
|
|
|
s.logAuditEvent(models.ActionMemberInvited, r, auditMeta(map[string]string{
|
|
"email": inv.Email,
|
|
"role": inv.Role,
|
|
"workspace": old.WorkspaceID,
|
|
"resend": "true",
|
|
}))
|
|
|
|
method := "code"
|
|
if s.email != nil && joinURL != "" {
|
|
method = "email"
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"method": method,
|
|
"message": "Invitation resent to " + inv.Email,
|
|
})
|
|
}
|
|
|
|
// handleAdminDeleteInvitation revokes a pending invitation.
|
|
// DELETE /api/v1/admin/invitations/{invID}
|
|
func (s *Server) handleAdminDeleteInvitation(w http.ResponseWriter, r *http.Request) {
|
|
if !requireAdmin(w, r) {
|
|
return
|
|
}
|
|
|
|
invID := chi.URLParam(r, "invID")
|
|
if err := s.store.DeleteInvitationAdmin(invID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
writeError(w, http.StatusNotFound, "not_found", "Pending invitation not found")
|
|
return
|
|
}
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"message": "Invitation revoked",
|
|
})
|
|
}
|