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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<Modal open={open} title={account ? `Edit credentials — ${account.src_login}` : 'Edit credentials'} onClose={onClose}>
|
||||
<form onSubmit={submit}>
|
||||
<p className="map-hint">
|
||||
Leave a password field empty to keep the stored one. Saving resets both connection tests, so re-run{' '}
|
||||
<strong>Test connections</strong> afterwards.
|
||||
</p>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="edit_src_login">Source login</label>
|
||||
<input
|
||||
id="edit_src_login"
|
||||
data-modal-autofocus
|
||||
value={srcLogin}
|
||||
onChange={(e) => setSrcLogin(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="edit_src_pass">Source password</label>
|
||||
<input
|
||||
id="edit_src_pass"
|
||||
type="password"
|
||||
value={srcPass}
|
||||
onChange={(e) => setSrcPass(e.target.value)}
|
||||
placeholder="unchanged"
|
||||
autoComplete="new-password"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label htmlFor="edit_dst_login">Destination login</label>
|
||||
<input
|
||||
id="edit_dst_login"
|
||||
value={dstLogin}
|
||||
onChange={(e) => setDstLogin(e.target.value)}
|
||||
disabled={busy}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="edit_dst_pass">Destination password</label>
|
||||
<input
|
||||
id="edit_dst_pass"
|
||||
type="password"
|
||||
value={dstPass}
|
||||
onChange={(e) => setDstPass(e.target.value)}
|
||||
placeholder="unchanged"
|
||||
autoComplete="new-password"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save credentials'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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<Account | null>(null)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const fileInputRef = useRef<HTMLInputElement>(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' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="badge-btn"
|
||||
title="Edit credentials for this account"
|
||||
onClick={() => setCredsFor(a)}
|
||||
>
|
||||
<StatusBadge status={status} />
|
||||
</button>
|
||||
) : (
|
||||
<StatusBadge status={status} />
|
||||
)
|
||||
// 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 }) {
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={a.test_src_status} />
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={a.test_dst_status} />
|
||||
</td>
|
||||
<td>{testCell(a, a.test_src_status)}</td>
|
||||
<td>{testCell(a, a.test_dst_status)}</td>
|
||||
<td>
|
||||
<StatusBadge status={a.status} />
|
||||
</td>
|
||||
@@ -820,6 +849,13 @@ export function TaskDetail({ id }: { id: number }) {
|
||||
onClose={() => setKerioOpen(false)}
|
||||
onSubmit={onKerioImport}
|
||||
/>
|
||||
<AccountCredentialsModal
|
||||
open={credsFor !== null}
|
||||
busy={busy === 'add'}
|
||||
account={credsFor}
|
||||
onClose={() => setCredsFor(null)}
|
||||
onSubmit={saveCredentials}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user