From c077b368f353d589136aac0bc46ad114a26a3c51 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 28 Jul 2026 12:08:28 +0700 Subject: [PATCH] Allow fixing an account's credentials from a failed test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/httpapi/accounts.go | 76 ++++++++++++ internal/httpapi/router.go | 1 + internal/store/accounts.go | 15 +++ internal/store/accounts_test.go | 41 +++++++ web/src/api.ts | 8 ++ web/src/app.css | 19 +++ .../components/AccountCredentialsModal.tsx | 116 ++++++++++++++++++ web/src/pages/TaskDetail.tsx | 50 ++++++-- 8 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 web/src/components/AccountCredentialsModal.tsx diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go index 822a6c7..2e005fe 100644 --- a/internal/httpapi/accounts.go +++ b/internal/httpapi/accounts.go @@ -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) diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 5b11755..0e842ae 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -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) diff --git a/internal/store/accounts.go b/internal/store/accounts.go index ef94512..1a29d1f 100644 --- a/internal/store/accounts.go +++ b/internal/store/accounts.go @@ -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) diff --git a/internal/store/accounts_test.go b/internal/store/accounts_test.go index 02277a5..1b7af61 100644 --- a/internal/store/accounts_test.go +++ b/internal/store/accounts_test.go @@ -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() diff --git a/web/src/api.ts b/web/src/api.ts index d86e583..2891f51 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -89,6 +89,14 @@ export const deleteTask = (id: number) => api(`/api/tasks/${id}`, { method: 'DEL export const deleteAccount = (taskId: number, accountId: number) => api(`/api/tasks/${taskId}/accounts/${accountId}`, { method: 'DELETE' }) +// Empty password fields keep the stored ones; both connection tests reset to +// unknown server-side, so the account must be re-tested afterwards. +export const updateAccountCredentials = ( + taskId: number, + accountId: number, + body: { src_login: string; src_pass: string; dst_login: string; dst_pass: string }, +) => api(`/api/tasks/${taskId}/accounts/${accountId}/credentials`, { ...jsonBody(body), method: 'PUT' }) + export const cancelAccount = (taskId: number, accountId: number) => api(`/api/tasks/${taskId}/accounts/${accountId}/cancel`, { method: 'POST' }) diff --git a/web/src/app.css b/web/src/app.css index e43e61f..98247a2 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -667,6 +667,25 @@ table.tbl a.rowlink:focus-visible { /* ---------- status badges ---------- */ +/* A badge that opens a dialog: the badge keeps its own look, the button only + contributes the affordance. */ +.badge-btn { + padding: 0; + border: 0; + background: none; + font: inherit; + cursor: pointer; +} + +.badge-btn:hover .badge { + filter: brightness(1.25); +} + +.badge-btn:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; +} + .badge { display: inline-flex; align-items: center; diff --git a/web/src/components/AccountCredentialsModal.tsx b/web/src/components/AccountCredentialsModal.tsx new file mode 100644 index 0000000..0a9976e --- /dev/null +++ b/web/src/components/AccountCredentialsModal.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { Modal } from './Modal' +import type { Account } from '../api' + +type Props = { + open: boolean + busy: boolean + account: Account | null + onClose: () => void + onSubmit: (body: { src_login: string; src_pass: string; dst_login: string; dst_pass: string }) => void +} + +// Fixes the credentials of an account that failed its connection test — usually +// a wrong password that came in through a CSV import. Passwords are never sent +// back to the browser, so the fields start empty and an empty field means +// "keep the stored password". +export function AccountCredentialsModal({ open, busy, account, onClose, onSubmit }: Props) { + const [srcLogin, setSrcLogin] = useState('') + const [dstLogin, setDstLogin] = useState('') + const [srcPass, setSrcPass] = useState('') + const [dstPass, setDstPass] = useState('') + const [error, setError] = useState(null) + + useEffect(() => { + if (!open || !account) return + setSrcLogin(account.src_login) + setDstLogin(account.dst_login) + setSrcPass('') + setDstPass('') + setError(null) + }, [open, account]) + + function submit(e: FormEvent) { + e.preventDefault() + if (srcLogin.trim() === '' || dstLogin.trim() === '') { + setError('Both logins are required') + return + } + setError(null) + onSubmit({ + src_login: srcLogin.trim(), + src_pass: srcPass, + dst_login: dstLogin.trim(), + dst_pass: dstPass, + }) + } + + return ( + +
+

