Allow fixing an account's credentials from a failed test

An imported account with a wrong password could only be deleted and
re-added, which loses its folder mapping and its migration journal.
Clicking either FAIL badge now opens a dialog for both logins and both
passwords.

Passwords are never sent to the browser, so the password fields start
empty and an empty field keeps the stored ciphertext — one side can be
corrected without retyping the other. Saving resets both test verdicts
to unknown: they described the previous credentials, and the run gate
requires a passing test on both sides, so the account cannot start on an
unverified password.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 12:08:28 +07:00
co-authored by Claude Opus 5
parent 76ada57dd7
commit c077b368f3
8 changed files with 319 additions and 7 deletions
+76
View File
@@ -154,6 +154,82 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]int64{"id": id})
}
// handleUpdateAccountCredentials fixes the logins/passwords of an existing
// account — typically after an import brought in a wrong password and the
// connection test failed. An empty password field keeps the stored one, so the
// operator can correct one side without retyping the other.
func (s *Server) handleUpdateAccountCredentials(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
accID, err := pathID(r, "accountId")
if err != nil {
http.Error(w, "bad account id", http.StatusBadRequest)
return
}
task, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
acc, ok := s.findAccount(r, taskID, accID)
if !ok {
http.Error(w, "account not found", http.StatusNotFound)
return
}
if task.Status == "running" || acc.Status == "running" {
http.Error(w, "cannot change credentials while the account is running", http.StatusConflict)
return
}
var body struct {
SrcLogin string `json:"src_login"`
SrcPass string `json:"src_pass"`
DstLogin string `json:"dst_login"`
DstPass string `json:"dst_pass"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// Same trimming as account creation: pasted logins/passwords often carry a
// stray space or newline that the IMAP server rejects.
body.SrcLogin = strings.TrimSpace(body.SrcLogin)
body.DstLogin = strings.TrimSpace(body.DstLogin)
body.SrcPass = strings.TrimSpace(body.SrcPass)
body.DstPass = strings.TrimSpace(body.DstPass)
if body.SrcLogin == "" || body.DstLogin == "" {
http.Error(w, "src_login and dst_login are required", http.StatusBadRequest)
return
}
encrypt := func(pass string) (*string, error) {
if pass == "" {
return nil, nil // keep the stored password
}
enc, err := crypto.Encrypt(s.cfg.EncKey, []byte(pass))
if err != nil {
return nil, err
}
return &enc, nil
}
srcEnc, err := encrypt(body.SrcPass)
if err != nil {
http.Error(w, "encrypt", http.StatusInternalServerError)
return
}
dstEnc, err := encrypt(body.DstPass)
if err != nil {
http.Error(w, "encrypt", http.StatusInternalServerError)
return
}
if err := s.store.UpdateAccountCredentials(r.Context(), accID, body.SrcLogin, body.DstLogin, srcEnc, dstEnc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// findAccount returns the account with accID under taskID, or ok=false.
func (s *Server) findAccount(r *http.Request, taskID, accID int64) (store.Account, bool) {
accs, err := s.store.ListAccountsByTask(r.Context(), taskID)
+1
View File
@@ -27,6 +27,7 @@ func (s *Server) Router() http.Handler {
api.HandleFunc("GET /api/tasks/{id}/runs", s.handleListRuns)
api.HandleFunc("GET /api/tasks/{id}/accounts/{accountId}/errors", s.handleListAccountErrors)
api.HandleFunc("DELETE /api/tasks/{id}/accounts/{accountId}", s.handleDeleteAccount)
api.HandleFunc("PUT /api/tasks/{id}/accounts/{accountId}/credentials", s.handleUpdateAccountCredentials)
api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV)
api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts)
api.HandleFunc("POST /api/tasks/{id}/run", s.handleRun)
+15
View File
@@ -32,6 +32,21 @@ func (s *Store) CreateAccount(ctx context.Context, a Account) (int64, error) {
return id, err
}
// UpdateAccountCredentials replaces an account's logins and, when a new
// ciphertext is supplied, its passwords; a nil password keeps the stored one so
// the operator can fix only the side that failed. Both connection tests are
// reset to "unknown" because the previous verdicts no longer describe these
// credentials, which also forces a re-test before the account can run.
func (s *Store) UpdateAccountCredentials(ctx context.Context, id int64, srcLogin, dstLogin string, srcPassEnc, dstPassEnc *string) error {
_, err := s.Pool.Exec(ctx,
`UPDATE accounts SET src_login=$2, dst_login=$3,
src_pass_enc=COALESCE($4, src_pass_enc), dst_pass_enc=COALESCE($5, dst_pass_enc),
test_src_status='unknown', test_dst_status='unknown', last_error=''
WHERE id=$1`,
id, srcLogin, dstLogin, srcPassEnc, dstPassEnc)
return err
}
// DeleteAccount removes one account (and its migrated_messages via ON DELETE CASCADE).
func (s *Store) DeleteAccount(ctx context.Context, id int64) error {
_, err := s.Pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, id)
+41
View File
@@ -65,6 +65,47 @@ func TestResetAccountCounters(t *testing.T) {
}
}
// Fixing an imported account's credentials must replace only what the operator
// supplied: a nil password keeps the stored ciphertext, and both test verdicts
// go back to unknown because they described the old credentials.
func TestUpdateAccountCredentials(t *testing.T) {
s := testStore(t)
ctx := context.Background()
epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"})
epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"})
taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst})
accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "oldsrc", DstLogin: "u2", DstPassEnc: "olddst"})
_ = s.SetAccountTestStatus(ctx, accID, "src", "fail")
_ = s.SetAccountTestStatus(ctx, accID, "dst", "ok")
_ = s.SetAccountError(ctx, accID, "authentication failed")
newSrc := "newsrc"
if err := s.UpdateAccountCredentials(ctx, accID, "u@src.example", "u@dst.example", &newSrc, nil); err != nil {
t.Fatalf("update: %v", err)
}
accs, _ := s.ListAccountsByTask(ctx, taskID)
if len(accs) != 1 {
t.Fatalf("len=%d want 1", len(accs))
}
a := accs[0]
if a.SrcLogin != "u@src.example" || a.DstLogin != "u@dst.example" {
t.Fatalf("logins not updated: %q / %q", a.SrcLogin, a.DstLogin)
}
if a.SrcPassEnc != "newsrc" {
t.Fatalf("src password not updated: %q", a.SrcPassEnc)
}
if a.DstPassEnc != "olddst" {
t.Fatalf("nil password must keep the stored one, got %q", a.DstPassEnc)
}
if a.TestSrcStatus != "unknown" || a.TestDstStatus != "unknown" {
t.Fatalf("test statuses not reset: %q / %q", a.TestSrcStatus, a.TestDstStatus)
}
if a.LastError != "" {
t.Fatalf("last_error not cleared: %q", a.LastError)
}
}
func TestSetAccountFolderMapping(t *testing.T) {
s := testStore(t)
ctx := context.Background()