+ Leave a password field empty to keep the stored one. Saving resets both connection tests, so re-run{' '} + Test connections afterwards. +

+
+
+ + setSrcLogin(e.target.value)} + disabled={busy} + required + /> +
+
+ + setSrcPass(e.target.value)} + placeholder="unchanged" + autoComplete="new-password" + disabled={busy} + /> +
+
+
+
+ + setDstLogin(e.target.value)} + disabled={busy} + required + /> +
+
+ + setDstPass(e.target.value)} + placeholder="unchanged" + autoComplete="new-password" + disabled={busy} + /> +
+
+ {error &&
{error}
} +
+ + +
+
+
+ ) +} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index 1ace45a..8ab5faf 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' -import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, importKerioCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, type TaskDetail as TaskDetailData } from '../api' +import { cancelAccount, createAccount, deleteAccount, getTask, importCSV, importKerioCSV, probeAccountFolders, probeFolders, runTask, setAccountFolderMapping, setTaskSchedule, testAccounts, updateAccountCredentials, type Account, type TaskDetail as TaskDetailData } from '../api' import { connectTaskWS, type TaskEvent } from '../ws' import { StatusBadge } from '../components/StatusBadge' import { useConfirm } from '../components/ConfirmProvider' @@ -7,6 +7,7 @@ import { FolderMappingModal } from '../components/FolderMappingModal' import { RunLogModal } from '../components/RunLogModal' import { AccountErrorsModal } from '../components/AccountErrorsModal' import { KerioImportModal } from '../components/KerioImportModal' +import { AccountCredentialsModal } from '../components/AccountCredentialsModal' const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' } @@ -92,6 +93,7 @@ export function TaskDetail({ id }: { id: number }) { const [showRuns, setShowRuns] = useState(false) const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null) const [kerioOpen, setKerioOpen] = useState(false) + const [credsFor, setCredsFor] = useState(null) const [selected, setSelected] = useState>(new Set()) const fileInputRef = useRef(null) @@ -324,6 +326,21 @@ export function TaskDetail({ id }: { id: number }) { } } + async function saveCredentials(body: { src_login: string; src_pass: string; dst_login: string; dst_pass: string }) { + if (!credsFor) return + setBusy('add') + setError(null) + try { + await updateAccountCredentials(id, credsFor.id, body) + setCredsFor(null) + reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save credentials') + } finally { + setBusy(null) + } + } + async function onDeleteAccount(accId: number, login: string) { const ok = await confirm({ title: 'Remove account', @@ -405,6 +422,22 @@ export function TaskDetail({ id }: { id: number }) { const { task, accounts } = data const isRunning = task.status === 'running' + + // A failed connection test is the entry point for fixing the credentials that + // caused it — an imported account is otherwise only deletable. + const testCell = (a: Account, status: string) => + status === 'fail' && !isRunning && a.status !== 'running' ? ( + + ) : ( + + ) // A row is selectable only when both connection tests pass and no run is live. const selectableIds = accounts .filter((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok') @@ -677,12 +710,8 @@ export function TaskDetail({ id }: { id: number }) { )} - - - - - - + {testCell(a, a.test_src_status)} + {testCell(a, a.test_dst_status)} @@ -820,6 +849,13 @@ export function TaskDetail({ id }: { id: number }) { onClose={() => setKerioOpen(false)} onSubmit={onKerioImport} /> + setCredsFor(null)} + onSubmit={saveCredentials} + /> ) }